From 76c69174b672456971e1cff34678be6ba46e2db8 Mon Sep 17 00:00:00 2001 From: anupsv <6407789+anupsv@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:25:50 -0700 Subject: [PATCH 1/3] harden provider-bound identity --- .../Sources/EigenInference/CLIRunner.swift | 17 +- coordinator/cmd/coordinator/main.go | 1 + coordinator/internal/api/edge_case_test.go | 1 + coordinator/internal/api/install.sh | 106 ++++- coordinator/internal/api/provider.go | 277 +++++++++-- .../internal/api/provider_identity_test.go | 399 ++++++++++++++++ coordinator/internal/api/release_handlers.go | 4 + coordinator/internal/api/server.go | 139 +++++- .../internal/attestation/provider_identity.go | 191 ++++++++ .../attestation/provider_identity_test.go | 139 ++++++ coordinator/internal/protocol/messages.go | 50 +- coordinator/internal/registry/registry.go | 120 ++--- .../internal/registry/registry_test.go | 44 ++ .../EigenInferenceEnclave/Bridge.swift | 80 +++- .../ProviderBoundIdentity.swift | 116 +++++ .../EigenInferenceEnclaveCLI/main.swift | 26 ++ .../SecureEnclaveTests.swift | 8 +- enclave/include/eigeninference_enclave.h | 28 ++ provider/src/coordinator.rs | 440 +++++++++++++++++- provider/src/main.rs | 305 ++++++++++-- provider/src/protocol.rs | 33 ++ provider/src/secure_enclave_key.rs | 91 ++++ provider/src/security.rs | 35 ++ scripts/build-bundle.sh | 16 +- scripts/bundle-app.sh | 17 +- scripts/entitlements.plist | 2 +- scripts/install.sh | 106 ++++- 27 files changed, 2547 insertions(+), 244 deletions(-) create mode 100644 coordinator/internal/api/provider_identity_test.go create mode 100644 coordinator/internal/attestation/provider_identity.go create mode 100644 coordinator/internal/attestation/provider_identity_test.go create mode 100644 enclave/Sources/EigenInferenceEnclave/ProviderBoundIdentity.swift diff --git a/app/EigenInference/Sources/EigenInference/CLIRunner.swift b/app/EigenInference/Sources/EigenInference/CLIRunner.swift index 0f7c6ff0..bfdd19b0 100644 --- a/app/EigenInference/Sources/EigenInference/CLIRunner.swift +++ b/app/EigenInference/Sources/EigenInference/CLIRunner.swift @@ -32,14 +32,8 @@ final class CLIRunner { static func resolveBinaryPath() -> String? { let fm = FileManager.default - // 1. ~/.darkbloom/bin/darkbloom (shared with CLI — single source of truth) - let home = fm.homeDirectoryForCurrentUser - let homeBin = home.appendingPathComponent(".darkbloom/bin/darkbloom").path - if fm.isExecutableFile(atPath: homeBin) { - return homeBin - } - - // 2. Inside app bundle (fallback) + // 1. Inside app bundle. Prefer the signed, notarized provider shipped + // with this app over any developer/home install path. if let bundlePath = Bundle.main.executablePath { let bundleDir = (bundlePath as NSString).deletingLastPathComponent let adjacent = (bundleDir as NSString).appendingPathComponent("darkbloom") @@ -48,6 +42,13 @@ final class CLIRunner { } } + // 2. ~/.darkbloom/bin/darkbloom (shared with CLI fallback) + let home = fm.homeDirectoryForCurrentUser + let homeBin = home.appendingPathComponent(".darkbloom/bin/darkbloom").path + if fm.isExecutableFile(atPath: homeBin) { + return homeBin + } + // 3. PATH lookup let whichProcess = Process() let whichPipe = Pipe() diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index f2c69ee3..e14e00e8 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -134,6 +134,7 @@ func main() { seedModelCatalog(st, logger) reg := registry.New(logger) + reg.SetRequireProviderIdentity(true) // Set minimum trust level for routing. Default: hardware (production). // Set EIGENINFERENCE_MIN_TRUST=none or EIGENINFERENCE_MIN_TRUST=self_signed for testing. diff --git a/coordinator/internal/api/edge_case_test.go b/coordinator/internal/api/edge_case_test.go index a571d4c0..00f9f08a 100644 --- a/coordinator/internal/api/edge_case_test.go +++ b/coordinator/internal/api/edge_case_test.go @@ -744,6 +744,7 @@ func TestEdge_ReleaseRegisterMissingFields(t *testing.T) { // platform defaults to "macos-arm64" when omitted, so omit a truly required field instead {"empty_version", `{"version":"","platform":"macos-arm64","binary_hash":"abc","bundle_hash":"def","url":"http://example.com/b.tar.gz"}`}, {"missing_hash", `{"version":"1.0.0","platform":"macos-arm64","url":"http://example.com/b.tar.gz"}`}, + {"missing_bundle_hash", `{"version":"1.0.0","platform":"macos-arm64","binary_hash":"abc","url":"http://example.com/b.tar.gz"}`}, {"missing_url", `{"version":"1.0.0","platform":"macos-arm64","binary_hash":"abc","bundle_hash":"def"}`}, } diff --git a/coordinator/internal/api/install.sh b/coordinator/internal/api/install.sh index 8a555516..3aa0598c 100644 --- a/coordinator/internal/api/install.sh +++ b/coordinator/internal/api/install.sh @@ -82,36 +82,111 @@ echo "" echo "→ [2/7] Downloading Darkbloom v${VERSION}..." mkdir -p "$INSTALL_DIR" "$BIN_DIR" -curl -f#L "$BUNDLE_URL" -o "/tmp/eigeninference-bundle.tar.gz" +DOWNLOAD_PATH=$(mktemp /tmp/darkbloom-bundle.XXXXXX.tar.gz) +STAGE_DIR="" +cleanup_install_tmp() { + rm -rf "${STAGE_DIR:-}" "$DOWNLOAD_PATH" /tmp/darkbloom-tar-members.$$ +} +trap cleanup_install_tmp EXIT + +curl -f#L "$BUNDLE_URL" -o "$DOWNLOAD_PATH" + +if [ -z "$BUNDLE_HASH" ]; then + echo " ✗ Release did not include bundle_hash; refusing unsigned update metadata" + exit 1 +fi # Verify bundle hash -ACTUAL_HASH=$(shasum -a 256 /tmp/eigeninference-bundle.tar.gz | cut -d' ' -f1) +ACTUAL_HASH=$(shasum -a 256 "$DOWNLOAD_PATH" | cut -d' ' -f1) if [ "$ACTUAL_HASH" != "$BUNDLE_HASH" ]; then echo "" echo " ✗ Bundle hash mismatch — download may be corrupted." echo " Expected: $BUNDLE_HASH" echo " Got: $ACTUAL_HASH" - rm -f /tmp/eigeninference-bundle.tar.gz exit 1 fi echo "" echo " Hash verified ✓" +validate_tarball() { + local archive="$1" + if ! tar tzf "$archive" >/tmp/darkbloom-tar-members.$$ 2>/dev/null; then + echo " ✗ Could not read release tarball" + rm -f /tmp/darkbloom-tar-members.$$ + exit 1 + fi + while IFS= read -r member; do + case "$member" in + ""|/*|..|../*|*/..|*/../*) + echo " ✗ Release tarball contains unsafe path: $member" + rm -f /tmp/darkbloom-tar-members.$$ + exit 1 + ;; + esac + done < /tmp/darkbloom-tar-members.$$ + rm -f /tmp/darkbloom-tar-members.$$ + + if tar tvzf "$archive" | awk 'substr($1,1,1) == "l" || substr($1,1,1) == "h" { bad = 1 } END { exit bad ? 1 : 0 }'; then + return + fi + echo " ✗ Release tarball contains links; refusing unsafe archive" + exit 1 +} + +# Verify code signature and Team ID (codesign is part of base macOS, no CLT needed) +EXPECTED_TEAM="SLDQ2GJ6TL" +EXPECTED_GROUP="SLDQ2GJ6TL.io.darkbloom.provider" +verify_darkbloom_binary() { + local bin="$1" + local name="$2" + if ! codesign --verify --strict --verbose "$bin" >/dev/null 2>&1; then + echo " ✗ $name code signature could not be verified" + exit 1 + fi + local team + team=$(codesign -dvv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p' | head -1) + if [ "$team" != "$EXPECTED_TEAM" ]; then + echo " ✗ $name signed by unexpected TeamIdentifier: ${team:-missing}" + echo " Expected: $EXPECTED_TEAM" + exit 1 + fi + if ! codesign -d --entitlements :- "$bin" 2>/dev/null | grep -q "$EXPECTED_GROUP"; then + echo " ✗ $name missing provider keychain access-group entitlement" + exit 1 + fi + if command -v spctl >/dev/null 2>&1 && ! spctl -a -t execute "$bin" >/dev/null 2>&1; then + echo " ✗ $name was not accepted by Gatekeeper/notarization policy" + exit 1 + fi + echo " $name signature verified ✓ (Team: $team)" +} + +staged_binary() { + if [ -f "$STAGE_DIR/bin/$1" ]; then + echo "$STAGE_DIR/bin/$1" + else + echo "$STAGE_DIR/$1" + fi +} + echo " Installing binaries..." -tar xzf /tmp/eigeninference-bundle.tar.gz -C "$INSTALL_DIR" +STAGE_DIR=$(mktemp -d /tmp/darkbloom-install.XXXXXX) +validate_tarball "$DOWNLOAD_PATH" +tar xzf "$DOWNLOAD_PATH" -C "$STAGE_DIR" +verify_darkbloom_binary "$(staged_binary darkbloom)" "darkbloom" +if [ -f "$(staged_binary eigeninference-enclave)" ]; then + verify_darkbloom_binary "$(staged_binary eigeninference-enclave)" "eigeninference-enclave" +fi +/usr/bin/ditto "$STAGE_DIR" "$INSTALL_DIR" # Migrate older flat bundle layouts into the current install structure. [ -f "$INSTALL_DIR/darkbloom" ] && mv -f "$INSTALL_DIR/darkbloom" "$BIN_DIR/darkbloom" [ -f "$INSTALL_DIR/eigeninference-enclave" ] && mv -f "$INSTALL_DIR/eigeninference-enclave" "$BIN_DIR/eigeninference-enclave" chmod +x "$BIN_DIR/darkbloom" "$BIN_DIR/eigeninference-enclave" 2>/dev/null || true -rm -f /tmp/eigeninference-bundle.tar.gz -# Verify code signature (codesign is part of base macOS, no CLT needed) -if codesign --verify --verbose "$BIN_DIR/darkbloom" 2>/dev/null; then - TEAM=$(codesign -dvv "$BIN_DIR/darkbloom" 2>&1 | grep "TeamIdentifier=" | cut -d= -f2) - echo " Code signature verified ✓ (Team: $TEAM)" -else - echo " ⚠ Code signature could not be verified" +verify_darkbloom_binary "$BIN_DIR/darkbloom" "darkbloom" +if [ -f "$BIN_DIR/eigeninference-enclave" ]; then + verify_darkbloom_binary "$BIN_DIR/eigeninference-enclave" "eigeninference-enclave" fi # Make available in PATH @@ -256,9 +331,12 @@ fi echo "" echo "→ [4/7] Setting up Secure Enclave identity..." -"$BIN_DIR/eigeninference-enclave" info >/dev/null 2>&1 \ - && echo " Secure Enclave ✓ (P-256 key generated)" \ - || echo " Secure Enclave ⚠ (not available on this hardware)" +if "$BIN_DIR/eigeninference-enclave" provider-identity-info >/dev/null 2>&1; then + echo " Provider-bound Secure Enclave identity ✓" +else + echo " ✗ Provider-bound identity unavailable (missing entitlement or Secure Enclave)" + exit 1 +fi # ─── Step 5: Enrollment + device attestation ───────────────── echo "" diff --git a/coordinator/internal/api/provider.go b/coordinator/internal/api/provider.go index 4c0765cb..3e7c9b4a 100644 --- a/coordinator/internal/api/provider.go +++ b/coordinator/internal/api/provider.go @@ -165,6 +165,7 @@ func (s *Server) providerReadLoop(ctx context.Context, conn *websocket.Conn, pro regMsg := msg.Payload.(*protocol.RegisterMessage) provider = s.registry.Register(providerID, conn, regMsg) s.verifyProviderAttestation(providerID, provider, regMsg) + s.verifyProviderIdentityRegistration(providerID, provider, regMsg) // Record registration outcome metrics + telemetry. if s.metrics != nil { @@ -210,7 +211,8 @@ func (s *Server) providerReadLoop(ctx context.Context, conn *websocket.Conn, pro } // Verify runtime integrity against the known-good manifest. - if s.knownRuntimeManifest != nil { + runtimeManifest := s.knownRuntimeManifestSnapshot() + if runtimeManifest != nil { runtimeOK, mismatches := s.verifyRuntimeHashes( regMsg.PythonHash, regMsg.RuntimeHash, regMsg.TemplateHashes) provider.Mu().Lock() @@ -502,7 +504,11 @@ func (s *Server) handleAttestationResponse(providerID string, provider *registry pc := tracker.remove(msg.Nonce) if pc == nil { - s.logger.Warn("attestation response for unknown challenge", "provider_id", providerID, "nonce", msg.Nonce[:8]+"...") + noncePreview := msg.Nonce + if len(noncePreview) > 8 { + noncePreview = noncePreview[:8] + "..." + } + s.logger.Warn("attestation response for unknown challenge", "provider_id", providerID, "nonce", noncePreview) return } @@ -604,6 +610,10 @@ func (s *Server) verifyChallengeResponse(providerID string, provider *registry.P } } + if !s.verifyProviderIdentityChallenge(providerID, provider, pc, resp) { + return + } + // Status-field enforcement policy (asymmetric, by design): // // The checks below act on resp.SIPEnabled / SecureBootEnabled / @@ -685,9 +695,20 @@ func (s *Server) verifyChallengeResponse(providerID string, provider *registry.P ) } - // Verify fresh binary hash if reported and known hashes are configured. - if resp.BinaryHash != "" && len(s.knownBinaryHashes) > 0 { - if !s.knownBinaryHashes[resp.BinaryHash] { + // Verify fresh binary hash when known hashes are configured. Omission is a + // failure for the same reason as registration: it would otherwise downgrade + // binary enforcement to "only if the provider volunteers a value". + knownBinaryHashes := s.knownBinaryHashesSnapshot() + if len(knownBinaryHashes) > 0 { + if resp.BinaryHash == "" { + s.logger.Error("provider omitted binary hash required by known-good list", + "provider_id", providerID, + ) + s.registry.MarkUntrusted(providerID) + s.handleChallengeFailure(providerID, "binary hash missing") + return + } + if !knownBinaryHashes[resp.BinaryHash] { s.logger.Error("provider binary hash changed — no longer matches known-good list", "provider_id", providerID, "binary_hash", resp.BinaryHash, @@ -699,30 +720,38 @@ func (s *Server) verifyChallengeResponse(providerID string, provider *registry.P } // Verify active model hash if reported and catalog has expected hash. - if resp.ActiveModelHash != "" { - // Get the current model from the provider's last heartbeat. - provider.Mu().Lock() - currentModel := provider.CurrentModel - provider.Mu().Unlock() + // Get the current model from the provider's last heartbeat. + provider.Mu().Lock() + currentModel := provider.CurrentModel + provider.Mu().Unlock() - if currentModel != "" { - expectedHash := s.registry.CatalogWeightHash(currentModel) - if expectedHash != "" && resp.ActiveModelHash != expectedHash { - s.logger.Error("provider active model hash mismatch — possible model swap", - "provider_id", providerID, - "model", currentModel, - "expected", registry.TruncHash(expectedHash), - "got", registry.TruncHash(resp.ActiveModelHash), - ) - s.registry.MarkUntrusted(providerID) - s.handleChallengeFailure(providerID, "active model weight hash mismatch") - return - } + if currentModel != "" { + expectedHash := s.registry.CatalogWeightHash(currentModel) + if expectedHash != "" && resp.ActiveModelHash == "" { + s.logger.Error("provider omitted active model hash required by catalog", + "provider_id", providerID, + "model", currentModel, + ) + s.registry.MarkUntrusted(providerID) + s.handleChallengeFailure(providerID, "active model weight hash missing") + return + } + if expectedHash != "" && resp.ActiveModelHash != expectedHash { + s.logger.Error("provider active model hash mismatch — possible model swap", + "provider_id", providerID, + "model", currentModel, + "expected", registry.TruncHash(expectedHash), + "got", registry.TruncHash(resp.ActiveModelHash), + ) + s.registry.MarkUntrusted(providerID) + s.handleChallengeFailure(providerID, "active model weight hash mismatch") + return } } // Verify runtime integrity hashes from challenge response. - if s.knownRuntimeManifest != nil { + runtimeManifest := s.knownRuntimeManifestSnapshot() + if runtimeManifest != nil { runtimeOK, mismatches := s.verifyRuntimeHashes( resp.PythonHash, resp.RuntimeHash, resp.TemplateHashes) provider.Mu().Lock() @@ -1126,6 +1155,188 @@ func (s *Server) handleInferenceError(providerID string, provider *registry.Prov ) } +func (s *Server) verifyProviderIdentityRegistration(providerID string, provider *registry.Provider, regMsg *protocol.RegisterMessage) { + if provider == nil || provider.PublicKey == "" { + return + } + knownBinaryHashes := s.knownBinaryHashesSnapshot() + if len(knownBinaryHashes) == 0 { + s.logger.Warn("provider-bound identity not verified because no known-good binary hashes are configured", + "provider_id", providerID, + ) + return + } + if regMsg.ProviderIdentityPublicKey == "" || regMsg.ProviderIdentitySignature == "" { + s.logger.Warn("provider missing provider-bound identity signature; private text disabled", + "provider_id", providerID, + ) + return + } + if regMsg.BinaryHash == "" { + s.logger.Warn("provider-bound registration missing binary hash; private text disabled", + "provider_id", providerID, + ) + return + } + if !knownBinaryHashes[regMsg.BinaryHash] { + s.logger.Warn("provider-bound registration binary hash not in known-good list; private text disabled", + "provider_id", providerID, + "binary_hash", regMsg.BinaryHash, + ) + return + } + + canonical, err := attestation.BuildProviderIdentityRegistrationCanonical( + attestation.ProviderIdentityRegistrationInput{ + ProviderIdentityPublicKey: regMsg.ProviderIdentityPublicKey, + PublicKey: provider.PublicKey, + BinaryHash: regMsg.BinaryHash, + Version: regMsg.Version, + Backend: regMsg.Backend, + EncryptedResponseChunks: regMsg.EncryptedResponseChunks, + PythonHash: regMsg.PythonHash, + RuntimeHash: regMsg.RuntimeHash, + TemplateHashes: regMsg.TemplateHashes, + PrivacyCapabilities: providerIdentityPrivacyCapabilities(regMsg.PrivacyCapabilities), + }, + ) + if err != nil { + s.logger.Warn("failed to build provider-bound registration payload", + "provider_id", providerID, + "error", err, + ) + return + } + if err := attestation.VerifyProviderIdentitySignature( + regMsg.ProviderIdentityPublicKey, + regMsg.ProviderIdentitySignature, + canonical, + ); err != nil { + s.logger.Warn("provider-bound registration signature invalid; private text disabled", + "provider_id", providerID, + "error", err, + ) + return + } + + provider.Mu().Lock() + provider.ProviderIdentityPublicKey = regMsg.ProviderIdentityPublicKey + provider.ProviderIdentityVerified = true + provider.BinaryHash = regMsg.BinaryHash + provider.Mu().Unlock() + + s.logger.Info("provider-bound identity verified", + "provider_id", providerID, + "binary_hash", registry.TruncHash(regMsg.BinaryHash), + ) +} + +func (s *Server) verifyProviderIdentityChallenge(providerID string, provider *registry.Provider, pc *pendingChallenge, resp *protocol.AttestationResponseMessage) bool { + if provider == nil || provider.PublicKey == "" { + return true + } + + provider.Mu().Lock() + identityPublicKey := provider.ProviderIdentityPublicKey + identityVerified := provider.ProviderIdentityVerified + registeredBinaryHash := provider.BinaryHash + provider.Mu().Unlock() + + if !identityVerified && (identityPublicKey == "" || resp.ProviderIdentitySignature == "") { + return true + } + if identityPublicKey == "" || resp.ProviderIdentitySignature == "" { + provider.Mu().Lock() + provider.ProviderIdentityVerified = false + provider.Mu().Unlock() + s.handleChallengeFailure(providerID, "provider identity signature missing") + return false + } + if resp.BinaryHash == "" { + provider.Mu().Lock() + provider.ProviderIdentityVerified = false + provider.Mu().Unlock() + s.handleChallengeFailure(providerID, "provider identity challenge missing binary hash") + return false + } + if registeredBinaryHash != "" && resp.BinaryHash != registeredBinaryHash { + provider.Mu().Lock() + provider.ProviderIdentityVerified = false + provider.Mu().Unlock() + s.handleChallengeFailure(providerID, "provider identity challenge binary hash changed") + return false + } + knownBinaryHashes := s.knownBinaryHashesSnapshot() + if len(knownBinaryHashes) == 0 || !knownBinaryHashes[resp.BinaryHash] { + provider.Mu().Lock() + provider.ProviderIdentityVerified = false + provider.Mu().Unlock() + s.handleChallengeFailure(providerID, "provider identity challenge binary hash not known-good") + return false + } + + canonical, err := attestation.BuildProviderIdentityChallengeCanonical( + attestation.ProviderIdentityChallengeInput{ + ProviderIdentityPublicKey: identityPublicKey, + PublicKey: resp.PublicKey, + Nonce: pc.nonce, + Timestamp: pc.timestamp, + HypervisorActive: resp.HypervisorActive, + RDMADisabled: resp.RDMADisabled, + SIPEnabled: resp.SIPEnabled, + SecureBootEnabled: resp.SecureBootEnabled, + BinaryHash: resp.BinaryHash, + ActiveModelHash: resp.ActiveModelHash, + PythonHash: resp.PythonHash, + RuntimeHash: resp.RuntimeHash, + TemplateHashes: resp.TemplateHashes, + ModelHashes: resp.ModelHashes, + }, + ) + if err != nil { + provider.Mu().Lock() + provider.ProviderIdentityVerified = false + provider.Mu().Unlock() + s.handleChallengeFailure(providerID, "provider identity canonicalization failed: "+err.Error()) + return false + } + if err := attestation.VerifyProviderIdentitySignature( + identityPublicKey, + resp.ProviderIdentitySignature, + canonical, + ); err != nil { + provider.Mu().Lock() + provider.ProviderIdentityVerified = false + provider.Mu().Unlock() + s.handleChallengeFailure(providerID, "provider identity signature invalid: "+err.Error()) + return false + } + + provider.Mu().Lock() + provider.ProviderIdentityVerified = true + provider.BinaryHash = resp.BinaryHash + provider.Mu().Unlock() + + return true +} + +func providerIdentityPrivacyCapabilities(caps *protocol.PrivacyCapabilities) *attestation.ProviderIdentityPrivacyCapabilities { + if caps == nil { + return nil + } + return &attestation.ProviderIdentityPrivacyCapabilities{ + TextBackendInprocess: caps.TextBackendInprocess, + TextProxyDisabled: caps.TextProxyDisabled, + PythonRuntimeLocked: caps.PythonRuntimeLocked, + DangerousModulesBlocked: caps.DangerousModulesBlocked, + SIPEnabled: caps.SIPEnabled, + AntiDebugEnabled: caps.AntiDebugEnabled, + CoreDumpsDisabled: caps.CoreDumpsDisabled, + EnvScrubbed: caps.EnvScrubbed, + HypervisorActive: caps.HypervisorActive, + } +} + // verifyProviderAttestation verifies a provider's Secure Enclave attestation // if one was included in the registration message. If the attestation is valid, // the provider is marked as attested. If missing or invalid, the provider is @@ -1183,9 +1394,21 @@ func (s *Server) verifyProviderAttestation(providerID string, provider *registry } } - // Verify binary hash against known-good hashes. - if len(s.knownBinaryHashes) > 0 && result.BinaryHash != "" { - if !s.knownBinaryHashes[result.BinaryHash] { + // Verify binary hash against known-good hashes. When a release allow-list is + // configured, omission is a failure: otherwise older or modified providers + // can bypass the check by leaving the field empty. + knownBinaryHashes := s.knownBinaryHashesSnapshot() + if len(knownBinaryHashes) > 0 { + if result.BinaryHash == "" { + s.logger.Warn("provider attestation missing binary hash while known-good list is configured", + "provider_id", providerID, + ) + result.Valid = false + result.Error = "binary hash missing" + provider.SetAttestationResult(&result) + return + } + if !knownBinaryHashes[result.BinaryHash] { s.logger.Warn("provider binary hash not in known-good list", "provider_id", providerID, "binary_hash", result.BinaryHash, diff --git a/coordinator/internal/api/provider_identity_test.go b/coordinator/internal/api/provider_identity_test.go new file mode 100644 index 00000000..ebbefbf4 --- /dev/null +++ b/coordinator/internal/api/provider_identity_test.go @@ -0,0 +1,399 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/asn1" + "encoding/base64" + "log/slog" + "math/big" + "os" + "testing" + "time" + + "github.com/eigeninference/coordinator/internal/attestation" + "github.com/eigeninference/coordinator/internal/protocol" + "github.com/eigeninference/coordinator/internal/registry" + "github.com/eigeninference/coordinator/internal/store" +) + +const testKnownBinaryHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +type providerIdentityTestSig struct { + R, S *big.Int +} + +func TestProviderIdentityRegistrationVerifiesWithKnownBinaryHash(t *testing.T) { + srv, reg := providerIdentityTestServer(t) + srv.SetKnownBinaryHashes([]string{testKnownBinaryHash}) + + providerPublicKey := testPublicKeyB64() + provider := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: providerPublicKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + + regMsg := signedProviderIdentityRegisterMessage(t, providerPublicKey, testKnownBinaryHash) + srv.verifyProviderIdentityRegistration(provider.ID, provider, regMsg) + + provider.Mu().Lock() + defer provider.Mu().Unlock() + if !provider.ProviderIdentityVerified { + t.Fatal("provider identity should verify when signature and binary hash are valid") + } + if provider.ProviderIdentityPublicKey != regMsg.ProviderIdentityPublicKey { + t.Fatal("provider identity public key was not recorded") + } + if provider.BinaryHash != testKnownBinaryHash { + t.Fatalf("binary hash = %q, want %q", provider.BinaryHash, testKnownBinaryHash) + } +} + +func TestProviderIdentityRegistrationRequiresKnownBinaryHashes(t *testing.T) { + srv, reg := providerIdentityTestServer(t) + providerPublicKey := testPublicKeyB64() + provider := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: providerPublicKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + + regMsg := signedProviderIdentityRegisterMessage(t, providerPublicKey, testKnownBinaryHash) + srv.verifyProviderIdentityRegistration(provider.ID, provider, regMsg) + + provider.Mu().Lock() + defer provider.Mu().Unlock() + if provider.ProviderIdentityVerified { + t.Fatal("provider identity must not verify when the coordinator has no known-good binary hashes") + } +} + +func TestProviderIdentityChallengeMissingBinaryHashClearsVerification(t *testing.T) { + srv, reg := providerIdentityTestServer(t) + srv.SetKnownBinaryHashes([]string{testKnownBinaryHash}) + + providerPublicKey := testPublicKeyB64() + identityPriv, identityPublicKey := providerIdentityTestKey(t) + provider := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: providerPublicKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + provider.Mu().Lock() + provider.ProviderIdentityPublicKey = identityPublicKey + provider.ProviderIdentityVerified = true + provider.BinaryHash = testKnownBinaryHash + provider.LastChallengeVerified = time.Now() + provider.Mu().Unlock() + + pc := &pendingChallenge{nonce: "nonce", timestamp: "2026-04-28T20:00:00Z"} + resp := &protocol.AttestationResponseMessage{ + PublicKey: providerPublicKey, + ProviderIdentitySignature: signProviderIdentityPayload(t, identityPriv, []byte("not used")), + } + + if srv.verifyProviderIdentityChallenge(provider.ID, provider, pc, resp) { + t.Fatal("challenge should fail when a verified provider omits binary hash") + } + + provider.Mu().Lock() + defer provider.Mu().Unlock() + if provider.ProviderIdentityVerified { + t.Fatal("missing binary hash should clear provider identity verification") + } + if !provider.LastChallengeVerified.IsZero() { + t.Fatal("failed provider identity challenge should clear prior challenge freshness") + } +} + +func TestProviderIdentityChallengeVerifiesWithKnownBinaryHash(t *testing.T) { + srv, reg := providerIdentityTestServer(t) + srv.SetKnownBinaryHashes([]string{testKnownBinaryHash}) + + providerPublicKey := testPublicKeyB64() + identityPriv, identityPublicKey := providerIdentityTestKey(t) + provider := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: providerPublicKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + provider.Mu().Lock() + provider.ProviderIdentityPublicKey = identityPublicKey + provider.ProviderIdentityVerified = true + provider.BinaryHash = testKnownBinaryHash + provider.Mu().Unlock() + + truthy := true + pc := &pendingChallenge{nonce: "nonce", timestamp: "2026-04-28T20:00:00Z"} + resp := &protocol.AttestationResponseMessage{ + PublicKey: providerPublicKey, + HypervisorActive: &truthy, + RDMADisabled: &truthy, + SIPEnabled: &truthy, + SecureBootEnabled: &truthy, + BinaryHash: testKnownBinaryHash, + PythonHash: "py", + RuntimeHash: "rt", + TemplateHashes: map[string]string{"chatml": "tmpl"}, + ModelHashes: map[string]string{"qwen": "weights"}, + ActiveModelHash: "weights", + } + canonical, err := attestation.BuildProviderIdentityChallengeCanonical(attestation.ProviderIdentityChallengeInput{ + ProviderIdentityPublicKey: identityPublicKey, + PublicKey: resp.PublicKey, + Nonce: pc.nonce, + Timestamp: pc.timestamp, + HypervisorActive: resp.HypervisorActive, + RDMADisabled: resp.RDMADisabled, + SIPEnabled: resp.SIPEnabled, + SecureBootEnabled: resp.SecureBootEnabled, + BinaryHash: resp.BinaryHash, + ActiveModelHash: resp.ActiveModelHash, + PythonHash: resp.PythonHash, + RuntimeHash: resp.RuntimeHash, + TemplateHashes: resp.TemplateHashes, + ModelHashes: resp.ModelHashes, + }) + if err != nil { + t.Fatal(err) + } + resp.ProviderIdentitySignature = signProviderIdentityPayload(t, identityPriv, canonical) + + if !srv.verifyProviderIdentityChallenge(provider.ID, provider, pc, resp) { + t.Fatal("challenge should verify when signature and binary hash are valid") + } + + provider.Mu().Lock() + defer provider.Mu().Unlock() + if !provider.ProviderIdentityVerified { + t.Fatal("valid challenge should preserve provider identity verification") + } +} + +func TestProviderIdentityChallengeCanRecoverUnverifiedProvider(t *testing.T) { + srv, reg := providerIdentityTestServer(t) + srv.SetKnownBinaryHashes([]string{testKnownBinaryHash}) + + providerPublicKey := testPublicKeyB64() + identityPriv, identityPublicKey := providerIdentityTestKey(t) + provider := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: providerPublicKey, + ProviderIdentityPublicKey: identityPublicKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + provider.Mu().Lock() + provider.BinaryHash = testKnownBinaryHash + provider.ProviderIdentityVerified = false + provider.Mu().Unlock() + + pc := &pendingChallenge{nonce: "nonce", timestamp: "2026-04-28T20:00:00Z"} + resp := &protocol.AttestationResponseMessage{ + PublicKey: providerPublicKey, + BinaryHash: testKnownBinaryHash, + } + canonical, err := attestation.BuildProviderIdentityChallengeCanonical(attestation.ProviderIdentityChallengeInput{ + ProviderIdentityPublicKey: identityPublicKey, + PublicKey: resp.PublicKey, + Nonce: pc.nonce, + Timestamp: pc.timestamp, + BinaryHash: resp.BinaryHash, + }) + if err != nil { + t.Fatal(err) + } + resp.ProviderIdentitySignature = signProviderIdentityPayload(t, identityPriv, canonical) + + if !srv.verifyProviderIdentityChallenge(provider.ID, provider, pc, resp) { + t.Fatal("challenge should verify and recover provider identity") + } + + provider.Mu().Lock() + defer provider.Mu().Unlock() + if !provider.ProviderIdentityVerified { + t.Fatal("valid challenge should restore provider identity verification") + } +} + +func TestKnownBinaryHashPolicyRevokesLiveProviderIdentity(t *testing.T) { + srv, reg := providerIdentityTestServer(t) + srv.SetKnownBinaryHashes([]string{testKnownBinaryHash}) + + provider := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: testPublicKeyB64(), + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + provider.Mu().Lock() + provider.ProviderIdentityVerified = true + provider.BinaryHash = testKnownBinaryHash + provider.LastChallengeVerified = time.Now() + provider.Mu().Unlock() + + srv.SetKnownBinaryHashes([]string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + + provider.Mu().Lock() + defer provider.Mu().Unlock() + if provider.ProviderIdentityVerified { + t.Fatal("provider identity should be revoked immediately when its binary hash leaves the allow-list") + } + if provider.Status != registry.StatusUntrusted { + t.Fatalf("provider status = %q, want %q", provider.Status, registry.StatusUntrusted) + } + if !provider.LastChallengeVerified.IsZero() { + t.Fatal("binary hash revocation should clear challenge freshness") + } +} + +func TestChallengeResponseRequiresBinaryHashWhenKnownHashesConfigured(t *testing.T) { + srv, reg := providerIdentityTestServer(t) + srv.SetKnownBinaryHashes([]string{testKnownBinaryHash}) + + providerPublicKey := testPublicKeyB64() + provider := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: providerPublicKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + provider.SetLastChallengeVerified(time.Now()) + + truthy := true + pc := &pendingChallenge{nonce: "nonce", timestamp: "2026-04-28T20:00:00Z"} + resp := &protocol.AttestationResponseMessage{ + Nonce: pc.nonce, + Signature: "non-empty", + PublicKey: providerPublicKey, + RDMADisabled: &truthy, + SIPEnabled: &truthy, + SecureBootEnabled: &truthy, + } + + srv.verifyChallengeResponse(provider.ID, provider, pc, resp) + + provider.Mu().Lock() + defer provider.Mu().Unlock() + if provider.Status != registry.StatusUntrusted { + t.Fatalf("provider status = %q, want %q", provider.Status, registry.StatusUntrusted) + } + if !provider.LastChallengeVerified.IsZero() { + t.Fatal("missing binary hash challenge should clear prior challenge freshness") + } +} + +func TestAttestationRequiresBinaryHashWhenKnownHashesConfigured(t *testing.T) { + srv, reg := providerIdentityTestServer(t) + srv.SetKnownBinaryHashes([]string{testKnownBinaryHash}) + + providerPublicKey := testPublicKeyB64() + provider := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: providerPublicKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + PublicKey: providerPublicKey, + Attestation: createTestAttestationJSON(t, providerPublicKey), + } + + srv.verifyProviderAttestation(provider.ID, provider, regMsg) + + result := provider.GetAttestationResult() + if result == nil { + t.Fatal("expected attestation result") + } + if result.Valid { + t.Fatal("attestation should fail when known binary hashes are configured but binaryHash is omitted") + } + if result.Error != "binary hash missing" { + t.Fatalf("attestation error = %q, want %q", result.Error, "binary hash missing") + } + if provider.TrustLevel != registry.TrustNone { + t.Fatalf("trust level = %q, want %q", provider.TrustLevel, registry.TrustNone) + } +} + +func providerIdentityTestServer(t *testing.T) (*Server, *registry.Registry) { + t.Helper() + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + return NewServer(reg, st, logger), reg +} + +func signedProviderIdentityRegisterMessage(t *testing.T, providerPublicKey, binaryHash string) *protocol.RegisterMessage { + t.Helper() + identityPriv, identityPublicKey := providerIdentityTestKey(t) + msg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Backend: "inprocess-mlx", + PublicKey: providerPublicKey, + BinaryHash: binaryHash, + Version: "0.4.7", + EncryptedResponseChunks: true, + PythonHash: "py", + RuntimeHash: "rt", + TemplateHashes: map[string]string{"chatml": "tmpl"}, + PrivacyCapabilities: testPrivacyCaps(), + ProviderIdentityPublicKey: identityPublicKey, + } + canonical, err := attestation.BuildProviderIdentityRegistrationCanonical(attestation.ProviderIdentityRegistrationInput{ + ProviderIdentityPublicKey: msg.ProviderIdentityPublicKey, + PublicKey: msg.PublicKey, + BinaryHash: msg.BinaryHash, + Version: msg.Version, + Backend: msg.Backend, + EncryptedResponseChunks: msg.EncryptedResponseChunks, + PythonHash: msg.PythonHash, + RuntimeHash: msg.RuntimeHash, + TemplateHashes: msg.TemplateHashes, + PrivacyCapabilities: providerIdentityPrivacyCapabilities(msg.PrivacyCapabilities), + }) + if err != nil { + t.Fatal(err) + } + msg.ProviderIdentitySignature = signProviderIdentityPayload(t, identityPriv, canonical) + return msg +} + +func providerIdentityTestKey(t *testing.T) (*ecdsa.PrivateKey, string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return priv, base64.StdEncoding.EncodeToString(elliptic.Marshal(elliptic.P256(), priv.X, priv.Y)) +} + +func signProviderIdentityPayload(t *testing.T, priv *ecdsa.PrivateKey, payload []byte) string { + t.Helper() + hash := sha256.Sum256(payload) + r, s, err := ecdsa.Sign(rand.Reader, priv, hash[:]) + if err != nil { + t.Fatal(err) + } + der, err := asn1.Marshal(providerIdentityTestSig{R: r, S: s}) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(der) +} diff --git a/coordinator/internal/api/release_handlers.go b/coordinator/internal/api/release_handlers.go index 7cdef4f3..71fa7db3 100644 --- a/coordinator/internal/api/release_handlers.go +++ b/coordinator/internal/api/release_handlers.go @@ -37,6 +37,10 @@ func (s *Server) handleRegisterRelease(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "binary_hash is required")) return } + if release.BundleHash == "" { + writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "bundle_hash is required")) + return + } if release.URL == "" { writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "url is required")) return diff --git a/coordinator/internal/api/server.go b/coordinator/internal/api/server.go index 303d71ce..845a65cf 100644 --- a/coordinator/internal/api/server.go +++ b/coordinator/internal/api/server.go @@ -121,6 +121,7 @@ type Server struct { // When set, providers whose runtime hashes don't match are marked as // unverified and excluded from routing (but not disconnected). knownRuntimeManifest *RuntimeManifest + releaseStateMu sync.RWMutex // minProviderVersion is the minimum provider version accepted for routing. // Providers below this version are excluded and told to update. @@ -412,24 +413,26 @@ func (s *Server) SyncModelCatalog() { // SetKnownBinaryHashes configures the set of accepted provider binary hashes. // Providers whose binary SHA-256 doesn't match any known hash are rejected. func (s *Server) SetKnownBinaryHashes(hashes []string) { - s.knownBinaryHashes = make(map[string]bool, len(hashes)) + known := make(map[string]bool, len(hashes)) for _, h := range hashes { if h != "" { - s.knownBinaryHashes[h] = true + known[h] = true } } + s.setKnownBinaryHashes(known) + s.revalidateConnectedProvidersAgainstBinaryPolicy(known) } // AddKnownBinaryHashes adds hashes to the existing known set (for env var fallback). func (s *Server) AddKnownBinaryHashes(hashes []string) { - if s.knownBinaryHashes == nil { - s.knownBinaryHashes = make(map[string]bool) - } + known := s.knownBinaryHashesSnapshot() for _, h := range hashes { if h != "" { - s.knownBinaryHashes[h] = true + known[h] = true } } + s.setKnownBinaryHashes(known) + s.revalidateConnectedProvidersAgainstBinaryPolicy(known) } // SetConsoleURL sets the frontend URL for device auth verification links. @@ -469,7 +472,8 @@ func (s *Server) SyncBinaryHashes() { hashes[r.BinaryHash] = true } } - s.knownBinaryHashes = hashes + s.setKnownBinaryHashes(hashes) + s.revalidateConnectedProvidersAgainstBinaryPolicy(hashes) s.logger.Info("binary hashes synced from releases", "known_hashes", len(hashes)) } @@ -522,20 +526,110 @@ func (s *Server) SyncRuntimeManifest() { } if hasAny { - s.knownRuntimeManifest = manifest + s.setRuntimeManifestLocked(manifest) s.logger.Info("runtime manifest synced from releases", "python_hashes", len(manifest.PythonHashes), "runtime_hashes", len(manifest.RuntimeHashes), "template_hashes", len(manifest.TemplateHashes), ) } else { - s.knownRuntimeManifest = nil + s.setRuntimeManifestLocked(nil) } s.revalidateConnectedProvidersAgainstRuntimePolicy() } +func (s *Server) setKnownBinaryHashes(hashes map[string]bool) { + s.releaseStateMu.Lock() + defer s.releaseStateMu.Unlock() + s.knownBinaryHashes = cloneBoolMap(hashes) +} + +func (s *Server) knownBinaryHashesSnapshot() map[string]bool { + s.releaseStateMu.RLock() + defer s.releaseStateMu.RUnlock() + return cloneBoolMap(s.knownBinaryHashes) +} + +func (s *Server) setRuntimeManifestLocked(m *RuntimeManifest) { + s.releaseStateMu.Lock() + defer s.releaseStateMu.Unlock() + s.knownRuntimeManifest = cloneRuntimeManifest(m) +} + +func (s *Server) knownRuntimeManifestSnapshot() *RuntimeManifest { + s.releaseStateMu.RLock() + defer s.releaseStateMu.RUnlock() + return cloneRuntimeManifest(s.knownRuntimeManifest) +} + +func cloneBoolMap(in map[string]bool) map[string]bool { + if len(in) == 0 { + return map[string]bool{} + } + out := make(map[string]bool, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return map[string]string{} + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneRuntimeManifest(in *RuntimeManifest) *RuntimeManifest { + if in == nil { + return nil + } + return &RuntimeManifest{ + PythonHashes: cloneBoolMap(in.PythonHashes), + RuntimeHashes: cloneBoolMap(in.RuntimeHashes), + TemplateHashes: cloneStringMap(in.TemplateHashes), + } +} + +func (s *Server) revalidateConnectedProvidersAgainstBinaryPolicy(hashes map[string]bool) { + for _, providerID := range s.registry.ProviderIDs() { + provider := s.registry.GetProvider(providerID) + if provider == nil { + continue + } + + provider.Mu().Lock() + binaryHash := provider.BinaryHash + wasIdentityVerified := provider.ProviderIdentityVerified + switch { + case len(hashes) == 0: + provider.ProviderIdentityVerified = false + case binaryHash == "" || !hashes[binaryHash]: + provider.ProviderIdentityVerified = false + provider.Status = registry.StatusUntrusted + provider.LastChallengeVerified = time.Time{} + } + identityRevoked := wasIdentityVerified && !provider.ProviderIdentityVerified + status := provider.Status + provider.Mu().Unlock() + + if identityRevoked { + s.logger.Warn("provider identity verification revoked after binary hash policy change", + "provider_id", providerID, + "binary_hash", registry.TruncHash(binaryHash), + "status", status, + ) + } + } +} + func (s *Server) revalidateConnectedProvidersAgainstRuntimePolicy() { + manifest := s.knownRuntimeManifestSnapshot() for _, providerID := range s.registry.ProviderIDs() { provider := s.registry.GetProvider(providerID) if provider == nil { @@ -548,7 +642,7 @@ func (s *Server) revalidateConnectedProvidersAgainstRuntimePolicy() { templateHashes := registry.CloneStringMap(provider.TemplateHashes) version := provider.Version switch { - case s.knownRuntimeManifest == nil: + case manifest == nil: provider.RuntimeVerified = false provider.RuntimeManifestChecked = false case s.minProviderVersion != "" && @@ -557,7 +651,8 @@ func (s *Server) revalidateConnectedProvidersAgainstRuntimePolicy() { provider.RuntimeVerified = false provider.RuntimeManifestChecked = false default: - runtimeOK, _ := s.verifyRuntimeHashes( + runtimeOK, _ := verifyRuntimeHashesAgainstManifest( + manifest, pythonHash, runtimeHash, templateHashes, @@ -580,8 +675,6 @@ type RuntimeManifest struct { TemplateHashes map[string]string `json:"template_hashes"` // template_name -> expected hash } -// SetRuntimeManifest configures the known-good runtime manifest for provider -// verification. Pass nil to disable runtime verification (all providers pass). // semverGreater returns true if version a is greater than version b. // Compares numeric components (e.g. "0.2.31" > "0.2.9" = true). func semverGreater(a, b string) bool { @@ -616,8 +709,10 @@ func semverLess(a, b string) bool { return semverGreater(b, a) } +// SetRuntimeManifest configures the known-good runtime manifest for provider +// verification. Pass nil to disable runtime verification (all providers pass). func (s *Server) SetRuntimeManifest(m *RuntimeManifest) { - s.knownRuntimeManifest = m + s.setRuntimeManifestLocked(m) } // verifyRuntimeHashes checks provider-reported runtime hashes against the @@ -625,11 +720,14 @@ func (s *Server) SetRuntimeManifest(m *RuntimeManifest) { // the provider MUST report that component and it MUST match one of the known // good values. Omitting a required hash is treated as a mismatch. func (s *Server) verifyRuntimeHashes(pythonHash, runtimeHash string, templateHashes map[string]string) (bool, []protocol.RuntimeMismatch) { - if s.knownRuntimeManifest == nil { + return verifyRuntimeHashesAgainstManifest(s.knownRuntimeManifestSnapshot(), pythonHash, runtimeHash, templateHashes) +} + +func verifyRuntimeHashesAgainstManifest(manifest *RuntimeManifest, pythonHash, runtimeHash string, templateHashes map[string]string) (bool, []protocol.RuntimeMismatch) { + if manifest == nil { return true, nil } - manifest := s.knownRuntimeManifest var mismatches []protocol.RuntimeMismatch requireOneOf := func(component, got string, accepted map[string]bool) { @@ -697,15 +795,16 @@ func (s *Server) handleRuntimeManifest(w http.ResponseWriter, r *http.Request) { writeCachedJSON(w, http.StatusOK, cached) return } + manifest := s.knownRuntimeManifestSnapshot() var resp map[string]any - if s.knownRuntimeManifest == nil { + if manifest == nil { resp = map[string]any{"configured": false} } else { resp = map[string]any{ "configured": true, - "python_hashes": s.knownRuntimeManifest.PythonHashes, - "runtime_hashes": s.knownRuntimeManifest.RuntimeHashes, - "template_hashes": s.knownRuntimeManifest.TemplateHashes, + "python_hashes": manifest.PythonHashes, + "runtime_hashes": manifest.RuntimeHashes, + "template_hashes": manifest.TemplateHashes, } } body, err := json.Marshal(resp) diff --git a/coordinator/internal/attestation/provider_identity.go b/coordinator/internal/attestation/provider_identity.go new file mode 100644 index 00000000..cc246943 --- /dev/null +++ b/coordinator/internal/attestation/provider_identity.go @@ -0,0 +1,191 @@ +package attestation + +import ( + "bytes" + "crypto/ecdsa" + "crypto/sha256" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" +) + +const ( + ProviderIdentityRegistrationDomain = "darkbloom.provider.registration.v1" + ProviderIdentityChallengeDomain = "darkbloom.provider.challenge.v1" +) + +// ProviderIdentityPrivacyCapabilities mirrors the privacy fields signed by the +// provider-bound identity during registration. These are provider-local process +// invariants, so they are only useful when bound to the entitlement-gated key. +type ProviderIdentityPrivacyCapabilities struct { + TextBackendInprocess bool + TextProxyDisabled bool + PythonRuntimeLocked bool + DangerousModulesBlocked bool + SIPEnabled bool + AntiDebugEnabled bool + CoreDumpsDisabled bool + EnvScrubbed bool + HypervisorActive bool +} + +type ProviderIdentityRegistrationInput struct { + ProviderIdentityPublicKey string + PublicKey string + BinaryHash string + Version string + Backend string + EncryptedResponseChunks bool + PythonHash string + RuntimeHash string + TemplateHashes map[string]string + PrivacyCapabilities *ProviderIdentityPrivacyCapabilities +} + +type ProviderIdentityChallengeInput struct { + ProviderIdentityPublicKey string + PublicKey string + Nonce string + Timestamp string + HypervisorActive *bool + RDMADisabled *bool + SIPEnabled *bool + SecureBootEnabled *bool + BinaryHash string + ActiveModelHash string + PythonHash string + RuntimeHash string + TemplateHashes map[string]string + ModelHashes map[string]string +} + +func BuildProviderIdentityRegistrationCanonical(in ProviderIdentityRegistrationInput) ([]byte, error) { + m := map[string]any{ + "backend": in.Backend, + "domain": ProviderIdentityRegistrationDomain, + "encrypted_response_chunks": in.EncryptedResponseChunks, + "provider_identity_public_key": in.ProviderIdentityPublicKey, + } + if in.PublicKey != "" { + m["public_key"] = in.PublicKey + } + if in.BinaryHash != "" { + m["binary_hash"] = in.BinaryHash + } + if in.Version != "" { + m["version"] = in.Version + } + if in.PythonHash != "" { + m["python_hash"] = in.PythonHash + } + if in.RuntimeHash != "" { + m["runtime_hash"] = in.RuntimeHash + } + if len(in.TemplateHashes) > 0 { + m["template_hashes"] = in.TemplateHashes + } + if in.PrivacyCapabilities != nil { + m["privacy_capabilities"] = map[string]any{ + "anti_debug_enabled": in.PrivacyCapabilities.AntiDebugEnabled, + "core_dumps_disabled": in.PrivacyCapabilities.CoreDumpsDisabled, + "dangerous_modules_blocked": in.PrivacyCapabilities.DangerousModulesBlocked, + "env_scrubbed": in.PrivacyCapabilities.EnvScrubbed, + "hypervisor_active": in.PrivacyCapabilities.HypervisorActive, + "python_runtime_locked": in.PrivacyCapabilities.PythonRuntimeLocked, + "sip_enabled": in.PrivacyCapabilities.SIPEnabled, + "text_backend_inprocess": in.PrivacyCapabilities.TextBackendInprocess, + "text_proxy_disabled": in.PrivacyCapabilities.TextProxyDisabled, + } + } + return marshalProviderIdentityCanonical(m) +} + +func BuildProviderIdentityChallengeCanonical(in ProviderIdentityChallengeInput) ([]byte, error) { + m := map[string]any{ + "domain": ProviderIdentityChallengeDomain, + "nonce": in.Nonce, + "provider_identity_public_key": in.ProviderIdentityPublicKey, + "public_key": in.PublicKey, + "timestamp": in.Timestamp, + } + if in.HypervisorActive != nil { + m["hypervisor_active"] = *in.HypervisorActive + } + if in.RDMADisabled != nil { + m["rdma_disabled"] = *in.RDMADisabled + } + if in.SIPEnabled != nil { + m["sip_enabled"] = *in.SIPEnabled + } + if in.SecureBootEnabled != nil { + m["secure_boot_enabled"] = *in.SecureBootEnabled + } + if in.BinaryHash != "" { + m["binary_hash"] = in.BinaryHash + } + if in.ActiveModelHash != "" { + m["active_model_hash"] = in.ActiveModelHash + } + if in.PythonHash != "" { + m["python_hash"] = in.PythonHash + } + if in.RuntimeHash != "" { + m["runtime_hash"] = in.RuntimeHash + } + if len(in.TemplateHashes) > 0 { + m["template_hashes"] = in.TemplateHashes + } + if len(in.ModelHashes) > 0 { + m["model_hashes"] = in.ModelHashes + } + return marshalProviderIdentityCanonical(m) +} + +func marshalProviderIdentityCanonical(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +func VerifyProviderIdentitySignature(providerIdentityPublicKeyB64, signatureB64 string, data []byte) error { + if providerIdentityPublicKeyB64 == "" { + return errors.New("provider identity public key missing") + } + if signatureB64 == "" { + return errors.New("provider identity signature missing") + } + + pubKeyBytes, err := base64.StdEncoding.DecodeString(providerIdentityPublicKeyB64) + if err != nil { + return fmt.Errorf("invalid provider identity public key base64: %w", err) + } + pubKey, err := ParseP256PublicKey(pubKeyBytes) + if err != nil { + return fmt.Errorf("invalid provider identity public key: %w", err) + } + + sigBytes, err := base64.StdEncoding.DecodeString(signatureB64) + if err != nil { + return fmt.Errorf("invalid provider identity signature base64: %w", err) + } + + if !verifyECDSASHA256(pubKey, sigBytes, data) { + return errors.New("provider identity signature verification failed") + } + return nil +} + +func verifyECDSASHA256(pubKey *ecdsa.PublicKey, sigBytes, data []byte) bool { + var sig ecdsaSig + if _, err := asn1.Unmarshal(sigBytes, &sig); err != nil { + return false + } + hash := sha256.Sum256(data) + return ecdsa.Verify(pubKey, hash[:], sig.R, sig.S) +} diff --git a/coordinator/internal/attestation/provider_identity_test.go b/coordinator/internal/attestation/provider_identity_test.go new file mode 100644 index 00000000..a622acc4 --- /dev/null +++ b/coordinator/internal/attestation/provider_identity_test.go @@ -0,0 +1,139 @@ +package attestation + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/asn1" + "encoding/base64" + "testing" +) + +func TestProviderIdentityRegistrationCanonicalGolden(t *testing.T) { + templates := map[string]string{"z": "2", "a": "1"} + got, err := BuildProviderIdentityRegistrationCanonical(ProviderIdentityRegistrationInput{ + ProviderIdentityPublicKey: "idpk", + PublicKey: "x25519", + BinaryHash: "binhash", + Version: "0.4.7", + Backend: "inprocess-mlx", + EncryptedResponseChunks: true, + PythonHash: "py", + RuntimeHash: "rt", + TemplateHashes: templates, + PrivacyCapabilities: &ProviderIdentityPrivacyCapabilities{ + TextBackendInprocess: true, + TextProxyDisabled: true, + PythonRuntimeLocked: true, + DangerousModulesBlocked: true, + SIPEnabled: true, + AntiDebugEnabled: true, + CoreDumpsDisabled: true, + EnvScrubbed: true, + HypervisorActive: false, + }, + }) + if err != nil { + t.Fatal(err) + } + + expected := `{"backend":"inprocess-mlx","binary_hash":"binhash","domain":"darkbloom.provider.registration.v1","encrypted_response_chunks":true,"privacy_capabilities":{"anti_debug_enabled":true,"core_dumps_disabled":true,"dangerous_modules_blocked":true,"env_scrubbed":true,"hypervisor_active":false,"python_runtime_locked":true,"sip_enabled":true,"text_backend_inprocess":true,"text_proxy_disabled":true},"provider_identity_public_key":"idpk","public_key":"x25519","python_hash":"py","runtime_hash":"rt","template_hashes":{"a":"1","z":"2"},"version":"0.4.7"}` + if string(got) != expected { + t.Fatalf("canonical mismatch\n got: %s\nwant: %s", got, expected) + } +} + +func TestProviderIdentityChallengeCanonicalGolden(t *testing.T) { + truthy := true + got, err := BuildProviderIdentityChallengeCanonical(ProviderIdentityChallengeInput{ + ProviderIdentityPublicKey: "idpk", + PublicKey: "x25519", + Nonce: "nonce", + Timestamp: "2026-04-28T20:00:00Z", + HypervisorActive: &truthy, + RDMADisabled: &truthy, + SIPEnabled: &truthy, + SecureBootEnabled: &truthy, + BinaryHash: "binhash", + ActiveModelHash: "active", + PythonHash: "py", + RuntimeHash: "rt", + TemplateHashes: map[string]string{"z": "2", "a": "1"}, + ModelHashes: map[string]string{"qwen": "abc", "llama": "def"}, + }) + if err != nil { + t.Fatal(err) + } + + expected := `{"active_model_hash":"active","binary_hash":"binhash","domain":"darkbloom.provider.challenge.v1","hypervisor_active":true,"model_hashes":{"llama":"def","qwen":"abc"},"nonce":"nonce","provider_identity_public_key":"idpk","public_key":"x25519","python_hash":"py","rdma_disabled":true,"runtime_hash":"rt","secure_boot_enabled":true,"sip_enabled":true,"template_hashes":{"a":"1","z":"2"},"timestamp":"2026-04-28T20:00:00Z"}` + if string(got) != expected { + t.Fatalf("canonical mismatch\n got: %s\nwant: %s", got, expected) + } +} + +func TestProviderIdentityCanonicalDoesNotEscapeHTML(t *testing.T) { + got, err := BuildProviderIdentityRegistrationCanonical(ProviderIdentityRegistrationInput{ + ProviderIdentityPublicKey: "id<&>pk", + PublicKey: "x25519", + Backend: "inprocess-mlx", + EncryptedResponseChunks: true, + TemplateHashes: map[string]string{"a<&>": "v<&>"}, + }) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"backend":"inprocess-mlx","domain":"darkbloom.provider.registration.v1","encrypted_response_chunks":true,"provider_identity_public_key":"id<&>pk","public_key":"x25519","template_hashes":{"a<&>":"v<&>"}}` { + t.Fatalf("canonical HTML escaping mismatch: %s", got) + } +} + +func TestProviderIdentityChallengeCanonicalDoesNotEscapeHTML(t *testing.T) { + got, err := BuildProviderIdentityChallengeCanonical(ProviderIdentityChallengeInput{ + ProviderIdentityPublicKey: "id<&>pk", + PublicKey: "x<&>25519", + Nonce: "n<&>", + Timestamp: "2026-04-28T20:00:00Z", + BinaryHash: "bin<&>hash", + TemplateHashes: map[string]string{"a<&>": "v<&>"}, + ModelHashes: map[string]string{"m<&>": "w<&>"}, + }) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"binary_hash":"bin<&>hash","domain":"darkbloom.provider.challenge.v1","model_hashes":{"m<&>":"w<&>"},"nonce":"n<&>","provider_identity_public_key":"id<&>pk","public_key":"x<&>25519","template_hashes":{"a<&>":"v<&>"},"timestamp":"2026-04-28T20:00:00Z"}` { + t.Fatalf("canonical challenge HTML escaping mismatch: %s", got) + } +} + +func TestVerifyProviderIdentitySignature(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + payload := []byte(`{"domain":"darkbloom.provider.registration.v1","public_key":"x25519"}`) + sig := signProviderIdentityTestPayload(t, priv, payload) + pub := base64.StdEncoding.EncodeToString(elliptic.Marshal(elliptic.P256(), priv.X, priv.Y)) + + if err := VerifyProviderIdentitySignature(pub, sig, payload); err != nil { + t.Fatalf("signature should verify: %v", err) + } + if err := VerifyProviderIdentitySignature(pub, sig, []byte(`{"domain":"tampered"}`)); err == nil { + t.Fatal("tampered payload should fail verification") + } +} + +func signProviderIdentityTestPayload(t *testing.T, priv *ecdsa.PrivateKey, payload []byte) string { + t.Helper() + hash := sha256.Sum256(payload) + r, s, err := ecdsa.Sign(rand.Reader, priv, hash[:]) + if err != nil { + t.Fatal(err) + } + der, err := asn1.Marshal(ecdsaSig{R: r, S: s}) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(der) +} diff --git a/coordinator/internal/protocol/messages.go b/coordinator/internal/protocol/messages.go index 4237adf0..0f974707 100644 --- a/coordinator/internal/protocol/messages.go +++ b/coordinator/internal/protocol/messages.go @@ -83,18 +83,21 @@ type ModelInfo struct { // RegisterMessage is sent when a provider first connects. type RegisterMessage struct { - Type string `json:"type"` - Hardware Hardware `json:"hardware"` - Models []ModelInfo `json:"models"` - Backend string `json:"backend"` - Version string `json:"version,omitempty"` // provider binary version (e.g. "0.2.31") - PublicKey string `json:"public_key,omitempty"` // base64-encoded X25519 public key for E2E encryption - EncryptedResponseChunks bool `json:"encrypted_response_chunks,omitempty"` // true when text response chunks are returned encrypted to the coordinator - WalletAddress string `json:"wallet_address,omitempty"` // Ethereum-format hex address for Tempo payouts - Attestation json.RawMessage `json:"attestation,omitempty"` // signed Secure Enclave attestation blob - PrefillTPS float64 `json:"prefill_tps,omitempty"` // benchmark: prefill tokens per second - DecodeTPS float64 `json:"decode_tps,omitempty"` // benchmark: decode tokens per second - AuthToken string `json:"auth_token,omitempty"` // device-linked provider token (from darkbloom login) + Type string `json:"type"` + Hardware Hardware `json:"hardware"` + Models []ModelInfo `json:"models"` + Backend string `json:"backend"` + Version string `json:"version,omitempty"` // provider binary version (e.g. "0.2.31") + PublicKey string `json:"public_key,omitempty"` // base64-encoded X25519 public key for E2E encryption + BinaryHash string `json:"binary_hash,omitempty"` // SHA-256 of provider binary, signed by provider identity + ProviderIdentityPublicKey string `json:"provider_identity_public_key,omitempty"` // base64 P-256 public key for provider-bound identity + ProviderIdentitySignature string `json:"provider_identity_signature,omitempty"` // signature over canonical registration payload + EncryptedResponseChunks bool `json:"encrypted_response_chunks,omitempty"` // true when text response chunks are returned encrypted to the coordinator + WalletAddress string `json:"wallet_address,omitempty"` // Ethereum-format hex address for Tempo payouts + Attestation json.RawMessage `json:"attestation,omitempty"` // signed Secure Enclave attestation blob + PrefillTPS float64 `json:"prefill_tps,omitempty"` // benchmark: prefill tokens per second + DecodeTPS float64 `json:"decode_tps,omitempty"` // benchmark: decode tokens per second + AuthToken string `json:"auth_token,omitempty"` // device-linked provider token (from darkbloom login) // Runtime integrity hashes — used for runtime verification against known-good manifests. PythonHash string `json:"python_hash,omitempty"` // SHA-256 of Python runtime @@ -271,17 +274,18 @@ type AttestationChallengeMessage struct { // which case the status fields are treated as advisory (not a basis for // trust upgrades). type AttestationResponseMessage struct { - Type string `json:"type"` - Nonce string `json:"nonce"` // echoed back from the challenge - Signature string `json:"signature"` // base64-encoded signature of nonce+timestamp - StatusSignature string `json:"status_signature,omitempty"` // base64-encoded signature of canonical status JSON (see attestation.BuildStatusCanonical) - PublicKey string `json:"public_key"` // base64-encoded public key - HypervisorActive *bool `json:"hypervisor_active,omitempty"` // hypervisor memory isolation active (Stage 2 page tables) - RDMADisabled *bool `json:"rdma_disabled,omitempty"` // fresh RDMA status (true = safe, false = remote memory access possible) - SIPEnabled *bool `json:"sip_enabled,omitempty"` // fresh SIP status at challenge time - SecureBootEnabled *bool `json:"secure_boot_enabled,omitempty"` // fresh Secure Boot status - BinaryHash string `json:"binary_hash,omitempty"` // fresh SHA-256 of provider binary - ActiveModelHash string `json:"active_model_hash,omitempty"` // SHA-256 weight fingerprint of loaded model + Type string `json:"type"` + Nonce string `json:"nonce"` // echoed back from the challenge + Signature string `json:"signature"` // base64-encoded signature of nonce+timestamp + StatusSignature string `json:"status_signature,omitempty"` // base64-encoded signature of canonical status JSON (see attestation.BuildStatusCanonical) + ProviderIdentitySignature string `json:"provider_identity_signature,omitempty"` // provider-bound identity signature over canonical challenge/status JSON + PublicKey string `json:"public_key"` // base64-encoded public key + HypervisorActive *bool `json:"hypervisor_active,omitempty"` // hypervisor memory isolation active (Stage 2 page tables) + RDMADisabled *bool `json:"rdma_disabled,omitempty"` // fresh RDMA status (true = safe, false = remote memory access possible) + SIPEnabled *bool `json:"sip_enabled,omitempty"` // fresh SIP status at challenge time + SecureBootEnabled *bool `json:"secure_boot_enabled,omitempty"` // fresh Secure Boot status + BinaryHash string `json:"binary_hash,omitempty"` // fresh SHA-256 of provider binary + ActiveModelHash string `json:"active_model_hash,omitempty"` // SHA-256 weight fingerprint of loaded model // Runtime integrity hashes — fresh values reported at challenge time. PythonHash string `json:"python_hash,omitempty"` // SHA-256 of Python runtime diff --git a/coordinator/internal/registry/registry.go b/coordinator/internal/registry/registry.go index 499de8f8..d2f2618d 100644 --- a/coordinator/internal/registry/registry.go +++ b/coordinator/internal/registry/registry.go @@ -89,25 +89,29 @@ type PendingRequest struct { // Provider represents a connected provider agent. type Provider struct { - ID string - Hardware protocol.Hardware - Models []protocol.ModelInfo - Backend string - PublicKey string // base64-encoded X25519 public key for E2E encryption - WalletAddress string // Ethereum-format hex address for Tempo payouts - Attested bool // true if attestation was verified successfully - AttestationResult *attestation.VerificationResult - TrustLevel TrustLevel // attestation trust level - MDAVerified bool // true if Apple Device Attestation cert chain verified - MDACertChain [][]byte // DER-encoded Apple MDA certificate chain (leaf first) - MDAResult *attestation.MDAResult // parsed OIDs from Apple cert - ACMEVerified bool // true if ACME device-attest-01 client cert verified (SE key proven) - SEKeyBound bool // true if SE key was bound to device via MDA nonce - Status ProviderStatus - Conn *websocket.Conn - LastHeartbeat time.Time - Stats protocol.HeartbeatStats // lifetime counters shown to users - lastSessionStats protocol.HeartbeatStats // raw counters from the current provider process + ID string + Hardware protocol.Hardware + Models []protocol.ModelInfo + Backend string + PublicKey string // base64-encoded X25519 public key for E2E encryption + BinaryHash string // SHA-256 of provider binary reported at registration + ProviderIdentityPublicKey string // base64 P-256 provider-bound identity public key + ProviderIdentityVerified bool // true when the entitlement-gated identity signed current claims + RequireProviderIdentity bool // true when private text must be provider-identity bound + WalletAddress string // Ethereum-format hex address for Tempo payouts + Attested bool // true if attestation was verified successfully + AttestationResult *attestation.VerificationResult + TrustLevel TrustLevel // attestation trust level + MDAVerified bool // true if Apple Device Attestation cert chain verified + MDACertChain [][]byte // DER-encoded Apple MDA certificate chain (leaf first) + MDAResult *attestation.MDAResult // parsed OIDs from Apple cert + ACMEVerified bool // true if ACME device-attest-01 client cert verified (SE key proven) + SEKeyBound bool // true if SE key was bound to device via MDA nonce + Status ProviderStatus + Conn *websocket.Conn + LastHeartbeat time.Time + Stats protocol.HeartbeatStats // lifetime counters shown to users + lastSessionStats protocol.HeartbeatStats // raw counters from the current provider process // Account linkage (set when provider authenticates via device auth token) AccountID string // internal account ID (from device auth flow) @@ -161,6 +165,9 @@ func providerSupportsPrivateTextLocked(p *Provider) bool { if p.PublicKey == "" || p.Backend != "inprocess-mlx" || !p.EncryptedResponseChunks { return false } + if p.RequireProviderIdentity && !p.ProviderIdentityVerified { + return false + } if !p.RuntimeManifestChecked { return false } @@ -348,6 +355,11 @@ type Registry struct { // provider records and reputation are persisted across coordinator restarts. store store.Store + // RequireProviderIdentity gates private text routing on the persistent + // entitlement-gated provider identity. Tests can leave this false when + // exercising scheduler behavior without protocol signatures. + RequireProviderIdentity bool + logger *slog.Logger } @@ -367,6 +379,14 @@ func (r *Registry) SetStore(st store.Store) { r.store = st } +// SetRequireProviderIdentity controls whether private text routing requires a +// verified provider-bound identity signature. +func (r *Registry) SetRequireProviderIdentity(required bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.RequireProviderIdentity = required +} + // LoadStoredProviders loads provider records and reputation from the store // on startup. This pre-populates a lookup table so that reconnecting providers // can have their trust level and reputation restored. Providers are NOT added @@ -401,9 +421,9 @@ func (r *Registry) LoadStoredProviders() map[string]*store.ProviderRecord { return lookup } -// RestoreProviderState restores trust level and reputation from a stored record -// onto a live provider. Called after a provider reconnects and is matched to -// its stored state by serial number or SE key. +// RestoreProviderState restores non-security state from a stored record onto a +// live provider. Trust is intentionally not restored: every connection must earn +// fresh self-signed/hardware trust from the current attestation flow. func (r *Registry) RestoreProviderState(p *Provider, rec *store.ProviderRecord) { if rec == nil { return @@ -412,18 +432,6 @@ func (r *Registry) RestoreProviderState(p *Provider, rec *store.ProviderRecord) p.mu.Lock() defer p.mu.Unlock() - // Restore trust level (will be re-verified via fresh attestation) - p.TrustLevel = TrustLevel(rec.TrustLevel) - p.Attested = rec.Attested - p.MDAVerified = rec.MDAVerified - p.ACMEVerified = rec.ACMEVerified - - // Restore challenge state - if rec.LastChallengeVerified != nil { - p.LastChallengeVerified = *rec.LastChallengeVerified - } - p.FailedChallenges = rec.FailedChallenges - // Restore account linkage if rec.AccountID != "" && p.AccountID == "" { p.AccountID = rec.AccountID @@ -758,6 +766,7 @@ func (r *Registry) Register(id string, conn *websocket.Conn, msg *protocol.Regis models := msg.Models r.mu.RLock() catalog := r.modelCatalog + requireProviderIdentity := r.RequireProviderIdentity r.mu.RUnlock() if len(catalog) > 0 { filtered := make([]protocol.ModelInfo, 0, len(models)) @@ -797,26 +806,29 @@ func (r *Registry) Register(id string, conn *websocket.Conn, msg *protocol.Regis } p := &Provider{ - ID: id, - Hardware: msg.Hardware, - Models: models, - Backend: msg.Backend, - PublicKey: pubKey, - EncryptedResponseChunks: msg.EncryptedResponseChunks, - WalletAddress: msg.WalletAddress, - PrefillTPS: msg.PrefillTPS, - DecodeTPS: msg.DecodeTPS, - TrustLevel: TrustNone, - RuntimeVerified: true, // default to verified; API layer sets false when manifest check fails - RuntimeManifestChecked: true, // default to true; API layer sets false when no manifest is configured - ChallengeVerifiedSIP: false, // starts false; set true by attestation challenge handler after SIP check - PrivacyCapabilities: msg.PrivacyCapabilities, - TemplateHashes: CloneStringMap(msg.TemplateHashes), - Status: StatusOnline, - Conn: conn, - LastHeartbeat: time.Now(), - Reputation: NewReputation(), - pendingReqs: make(map[string]*PendingRequest), + ID: id, + Hardware: msg.Hardware, + Models: models, + Backend: msg.Backend, + PublicKey: pubKey, + BinaryHash: msg.BinaryHash, + ProviderIdentityPublicKey: msg.ProviderIdentityPublicKey, + RequireProviderIdentity: requireProviderIdentity, + EncryptedResponseChunks: msg.EncryptedResponseChunks, + WalletAddress: msg.WalletAddress, + PrefillTPS: msg.PrefillTPS, + DecodeTPS: msg.DecodeTPS, + TrustLevel: TrustNone, + RuntimeVerified: true, // default to verified; API layer sets false when manifest check fails + RuntimeManifestChecked: true, // default to true; API layer sets false when no manifest is configured + ChallengeVerifiedSIP: false, // starts false; set true by attestation challenge handler after SIP check + PrivacyCapabilities: msg.PrivacyCapabilities, + TemplateHashes: CloneStringMap(msg.TemplateHashes), + Status: StatusOnline, + Conn: conn, + LastHeartbeat: time.Now(), + Reputation: NewReputation(), + pendingReqs: make(map[string]*PendingRequest), } r.mu.Lock() diff --git a/coordinator/internal/registry/registry_test.go b/coordinator/internal/registry/registry_test.go index f25aea1e..806e7037 100644 --- a/coordinator/internal/registry/registry_test.go +++ b/coordinator/internal/registry/registry_test.go @@ -163,6 +163,22 @@ func TestProviderPartialPrivacyCapsExcluded(t *testing.T) { } } +func TestProviderIdentityRequiredForPrivateTextRouting(t *testing.T) { + reg := New(testLogger()) + reg.SetRequireProviderIdentity(true) + p := reg.Register("p-identity", nil, testRegisterMessage()) + testMakeTextRoutable(p) + + if found := reg.FindProvider("mlx-community/Qwen3.5-9B-Instruct-4bit"); found != nil { + t.Fatal("provider without verified provider-bound identity should not be routable for private text") + } + + p.ProviderIdentityVerified = true + if found := reg.FindProvider("mlx-community/Qwen3.5-9B-Instruct-4bit"); found == nil { + t.Fatal("provider with verified provider-bound identity should be routable") + } +} + func TestHeartbeat(t *testing.T) { reg := New(testLogger()) msg := testRegisterMessage() @@ -240,6 +256,34 @@ func TestHeartbeatAccumulatesAcrossRestarts(t *testing.T) { } } +func TestRestoreProviderStateDoesNotRestoreTrust(t *testing.T) { + reg := New(testLogger()) + p := reg.Register("p1", nil, testRegisterMessage()) + lastChallenge := time.Now() + + reg.RestoreProviderState(p, &store.ProviderRecord{ + ID: "persisted-p1", + TrustLevel: string(TrustHardware), + Attested: true, + MDAVerified: true, + ACMEVerified: true, + LastChallengeVerified: &lastChallenge, + FailedChallenges: 2, + }) + + p.Mu().Lock() + defer p.Mu().Unlock() + if p.TrustLevel != TrustNone { + t.Fatalf("trust level restored from storage = %q, want %q", p.TrustLevel, TrustNone) + } + if p.Attested || p.MDAVerified || p.ACMEVerified { + t.Fatal("security trust flags should require fresh verification on reconnect") + } + if !p.LastChallengeVerified.IsZero() || p.FailedChallenges != 0 { + t.Fatal("challenge state should require fresh verification on reconnect") + } +} + func TestHeartbeatUnknownProvider(t *testing.T) { reg := New(testLogger()) hb := &protocol.HeartbeatMessage{ diff --git a/enclave/Sources/EigenInferenceEnclave/Bridge.swift b/enclave/Sources/EigenInferenceEnclave/Bridge.swift index 1b8fba6a..10e9da34 100644 --- a/enclave/Sources/EigenInferenceEnclave/Bridge.swift +++ b/enclave/Sources/EigenInferenceEnclave/Bridge.swift @@ -45,7 +45,8 @@ public func eigeninference_enclave_create() -> UnsafeMutableRawPointer? { /// This releases the retained reference to the SecureEnclaveIdentity object. /// After calling this, the pointer must not be used again. @_cdecl("eigeninference_enclave_free") -public func eigeninference_enclave_free(_ ptr: UnsafeMutableRawPointer) { +public func eigeninference_enclave_free(_ ptr: UnsafeMutableRawPointer?) { + guard let ptr else { return } Unmanaged.fromOpaque(ptr).release() } @@ -62,8 +63,9 @@ public func eigeninference_enclave_is_available() -> Int32 { /// Caller must free the returned string with `eigeninference_enclave_free_string()`. @_cdecl("eigeninference_enclave_public_key_base64") public func eigeninference_enclave_public_key_base64( - _ ptr: UnsafeRawPointer + _ ptr: UnsafeRawPointer? ) -> UnsafeMutablePointer? { + guard let ptr else { return nil } let identity = Unmanaged.fromOpaque(ptr) .takeUnretainedValue() let base64 = identity.publicKeyBase64 @@ -78,13 +80,60 @@ public func eigeninference_enclave_public_key_base64( /// Returns NULL on failure (e.g., Secure Enclave unavailable, biometric denied). @_cdecl("eigeninference_enclave_sign") public func eigeninference_enclave_sign( - _ ptr: UnsafeRawPointer, - _ dataPtr: UnsafePointer, - _ dataLen: Int + _ ptr: UnsafeRawPointer?, + _ dataPtr: UnsafePointer?, + _ dataLen: Int32 ) -> UnsafeMutablePointer? { + guard let ptr, let dataPtr, dataLen >= 0 else { return nil } let identity = Unmanaged.fromOpaque(ptr) .takeUnretainedValue() - let data = Data(bytes: dataPtr, count: dataLen) + let data = Data(bytes: dataPtr, count: Int(dataLen)) + guard let signature = try? identity.sign(data) else { return nil } + return strdup(signature.base64EncodedString()) +} + +/// Load or create the persistent provider-bound identity. +/// +/// The backing private key is a permanent Secure Enclave key stored under the +/// signed provider's keychain access group. Unsigned, ad hoc-signed, or +/// differently team-signed forks should fail here with missing entitlement. +@_cdecl("eigeninference_provider_identity_load_or_create") +public func eigeninference_provider_identity_load_or_create() -> UnsafeMutableRawPointer? { + guard SecureEnclave.isAvailable else { return nil } + guard let identity = try? ProviderBoundIdentity() else { return nil } + return Unmanaged.passRetained(identity as AnyObject).toOpaque() +} + +/// Free an identity created by `eigeninference_provider_identity_load_or_create`. +@_cdecl("eigeninference_provider_identity_free") +public func eigeninference_provider_identity_free(_ ptr: UnsafeMutableRawPointer?) { + guard let ptr else { return } + Unmanaged.fromOpaque(ptr).release() +} + +/// Get the provider-bound identity public key as base64 raw P-256 bytes. +@_cdecl("eigeninference_provider_identity_public_key_base64") +public func eigeninference_provider_identity_public_key_base64( + _ ptr: UnsafeRawPointer? +) -> UnsafeMutablePointer? { + guard let ptr else { return nil } + let identity = Unmanaged.fromOpaque(ptr) + .takeUnretainedValue() + guard let base64 = try? identity.publicKeyBase64 else { return nil } + return strdup(base64) +} + +/// Sign data with the persistent provider-bound identity. +@_cdecl("eigeninference_provider_identity_sign") +public func eigeninference_provider_identity_sign( + _ ptr: UnsafeRawPointer?, + _ dataPtr: UnsafePointer?, + _ dataLen: Int32 +) -> UnsafeMutablePointer? { + guard let ptr, let dataPtr, dataLen >= 0 else { return nil } + let identity = Unmanaged.fromOpaque(ptr) + .takeUnretainedValue() + let data = Data(bytes: dataPtr, count: Int(dataLen)) guard let signature = try? identity.sign(data) else { return nil } return strdup(signature.base64EncodedString()) } @@ -102,11 +151,12 @@ public func eigeninference_enclave_sign( /// Returns 1 if the signature is valid, 0 otherwise. @_cdecl("eigeninference_enclave_verify") public func eigeninference_enclave_verify( - _ pubKeyBase64: UnsafePointer, - _ dataPtr: UnsafePointer, - _ dataLen: Int, - _ sigBase64: UnsafePointer + _ pubKeyBase64: UnsafePointer?, + _ dataPtr: UnsafePointer?, + _ dataLen: Int32, + _ sigBase64: UnsafePointer? ) -> Int32 { + guard let pubKeyBase64, let dataPtr, let sigBase64, dataLen >= 0 else { return 0 } let pubKeyStr = String(cString: pubKeyBase64) let sigStr = String(cString: sigBase64) @@ -115,7 +165,7 @@ public func eigeninference_enclave_verify( return 0 } - let data = Data(bytes: dataPtr, count: dataLen) + let data = Data(bytes: dataPtr, count: Int(dataLen)) return SecureEnclaveIdentity.verify( signature: sigData, for: data, @@ -133,7 +183,7 @@ public func eigeninference_enclave_verify( /// Returns NULL on failure. @_cdecl("eigeninference_enclave_create_attestation") public func eigeninference_enclave_create_attestation( - _ ptr: UnsafeRawPointer + _ ptr: UnsafeRawPointer? ) -> UnsafeMutablePointer? { return eigeninference_enclave_create_attestation_with_key(ptr, nil) } @@ -154,9 +204,10 @@ public func eigeninference_enclave_create_attestation( /// Returns NULL on failure. @_cdecl("eigeninference_enclave_create_attestation_with_key") public func eigeninference_enclave_create_attestation_with_key( - _ ptr: UnsafeRawPointer, + _ ptr: UnsafeRawPointer?, _ encryptionKeyBase64: UnsafePointer? ) -> UnsafeMutablePointer? { + guard let ptr else { return nil } let identity = Unmanaged.fromOpaque(ptr) .takeUnretainedValue() let service = AttestationService(identity: identity) @@ -182,10 +233,11 @@ public func eigeninference_enclave_create_attestation_with_key( /// path. Both the encryption key and binary hash are optional (pass NULL to omit). @_cdecl("eigeninference_enclave_create_attestation_full") public func eigeninference_enclave_create_attestation_full( - _ ptr: UnsafeRawPointer, + _ ptr: UnsafeRawPointer?, _ encryptionKeyBase64: UnsafePointer?, _ binaryHashHex: UnsafePointer? ) -> UnsafeMutablePointer? { + guard let ptr else { return nil } let identity = Unmanaged.fromOpaque(ptr) .takeUnretainedValue() let service = AttestationService(identity: identity) diff --git a/enclave/Sources/EigenInferenceEnclave/ProviderBoundIdentity.swift b/enclave/Sources/EigenInferenceEnclave/ProviderBoundIdentity.swift new file mode 100644 index 00000000..3fe1bdc6 --- /dev/null +++ b/enclave/Sources/EigenInferenceEnclave/ProviderBoundIdentity.swift @@ -0,0 +1,116 @@ +import Foundation +import Security + +/// Persistent provider identity scoped to the Darkbloom keychain access group. +/// +/// This is distinct from the per-launch attestation key. It is created as a +/// permanent Secure Enclave private key, stored by Security.framework in the +/// keychain access group embedded in the signed provider entitlement. A fork +/// signed outside the Darkbloom Team ID cannot load or use this key. +public final class ProviderBoundIdentity { + private static let defaultAccessGroup = "SLDQ2GJ6TL.io.darkbloom.provider" + private static let applicationTag = "io.darkbloom.provider.identity.v1".data(using: .utf8)! + + private let privateKey: SecKey + + public init() throws { + if let existing = try Self.findIdentityKey() { + self.privateKey = existing + return + } + self.privateKey = try Self.createIdentityKey() + } + + public var publicKeyBase64: String { + get throws { + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw ProviderBoundIdentityError.publicKeyUnavailable + } + var error: Unmanaged? + guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else { + throw ProviderBoundIdentityError.securityError(error?.takeRetainedValue()) + } + return publicKeyData.base64EncodedString() + } + } + + public func sign(_ data: Data) throws -> Data { + let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256 + guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else { + throw ProviderBoundIdentityError.unsupportedAlgorithm + } + + var error: Unmanaged? + guard let signature = SecKeyCreateSignature(privateKey, algorithm, data as CFData, &error) as Data? else { + throw ProviderBoundIdentityError.securityError(error?.takeRetainedValue()) + } + return signature + } + + private static func findIdentityKey() throws -> SecKey? { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrKeyClass as String: kSecAttrKeyClassPrivate, + kSecAttrApplicationTag as String: applicationTag, + kSecAttrAccessGroup as String: accessGroup, + kSecUseDataProtectionKeychain as String: true, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw ProviderBoundIdentityError.osStatus(status) + } + guard let key = item else { + throw ProviderBoundIdentityError.publicKeyUnavailable + } + return (key as! SecKey) + } + + private static func createIdentityKey() throws -> SecKey { + var accessError: Unmanaged? + guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + .privateKeyUsage, + &accessError + ) else { + throw ProviderBoundIdentityError.securityError(accessError?.takeRetainedValue()) + } + + let privateAttributes: [String: Any] = [ + kSecAttrIsPermanent as String: true, + kSecAttrAccessControl as String: accessControl, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrApplicationTag as String: applicationTag, + ] + + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecUseDataProtectionKeychain as String: true, + kSecPrivateKeyAttrs as String: privateAttributes, + ] + + var createError: Unmanaged? + guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &createError) else { + throw ProviderBoundIdentityError.securityError(createError?.takeRetainedValue()) + } + return key + } + + private static var accessGroup: String { defaultAccessGroup } +} + +public enum ProviderBoundIdentityError: Error { + case osStatus(OSStatus) + case publicKeyUnavailable + case unsupportedAlgorithm + case securityError(CFError?) +} diff --git a/enclave/Sources/EigenInferenceEnclaveCLI/main.swift b/enclave/Sources/EigenInferenceEnclaveCLI/main.swift index a34f3a4b..9aca6f0c 100644 --- a/enclave/Sources/EigenInferenceEnclaveCLI/main.swift +++ b/enclave/Sources/EigenInferenceEnclaveCLI/main.swift @@ -8,6 +8,7 @@ import Foundation /// /// Usage: /// eigeninference-enclave attest [--encryption-key ] [--binary-hash ] +/// eigeninference-enclave provider-identity-info /// eigeninference-enclave info /// /// All keys are ephemeral — a fresh P-256 key is created in the Secure Enclave @@ -19,6 +20,8 @@ func printUsage() { Commands: attest Generate a signed attestation blob (ephemeral key) + provider-identity-info + Load/create the provider-bound keychain identity info Show Secure Enclave availability Options for 'attest': @@ -72,6 +75,26 @@ func cmdInfo() throws { } } +func cmdProviderIdentityInfo() throws { + guard SecureEnclave.isAvailable else { + fputs("error: Secure Enclave is not available on this device\n", stderr) + exit(1) + } + + let identity = try ProviderBoundIdentity() + let info: [String: Any] = [ + "provider_identity_public_key": try identity.publicKeyBase64, + "key_persistence": "keychain-access-group", + ] + let jsonData = try JSONSerialization.data( + withJSONObject: info, + options: [.sortedKeys, .prettyPrinted] + ) + if let jsonStr = String(data: jsonData, encoding: .utf8) { + print(jsonStr) + } +} + // MARK: - Argument Parsing let args = CommandLine.arguments @@ -106,6 +129,9 @@ do { case "info": try cmdInfo() + case "provider-identity-info": + try cmdProviderIdentityInfo() + default: fputs("error: unknown command '\(command)'\n", stderr) printUsage() diff --git a/enclave/Tests/EigenInferenceEnclaveTests/SecureEnclaveTests.swift b/enclave/Tests/EigenInferenceEnclaveTests/SecureEnclaveTests.swift index 07566f29..7806bbd2 100644 --- a/enclave/Tests/EigenInferenceEnclaveTests/SecureEnclaveTests.swift +++ b/enclave/Tests/EigenInferenceEnclaveTests/SecureEnclaveTests.swift @@ -159,7 +159,7 @@ final class SecureEnclaveTests: XCTestCase { eigeninference_enclave_sign( ptr, buf.baseAddress!.assumingMemoryBound(to: UInt8.self), - message.count + Int32(message.count) ) } guard let sigPtr = sigPtr else { @@ -181,7 +181,7 @@ final class SecureEnclaveTests: XCTestCase { eigeninference_enclave_verify( pubKeyBase64, buf.baseAddress!.assumingMemoryBound(to: UInt8.self), - message.count, + Int32(message.count), sigBase64 ) } @@ -255,7 +255,7 @@ final class SecureEnclaveTests: XCTestCase { eigeninference_enclave_sign( ptr, buf.baseAddress!.assumingMemoryBound(to: UInt8.self), - message.count + Int32(message.count) ) } guard let sigPtr = sigPtr else { @@ -275,7 +275,7 @@ final class SecureEnclaveTests: XCTestCase { eigeninference_enclave_verify( pubKeyBase64, buf.baseAddress!.assumingMemoryBound(to: UInt8.self), - message.count, + Int32(message.count), sigBase64 ) } diff --git a/enclave/include/eigeninference_enclave.h b/enclave/include/eigeninference_enclave.h index b2e74b2c..79f0fc79 100644 --- a/enclave/include/eigeninference_enclave.h +++ b/enclave/include/eigeninference_enclave.h @@ -47,6 +47,34 @@ char* eigeninference_enclave_sign( int data_len ); +/* + * Load or create the persistent provider-bound identity. + * The key is stored in the signed provider keychain access group. + */ +EigenInferenceEnclaveIdentity eigeninference_provider_identity_load_or_create(void); + +/* + * Free an identity created by eigeninference_provider_identity_load_or_create(). + */ +void eigeninference_provider_identity_free(EigenInferenceEnclaveIdentity identity); + +/* + * Get the provider-bound identity public key as a base64 string. + * Caller must free the returned string with eigeninference_enclave_free_string(). + */ +char* eigeninference_provider_identity_public_key_base64(EigenInferenceEnclaveIdentity identity); + +/* + * Sign data with the provider-bound identity private key. + * Returns the DER-encoded ECDSA signature as a base64 null-terminated string. + * Caller must free the returned string with eigeninference_enclave_free_string(). + */ +char* eigeninference_provider_identity_sign( + EigenInferenceEnclaveIdentity identity, + const uint8_t* data, + int data_len +); + /* * Verify a P-256 ECDSA signature. * pub_key_base64: signer's raw public key (base64) diff --git a/provider/src/coordinator.rs b/provider/src/coordinator.rs index 1c7d5a38..45f966b2 100644 --- a/provider/src/coordinator.rs +++ b/provider/src/coordinator.rs @@ -99,6 +99,8 @@ pub struct CoordinatorClient { backend_capacity: Arc>>, /// Ephemeral Secure Enclave handle for challenge-response signing. se_handle: Option>, + /// Persistent entitlement-gated provider identity for private text trust. + provider_identity: Option>, } impl CoordinatorClient { @@ -132,6 +134,7 @@ impl CoordinatorClient { model_hashes: std::collections::HashMap::new(), backend_capacity: Arc::new(std::sync::Mutex::new(None)), se_handle: None, + provider_identity: None, } } @@ -213,6 +216,15 @@ impl CoordinatorClient { self } + /// Set the persistent provider-bound identity handle. + pub fn with_provider_identity( + mut self, + handle: Option>, + ) -> Self { + self.provider_identity = handle; + self + } + /// Set the shared backend capacity data (updated by main loop, read by heartbeats). pub fn with_backend_capacity( mut self, @@ -324,13 +336,65 @@ impl CoordinatorClient { env_scrubbed: true, hypervisor_active: crate::security::check_hypervisor_active(), }; + let version = env!("CARGO_PKG_VERSION").to_string(); + let binary_hash = crate::security::self_binary_hash(); + let provider_identity_public_key = self + .provider_identity + .as_ref() + .map(|h| h.public_key_base64().to_string()); + let provider_identity_signature = match ( + self.provider_identity.as_ref(), + provider_identity_public_key.as_deref(), + ) { + (Some(identity), Some(identity_public_key)) => { + match build_provider_identity_registration_canonical( + identity_public_key, + self.public_key.as_deref(), + binary_hash.as_deref(), + Some(version.as_str()), + &self.backend_name, + true, + python_hash.as_deref(), + runtime_hash.as_deref(), + &template_hashes, + Some(&privacy_caps), + ) { + Ok(bytes) => match identity.sign(&bytes) { + Ok(sig) => Some(sig), + Err(e) => { + tracing::warn!( + "failed to sign provider-bound registration: {e}; private text routing will be disabled" + ); + None + } + }, + Err(e) => { + tracing::warn!( + "failed to build provider-bound registration payload: {e}; private text routing will be disabled" + ); + None + } + } + } + _ => { + if self.public_key.is_some() { + tracing::warn!( + "provider-bound identity unavailable; coordinator will reject private text routing" + ); + } + None + } + }; let register = ProviderMessage::Register { hardware: self.hardware.clone(), models: self.models.clone(), backend: self.backend_name.clone(), - version: Some(env!("CARGO_PKG_VERSION").to_string()), + version: Some(version), public_key: self.public_key.clone(), + binary_hash, + provider_identity_public_key, + provider_identity_signature, encrypted_response_chunks: true, wallet_address: self.wallet_address.clone(), attestation: self.attestation.clone(), @@ -482,6 +546,7 @@ impl CoordinatorClient { .or(self.runtime_hashes.as_ref()), self.model_hashes.clone(), self.se_handle.as_deref(), + self.provider_identity.as_deref(), ); let json = serde_json::to_string(&response) .unwrap_or_default(); @@ -603,6 +668,7 @@ pub fn handle_attestation_challenge( runtime_hashes: Option<&RuntimeHashes>, model_hashes: std::collections::HashMap, se_handle: Option<&crate::secure_enclave_key::SecureEnclaveHandle>, + provider_identity: Option<&crate::secure_enclave_key::ProviderIdentityHandle>, ) -> ProviderMessage { let data = format!("{}{}", nonce, timestamp); @@ -624,6 +690,7 @@ pub fn handle_attestation_challenge( let sip_enabled = crate::security::check_sip_enabled(); let rdma_disabled = crate::security::check_rdma_disabled(); let hypervisor_active = crate::security::check_hypervisor_active(); + let secure_boot_enabled = crate::security::check_secure_boot_enabled(); // Fresh binary hash — re-computed each challenge (~1ms for <50MB binary). let binary_hash = crate::security::self_binary_hash(); @@ -666,7 +733,7 @@ pub fn handle_attestation_challenge( Some(hypervisor_active), Some(rdma_disabled), Some(sip_enabled), - Some(true), + secure_boot_enabled, binary_hash.as_deref(), active_model_hash_owned.as_deref(), python_hash.as_deref(), @@ -686,16 +753,47 @@ pub fn handle_attestation_challenge( None } }; + let provider_identity_signature = provider_identity.and_then(|identity| { + match build_provider_identity_challenge_canonical( + identity.public_key_base64(), + pk_str, + nonce, + timestamp, + Some(hypervisor_active), + Some(rdma_disabled), + Some(sip_enabled), + secure_boot_enabled, + binary_hash.as_deref(), + active_model_hash_owned.as_deref(), + python_hash.as_deref(), + rt_hash.as_deref(), + &template_hashes, + &model_hashes, + ) { + Ok(bytes) => match identity.sign(&bytes) { + Ok(sig) => Some(sig), + Err(e) => { + tracing::warn!("failed to sign provider-bound challenge payload: {e}"); + None + } + }, + Err(e) => { + tracing::warn!("failed to build provider-bound challenge payload: {e}"); + None + } + } + }); ProviderMessage::AttestationResponse { nonce: nonce.to_string(), signature, status_signature, + provider_identity_signature, public_key: pk_str.to_string(), hypervisor_active: Some(hypervisor_active), rdma_disabled: Some(rdma_disabled), sip_enabled: Some(sip_enabled), - secure_boot_enabled: Some(true), // Apple Silicon always has Secure Boot in Full Security mode + secure_boot_enabled, binary_hash, active_model_hash: active_model_hash_owned, python_hash, @@ -809,6 +907,195 @@ fn build_status_canonical( serde_json::to_vec(&m) } +#[allow(clippy::too_many_arguments)] +fn build_provider_identity_registration_canonical( + provider_identity_public_key: &str, + public_key: Option<&str>, + binary_hash: Option<&str>, + version: Option<&str>, + backend: &str, + encrypted_response_chunks: bool, + python_hash: Option<&str>, + runtime_hash: Option<&str>, + template_hashes: &std::collections::HashMap, + privacy_capabilities: Option<&crate::protocol::PrivacyCapabilities>, +) -> serde_json::Result> { + use std::collections::BTreeMap; + + let mut m: BTreeMap<&str, serde_json::Value> = BTreeMap::new(); + m.insert("backend", serde_json::Value::String(backend.to_string())); + m.insert( + "domain", + serde_json::Value::String("darkbloom.provider.registration.v1".to_string()), + ); + m.insert( + "encrypted_response_chunks", + serde_json::Value::Bool(encrypted_response_chunks), + ); + m.insert( + "provider_identity_public_key", + serde_json::Value::String(provider_identity_public_key.to_string()), + ); + if let Some(v) = public_key { + if !v.is_empty() { + m.insert("public_key", serde_json::Value::String(v.to_string())); + } + } + if let Some(v) = binary_hash { + if !v.is_empty() { + m.insert("binary_hash", serde_json::Value::String(v.to_string())); + } + } + if let Some(v) = version { + if !v.is_empty() { + m.insert("version", serde_json::Value::String(v.to_string())); + } + } + if let Some(v) = python_hash { + if !v.is_empty() { + m.insert("python_hash", serde_json::Value::String(v.to_string())); + } + } + if let Some(v) = runtime_hash { + if !v.is_empty() { + m.insert("runtime_hash", serde_json::Value::String(v.to_string())); + } + } + if !template_hashes.is_empty() { + let sorted: BTreeMap<&str, &str> = template_hashes + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + m.insert("template_hashes", serde_json::to_value(sorted)?); + } + if let Some(caps) = privacy_capabilities { + let mut c: BTreeMap<&str, serde_json::Value> = BTreeMap::new(); + c.insert( + "anti_debug_enabled", + serde_json::Value::Bool(caps.anti_debug_enabled), + ); + c.insert( + "core_dumps_disabled", + serde_json::Value::Bool(caps.core_dumps_disabled), + ); + c.insert( + "dangerous_modules_blocked", + serde_json::Value::Bool(caps.dangerous_modules_blocked), + ); + c.insert("env_scrubbed", serde_json::Value::Bool(caps.env_scrubbed)); + c.insert( + "hypervisor_active", + serde_json::Value::Bool(caps.hypervisor_active), + ); + c.insert( + "python_runtime_locked", + serde_json::Value::Bool(caps.python_runtime_locked), + ); + c.insert("sip_enabled", serde_json::Value::Bool(caps.sip_enabled)); + c.insert( + "text_backend_inprocess", + serde_json::Value::Bool(caps.text_backend_inprocess), + ); + c.insert( + "text_proxy_disabled", + serde_json::Value::Bool(caps.text_proxy_disabled), + ); + m.insert("privacy_capabilities", serde_json::to_value(c)?); + } + + serde_json::to_vec(&m) +} + +#[allow(clippy::too_many_arguments)] +fn build_provider_identity_challenge_canonical( + provider_identity_public_key: &str, + public_key: &str, + nonce: &str, + timestamp: &str, + hypervisor_active: Option, + rdma_disabled: Option, + sip_enabled: Option, + secure_boot_enabled: Option, + binary_hash: Option<&str>, + active_model_hash: Option<&str>, + python_hash: Option<&str>, + runtime_hash: Option<&str>, + template_hashes: &std::collections::HashMap, + model_hashes: &std::collections::HashMap, +) -> serde_json::Result> { + use std::collections::BTreeMap; + + let mut m: BTreeMap<&str, serde_json::Value> = BTreeMap::new(); + m.insert( + "domain", + serde_json::Value::String("darkbloom.provider.challenge.v1".to_string()), + ); + m.insert("nonce", serde_json::Value::String(nonce.to_string())); + m.insert( + "provider_identity_public_key", + serde_json::Value::String(provider_identity_public_key.to_string()), + ); + m.insert( + "public_key", + serde_json::Value::String(public_key.to_string()), + ); + m.insert( + "timestamp", + serde_json::Value::String(timestamp.to_string()), + ); + if let Some(v) = hypervisor_active { + m.insert("hypervisor_active", serde_json::Value::Bool(v)); + } + if let Some(v) = rdma_disabled { + m.insert("rdma_disabled", serde_json::Value::Bool(v)); + } + if let Some(v) = sip_enabled { + m.insert("sip_enabled", serde_json::Value::Bool(v)); + } + if let Some(v) = secure_boot_enabled { + m.insert("secure_boot_enabled", serde_json::Value::Bool(v)); + } + if let Some(v) = binary_hash { + if !v.is_empty() { + m.insert("binary_hash", serde_json::Value::String(v.to_string())); + } + } + if let Some(v) = active_model_hash { + if !v.is_empty() { + m.insert( + "active_model_hash", + serde_json::Value::String(v.to_string()), + ); + } + } + if let Some(v) = python_hash { + if !v.is_empty() { + m.insert("python_hash", serde_json::Value::String(v.to_string())); + } + } + if let Some(v) = runtime_hash { + if !v.is_empty() { + m.insert("runtime_hash", serde_json::Value::String(v.to_string())); + } + } + if !template_hashes.is_empty() { + let sorted: BTreeMap<&str, &str> = template_hashes + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + m.insert("template_hashes", serde_json::to_value(sorted)?); + } + if !model_hashes.is_empty() { + let sorted: BTreeMap<&str, &str> = model_hashes + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + m.insert("model_hashes", serde_json::to_value(sorted)?); + } + + serde_json::to_vec(&m) +} + /// Build the register message for a given hardware, models, and backend. #[allow(dead_code)] pub fn build_register_message( @@ -836,6 +1123,9 @@ pub fn build_register_message_with_wallet( backend: backend_name.to_string(), version: None, public_key, + binary_hash: None, + provider_identity_public_key: None, + provider_identity_signature: None, encrypted_response_chunks: true, wallet_address, attestation, @@ -961,6 +1251,127 @@ mod tests { ); } + #[test] + fn test_provider_identity_registration_canonical_golden_bytes() { + let mut templates = std::collections::HashMap::new(); + templates.insert("z".to_string(), "2".to_string()); + templates.insert("a".to_string(), "1".to_string()); + + let caps = crate::protocol::PrivacyCapabilities { + text_backend_inprocess: true, + text_proxy_disabled: true, + python_runtime_locked: true, + dangerous_modules_blocked: true, + sip_enabled: true, + anti_debug_enabled: true, + core_dumps_disabled: true, + env_scrubbed: true, + hypervisor_active: false, + }; + + let bytes = build_provider_identity_registration_canonical( + "idpk", + Some("x25519"), + Some("binhash"), + Some("0.4.7"), + "inprocess-mlx", + true, + Some("py"), + Some("rt"), + &templates, + Some(&caps), + ) + .expect("canonical build should succeed"); + + let expected = br#"{"backend":"inprocess-mlx","binary_hash":"binhash","domain":"darkbloom.provider.registration.v1","encrypted_response_chunks":true,"privacy_capabilities":{"anti_debug_enabled":true,"core_dumps_disabled":true,"dangerous_modules_blocked":true,"env_scrubbed":true,"hypervisor_active":false,"python_runtime_locked":true,"sip_enabled":true,"text_backend_inprocess":true,"text_proxy_disabled":true},"provider_identity_public_key":"idpk","public_key":"x25519","python_hash":"py","runtime_hash":"rt","template_hashes":{"a":"1","z":"2"},"version":"0.4.7"}"#; + assert_eq!(bytes, expected.to_vec()); + } + + #[test] + fn test_provider_identity_challenge_canonical_golden_bytes() { + let mut templates = std::collections::HashMap::new(); + templates.insert("z".to_string(), "2".to_string()); + templates.insert("a".to_string(), "1".to_string()); + + let mut models = std::collections::HashMap::new(); + models.insert("qwen".to_string(), "abc".to_string()); + models.insert("llama".to_string(), "def".to_string()); + + let bytes = build_provider_identity_challenge_canonical( + "idpk", + "x25519", + "nonce", + "2026-04-28T20:00:00Z", + Some(true), + Some(true), + Some(true), + Some(true), + Some("binhash"), + Some("active"), + Some("py"), + Some("rt"), + &templates, + &models, + ) + .expect("canonical build should succeed"); + + let expected = br#"{"active_model_hash":"active","binary_hash":"binhash","domain":"darkbloom.provider.challenge.v1","hypervisor_active":true,"model_hashes":{"llama":"def","qwen":"abc"},"nonce":"nonce","provider_identity_public_key":"idpk","public_key":"x25519","python_hash":"py","rdma_disabled":true,"runtime_hash":"rt","secure_boot_enabled":true,"sip_enabled":true,"template_hashes":{"a":"1","z":"2"},"timestamp":"2026-04-28T20:00:00Z"}"#; + assert_eq!(bytes, expected.to_vec()); + } + + #[test] + fn test_provider_identity_registration_canonical_does_not_escape_html() { + let mut templates = std::collections::HashMap::new(); + templates.insert("a<&>".to_string(), "v<&>".to_string()); + + let bytes = build_provider_identity_registration_canonical( + "id<&>pk", + Some("x25519"), + None, + None, + "inprocess-mlx", + true, + None, + None, + &templates, + None, + ) + .expect("canonical build should succeed"); + + let expected = br#"{"backend":"inprocess-mlx","domain":"darkbloom.provider.registration.v1","encrypted_response_chunks":true,"provider_identity_public_key":"id<&>pk","public_key":"x25519","template_hashes":{"a<&>":"v<&>"}}"#; + assert_eq!(bytes, expected.to_vec()); + } + + #[test] + fn test_provider_identity_challenge_canonical_does_not_escape_html() { + let mut templates = std::collections::HashMap::new(); + templates.insert("a<&>".to_string(), "v<&>".to_string()); + + let mut models = std::collections::HashMap::new(); + models.insert("m<&>".to_string(), "w<&>".to_string()); + + let bytes = build_provider_identity_challenge_canonical( + "id<&>pk", + "x<&>25519", + "n<&>", + "2026-04-28T20:00:00Z", + None, + None, + None, + None, + Some("bin<&>hash"), + None, + None, + None, + &templates, + &models, + ) + .expect("canonical build should succeed"); + + let expected = br#"{"binary_hash":"bin<&>hash","domain":"darkbloom.provider.challenge.v1","model_hashes":{"m<&>":"w<&>"},"nonce":"n<&>","provider_identity_public_key":"id<&>pk","public_key":"x<&>25519","template_hashes":{"a<&>":"v<&>"},"timestamp":"2026-04-28T20:00:00Z"}"#; + assert_eq!(bytes, expected.to_vec()); + } + /// Mirror of Go's TestBuildStatusCanonicalUnicodeNonce. Both /// serializers must pass printable Unicode through as UTF-8 (no /// double-escaping). Today nonces are base64 ASCII so this is @@ -1054,6 +1465,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); match response { @@ -1084,6 +1496,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); match response { @@ -1113,6 +1526,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); let resp2 = handle_attestation_challenge( "bm9uY2U=", @@ -1122,6 +1536,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); // Same inputs should produce same output (deterministic). @@ -1144,6 +1559,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); let resp2 = handle_attestation_challenge( "bm9uY2Uy", @@ -1153,6 +1569,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); // The nonce fields must differ. @@ -1177,6 +1594,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); let json = serde_json::to_string(&response).unwrap(); assert!(json.contains("\"type\":\"attestation_response\"")); @@ -1338,6 +1756,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); match response { @@ -1345,6 +1764,7 @@ mod tests { nonce, signature, status_signature: _, + provider_identity_signature: _, public_key, hypervisor_active, rdma_disabled, @@ -1364,17 +1784,15 @@ mod tests { let _ = signature; // Public key matches input assert_eq!(public_key, "cHVibGljLWtleQ=="); - // All security status fields are populated + // Security status fields that have reliable software checks are populated. assert!(sip_enabled.is_some(), "sip_enabled must be present"); assert!(rdma_disabled.is_some(), "rdma_disabled must be present"); assert!( hypervisor_active.is_some(), "hypervisor_active must be present" ); - assert!( - secure_boot_enabled.is_some(), - "secure_boot_enabled must be present" - ); + // Secure Boot is omitted when macOS cannot provide a non-interactive answer. + let _ = secure_boot_enabled; } _ => panic!("Expected AttestationResponse"), } @@ -1392,6 +1810,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); match response { @@ -1413,6 +1832,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); match response { @@ -1437,6 +1857,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); let resp2 = handle_attestation_challenge( "bm9uY2U=", @@ -1446,6 +1867,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); // Both should be valid AttestationResponse messages @@ -1473,6 +1895,7 @@ mod tests { None, std::collections::HashMap::new(), None, + None, ); let json = serde_json::to_string(&response).unwrap(); @@ -1486,7 +1909,6 @@ mod tests { assert!(parsed.get("sip_enabled").is_some()); assert!(parsed.get("rdma_disabled").is_some()); assert!(parsed.get("hypervisor_active").is_some()); - assert!(parsed.get("secure_boot_enabled").is_some()); } #[test] diff --git a/provider/src/main.rs b/provider/src/main.rs index 25678857..86f42804 100644 --- a/provider/src/main.rs +++ b/provider/src/main.rs @@ -2287,6 +2287,25 @@ async fn cmd_serve( } }; + // Load or create the persistent provider-bound identity. This is the + // entitlement-gated key the coordinator requires for private text routing. + let provider_identity: Option> = + match secure_enclave_key::ProviderIdentityHandle::load_or_create() { + Ok(h) => { + tracing::info!( + "Provider-bound identity loaded (public: {})", + h.public_key_base64() + ); + Some(std::sync::Arc::new(h)) + } + Err(e) => { + tracing::warn!( + "Provider-bound identity unavailable: {e}; private text routing will be disabled" + ); + None + } + }; + // Clean up legacy persistent key files from previous versions secure_enclave_key::cleanup_legacy_key_files(); @@ -2617,17 +2636,17 @@ async fn cmd_serve( std::sync::Arc::new(std::sync::Mutex::new(selected_models.clone())); // Compute weight hashes for all active models. - let initial_model_hash = models::compute_weight_hash(&model); - let current_model_hash: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(initial_model_hash.clone())); - rehash_model_hash_opt = Some(current_model_hash.clone()); - - // Collect per-model weight hashes for attestation. let mut all_model_hashes: std::collections::HashMap = std::collections::HashMap::new(); - if let Some(ref h) = initial_model_hash { - all_model_hashes.insert(model.clone(), h.clone()); + for model_id in &selected_models { + if let Some(hash) = models::compute_weight_hash(model_id) { + all_model_hashes.insert(model_id.clone(), hash); + } } + let initial_model_hash = all_model_hashes.get(&model).cloned(); + let current_model_hash: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(initial_model_hash.clone())); + rehash_model_hash_opt = Some(current_model_hash.clone()); // Shared backend capacity data (updated by polling task, read by heartbeats). let backend_capacity: std::sync::Arc>> = @@ -2680,7 +2699,8 @@ async fn cmd_serve( .with_current_model_hash(current_model_hash) .with_model_hashes(all_model_hashes) .with_backend_capacity(backend_capacity) - .with_se_handle(se_handle.clone()); + .with_se_handle(se_handle.clone()) + .with_provider_identity(provider_identity.clone()); // Store coordinator client for deferred spawn after backends are ready. deferred_coordinator = Some((client, event_tx, outbound_rx, shutdown_rx)); @@ -5831,27 +5851,31 @@ async fn cmd_update(coordinator: String, force: bool) -> Result<()> { // Download the bundle println!(" Downloading update..."); - let tmp_path = "/tmp/eigeninference-bundle.tar.gz"; + let download_dir = + UpdateStageDir::new().context("failed to create update download directory")?; + let tmp_path = download_dir.path().join("eigeninference-bundle.tar.gz"); let download = client.get(download_url).send().await?; if !download.status().is_success() { anyhow::bail!("Download failed: {}", download.status()); } let bytes = download.bytes().await?; - std::fs::write(tmp_path, &bytes)?; + std::fs::write(&tmp_path, &bytes)?; println!(" Downloaded {} MB", bytes.len() / 1_048_576); - // Verify bundle hash if provided by the coordinator. + // Verify bundle hash from release metadata before extraction. let expected_hash = info["bundle_hash"].as_str().unwrap_or(""); - if !expected_hash.is_empty() { - let actual_hash = security::sha256_hex(&bytes); - if actual_hash != expected_hash { - std::fs::remove_file(tmp_path).ok(); - anyhow::bail!( - "Bundle hash mismatch — download may be compromised!\n Expected: {expected_hash}\n Got: {actual_hash}" - ); - } - println!(" Hash verified ✓"); + if expected_hash.is_empty() { + std::fs::remove_file(&tmp_path).ok(); + anyhow::bail!("Coordinator did not provide bundle_hash; refusing update"); } + let actual_hash = security::sha256_hex(&bytes); + if actual_hash != expected_hash { + std::fs::remove_file(&tmp_path).ok(); + anyhow::bail!( + "Bundle hash mismatch — download may be compromised!\n Expected: {expected_hash}\n Got: {actual_hash}" + ); + } + println!(" Hash verified ✓"); // Extract and install let eigeninference_dir = dirs::home_dir() @@ -5860,14 +5884,20 @@ async fn cmd_update(coordinator: String, force: bool) -> Result<()> { let bin_dir = eigeninference_dir.join("bin"); println!(" Installing..."); - let status = std::process::Command::new("tar") - .args(["xzf", tmp_path, "-C", &eigeninference_dir.to_string_lossy()]) - .status()?; - if !status.success() { - anyhow::bail!("tar extraction failed"); - } + let stage_dir = UpdateStageDir::new().context("failed to create update staging directory")?; + extract_update_tarball(&tmp_path, stage_dir.path())?; + verify_darkbloom_code_identity( + &bundled_update_binary(stage_dir.path(), "darkbloom"), + "darkbloom", + )?; + let staged_enclave = bundled_update_binary(stage_dir.path(), "eigeninference-enclave"); + if staged_enclave.exists() { + verify_darkbloom_code_identity(&staged_enclave, "eigeninference-enclave")?; + } + copy_update_tree(stage_dir.path(), &eigeninference_dir)?; // Move binaries to bin dir + std::fs::create_dir_all(&bin_dir)?; let _ = std::fs::rename( eigeninference_dir.join("darkbloom"), bin_dir.join("darkbloom"), @@ -5891,7 +5921,7 @@ async fn cmd_update(coordinator: String, force: bool) -> Result<()> { } } - std::fs::remove_file(tmp_path).ok(); + std::fs::remove_file(&tmp_path).ok(); let coordinator_http = base_url .replace("wss://", "https://") @@ -5965,6 +5995,9 @@ fn emit_update_warning(stdout: bool, message: &str) { } } +const EXPECTED_PROVIDER_TEAM_ID: &str = "SLDQ2GJ6TL"; +const EXPECTED_PROVIDER_ACCESS_GROUP: &str = "SLDQ2GJ6TL.io.darkbloom.provider"; + fn parse_codesign_team_identifier(output: &str) -> Option { output.lines().find_map(|line| { line.trim() @@ -5991,6 +6024,56 @@ fn codesign_team_identifier(path: &std::path::Path) -> Result { .ok_or_else(|| anyhow::anyhow!("{} is missing a TeamIdentifier", path.display())) } +fn verify_darkbloom_code_identity(path: &std::path::Path, name: &str) -> Result<()> { + if !path.exists() { + anyhow::bail!("{name} missing at {}", path.display()); + } + + let verify = std::process::Command::new("codesign") + .args(["--verify", "--strict", "--verbose", &path.to_string_lossy()]) + .output() + .with_context(|| format!("failed to verify code signature for {}", path.display()))?; + if !verify.status.success() { + let detail = String::from_utf8_lossy(&verify.stderr).trim().to_string(); + anyhow::bail!("{name} code signature is invalid: {detail}"); + } + + let team = codesign_team_identifier(path)?; + if team != EXPECTED_PROVIDER_TEAM_ID { + anyhow::bail!( + "{name} signed by unexpected Team ID {team}; expected {EXPECTED_PROVIDER_TEAM_ID}" + ); + } + + let entitlements = std::process::Command::new("codesign") + .args(["-d", "--entitlements", ":-", &path.to_string_lossy()]) + .output() + .with_context(|| format!("failed to read entitlements for {}", path.display()))?; + let entitlements_text = format!( + "{}\n{}", + String::from_utf8_lossy(&entitlements.stdout), + String::from_utf8_lossy(&entitlements.stderr) + ); + if !entitlements.status.success() || !entitlements_text.contains(EXPECTED_PROVIDER_ACCESS_GROUP) + { + anyhow::bail!( + "{name} missing provider keychain access-group entitlement {EXPECTED_PROVIDER_ACCESS_GROUP}" + ); + } + + if let Ok(spctl) = std::process::Command::new("spctl") + .args(["-a", "-t", "execute", &path.to_string_lossy()]) + .output() + { + if !spctl.status.success() { + let detail = String::from_utf8_lossy(&spctl.stderr).trim().to_string(); + anyhow::bail!("{name} rejected by Gatekeeper/notarization policy: {detail}"); + } + } + + Ok(()) +} + fn collect_python_core_signature_targets(dir: &std::path::Path, out: &mut Vec) { let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, @@ -6066,6 +6149,12 @@ fn verify_installed_update_runtime( coordinator_http: &str, stdout: bool, ) -> Result<()> { + verify_darkbloom_code_identity(&eigeninference_dir.join("bin/darkbloom"), "darkbloom")?; + let enclave_path = eigeninference_dir.join("bin/eigeninference-enclave"); + if enclave_path.exists() { + verify_darkbloom_code_identity(&enclave_path, "eigeninference-enclave")?; + } + let bundled_python = eigeninference_dir.join("python/bin/python3.12"); if let Err(err) = verify_python_core_signature_match(eigeninference_dir) { @@ -6121,6 +6210,127 @@ fn verify_installed_update_runtime( Ok(()) } +struct UpdateStageDir { + path: std::path::PathBuf, +} + +impl UpdateStageDir { + fn new() -> Result { + let base = std::env::temp_dir(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + for attempt in 0..100 { + let path = base.join(format!( + "darkbloom-update-{}-{now}-{attempt}", + std::process::id() + )); + match std::fs::create_dir(&path) { + Ok(()) => return Ok(Self { path }), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err).context("create update staging directory"), + } + } + anyhow::bail!("could not allocate unique update staging directory") + } + + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for UpdateStageDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn validate_update_tarball(tarball: &std::path::Path) -> Result<()> { + let list = std::process::Command::new("tar") + .args(["tzf", &tarball.to_string_lossy()]) + .output() + .with_context(|| format!("failed to list update archive {}", tarball.display()))?; + if !list.status.success() { + anyhow::bail!("update archive is not readable"); + } + + for member in String::from_utf8_lossy(&list.stdout).lines() { + let path = std::path::Path::new(member); + if member.is_empty() || path.is_absolute() { + anyhow::bail!("update archive contains unsafe path: {member}"); + } + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + anyhow::bail!("update archive contains path traversal: {member}"); + } + } + + let verbose = std::process::Command::new("tar") + .args(["tvzf", &tarball.to_string_lossy()]) + .output() + .with_context(|| format!("failed to inspect update archive {}", tarball.display()))?; + if !verbose.status.success() { + anyhow::bail!("update archive metadata is not readable"); + } + for line in String::from_utf8_lossy(&verbose.stdout).lines() { + if line.starts_with('l') || line.starts_with('h') { + anyhow::bail!("update archive contains links; refusing unsafe archive"); + } + } + + Ok(()) +} + +fn extract_update_tarball(tarball: &std::path::Path, stage_dir: &std::path::Path) -> Result<()> { + validate_update_tarball(tarball)?; + let status = std::process::Command::new("tar") + .args([ + "xzf", + &tarball.to_string_lossy(), + "-C", + &stage_dir.to_string_lossy(), + ]) + .status()?; + if !status.success() { + anyhow::bail!("tar extraction failed"); + } + Ok(()) +} + +fn bundled_update_binary(root: &std::path::Path, name: &str) -> std::path::PathBuf { + let nested = root.join("bin").join(name); + if nested.exists() { + return nested; + } + root.join(name) +} + +fn copy_update_tree(src: &std::path::Path, dst: &std::path::Path) -> Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + let file_type = entry.file_type()?; + if file_type.is_symlink() { + anyhow::bail!("update staging contains symlink: {}", src_path.display()); + } + if file_type.is_dir() { + copy_update_tree(&src_path, &dst_path)?; + } else if file_type.is_file() { + if let Some(parent) = dst_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&src_path, &dst_path) + .with_context(|| format!("failed to install {}", dst_path.display()))?; + } + } + Ok(()) +} + fn backup_installed_binary(path: &std::path::Path) -> Result> { if !path.exists() { return Ok(None); @@ -6186,19 +6396,22 @@ async fn auto_update_check(coordinator_base_url: &str) -> Result { } let bytes = download.bytes().await?; - // Verify bundle hash + // Verify bundle hash before extraction. let expected_hash = info["bundle_hash"].as_str().unwrap_or(""); - if !expected_hash.is_empty() { - let actual_hash = security::sha256_hex(&bytes); - if actual_hash != expected_hash { - anyhow::bail!("bundle hash mismatch — aborting update"); - } - tracing::info!("Bundle hash verified"); + if expected_hash.is_empty() { + anyhow::bail!("coordinator did not provide bundle_hash — aborting update"); } + let actual_hash = security::sha256_hex(&bytes); + if actual_hash != expected_hash { + anyhow::bail!("bundle hash mismatch — aborting update"); + } + tracing::info!("Bundle hash verified"); // Extract and install - let tmp_path = "/tmp/darkbloom-auto-update.tar.gz"; - std::fs::write(tmp_path, &bytes)?; + let download_dir = + UpdateStageDir::new().context("failed to create update download directory")?; + let tmp_path = download_dir.path().join("darkbloom-auto-update.tar.gz"); + std::fs::write(&tmp_path, &bytes)?; let eigeninference_dir = dirs::home_dir() .ok_or_else(|| anyhow::anyhow!("cannot find home directory"))? @@ -6207,14 +6420,20 @@ async fn auto_update_check(coordinator_base_url: &str) -> Result { let darkbloom_backup = backup_installed_binary(&bin_dir.join("darkbloom"))?; let enclave_backup = backup_installed_binary(&bin_dir.join("eigeninference-enclave"))?; - let status = std::process::Command::new("tar") - .args(["xzf", tmp_path, "-C", &eigeninference_dir.to_string_lossy()]) - .status()?; - if !status.success() { - anyhow::bail!("tar extraction failed"); + let stage_dir = UpdateStageDir::new().context("failed to create update staging directory")?; + extract_update_tarball(&tmp_path, stage_dir.path())?; + verify_darkbloom_code_identity( + &bundled_update_binary(stage_dir.path(), "darkbloom"), + "darkbloom", + )?; + let staged_enclave = bundled_update_binary(stage_dir.path(), "eigeninference-enclave"); + if staged_enclave.exists() { + verify_darkbloom_code_identity(&staged_enclave, "eigeninference-enclave")?; } + copy_update_tree(stage_dir.path(), &eigeninference_dir)?; // Move binaries to bin dir + std::fs::create_dir_all(&bin_dir)?; let _ = std::fs::rename( eigeninference_dir.join("darkbloom"), bin_dir.join("darkbloom"), @@ -6237,7 +6456,7 @@ async fn auto_update_check(coordinator_base_url: &str) -> Result { } } - std::fs::remove_file(tmp_path).ok(); + std::fs::remove_file(&tmp_path).ok(); let coordinator_http = coordinator_base_url .replace("wss://", "https://") .replace("ws://", "http://") diff --git a/provider/src/protocol.rs b/provider/src/protocol.rs index 33e53a7d..a0e66448 100644 --- a/provider/src/protocol.rs +++ b/provider/src/protocol.rs @@ -39,6 +39,18 @@ pub enum ProviderMessage { version: Option, #[serde(skip_serializing_if = "Option::is_none")] public_key: Option, + /// Fresh SHA-256 hash of the provider binary. + #[serde(default, skip_serializing_if = "Option::is_none")] + binary_hash: Option, + /// Persistent provider-bound identity public key. This key is stored as + /// a Secure Enclave key under the signed provider keychain access group. + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_identity_public_key: Option, + /// Signature by provider_identity_public_key over the canonical + /// registration payload, binding public_key and runtime claims to the + /// signed Darkbloom provider entitlement. + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_identity_signature: Option, /// True when text response chunks are encrypted back to the coordinator /// using the request's session key. #[serde(default, skip_serializing_if = "is_false")] @@ -129,6 +141,10 @@ pub enum ProviderMessage { /// downgrades trust accordingly. #[serde(default, skip_serializing_if = "Option::is_none")] status_signature: Option, + /// Signature by the persistent provider-bound identity over the + /// canonical challenge/status payload. Required for private text. + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_identity_signature: Option, public_key: String, /// Fresh hypervisor status at time of challenge response. /// When true, inference memory is hardware-isolated via Stage 2 @@ -345,6 +361,9 @@ mod tests { backend: "vllm_mlx".to_string(), version: None, public_key: None, + binary_hash: None, + provider_identity_public_key: None, + provider_identity_signature: None, encrypted_response_chunks: true, wallet_address: None, attestation: None, @@ -390,6 +409,9 @@ mod tests { backend: "vllm_mlx".to_string(), version: None, public_key: None, + binary_hash: None, + provider_identity_public_key: None, + provider_identity_signature: None, encrypted_response_chunks: true, wallet_address: Some("0x1234567890abcdef1234567890abcdef12345678".to_string()), attestation: None, @@ -428,6 +450,9 @@ mod tests { backend: "vllm_mlx".to_string(), version: None, public_key: Some("c29tZWtleQ==".to_string()), + binary_hash: None, + provider_identity_public_key: None, + provider_identity_signature: None, encrypted_response_chunks: true, wallet_address: None, attestation: Some(attestation_raw), @@ -653,6 +678,7 @@ mod tests { nonce: "dGVzdG5vbmNl".to_string(), signature: "c2lnbmF0dXJl".to_string(), status_signature: None, + provider_identity_signature: None, public_key: "cHVia2V5".to_string(), hypervisor_active: Some(true), rdma_disabled: Some(true), @@ -950,6 +976,9 @@ mod tests { backend: "vllm_mlx".to_string(), version: None, public_key: None, + binary_hash: None, + provider_identity_public_key: None, + provider_identity_signature: None, encrypted_response_chunks: true, wallet_address: None, attestation: None, @@ -1017,6 +1046,7 @@ mod tests { nonce: "bm9uY2U=".to_string(), signature: "c2ln".to_string(), status_signature: None, + provider_identity_signature: None, public_key: "cGs=".to_string(), hypervisor_active: Some(false), rdma_disabled: Some(true), @@ -1197,6 +1227,9 @@ mod tests { backend: "vllm_mlx".to_string(), version: None, public_key: None, + binary_hash: None, + provider_identity_public_key: None, + provider_identity_signature: None, encrypted_response_chunks: true, wallet_address: None, attestation: None, diff --git a/provider/src/secure_enclave_key.rs b/provider/src/secure_enclave_key.rs index da9bcee2..ad853d64 100644 --- a/provider/src/secure_enclave_key.rs +++ b/provider/src/secure_enclave_key.rs @@ -47,6 +47,14 @@ unsafe extern "C" { binary_hash_hex: *const c_char, ) -> *mut c_char; fn eigeninference_enclave_free_string(ptr: *mut c_char); + fn eigeninference_provider_identity_load_or_create() -> *mut c_void; + fn eigeninference_provider_identity_free(identity: *mut c_void); + fn eigeninference_provider_identity_public_key_base64(identity: *const c_void) -> *mut c_char; + fn eigeninference_provider_identity_sign( + identity: *const c_void, + data: *const u8, + data_len: c_int, + ) -> *mut c_char; } pub(crate) fn load_existing_x25519_secret() -> Result> { @@ -178,6 +186,89 @@ impl Drop for SecureEnclaveHandle { } } +/// Persistent provider-bound identity. +/// +/// This key is created once as a permanent Secure Enclave key under the +/// Darkbloom provider keychain access group. It is the fork-resistant root used +/// to bind a WebSocket X25519 key and runtime claims to a signed provider. +pub struct ProviderIdentityHandle { + #[cfg(target_os = "macos")] + ptr: *mut c_void, + public_key_b64: String, +} + +unsafe impl Send for ProviderIdentityHandle {} +unsafe impl Sync for ProviderIdentityHandle {} + +impl ProviderIdentityHandle { + #[cfg(target_os = "macos")] + pub fn load_or_create() -> Result { + let ptr = unsafe { eigeninference_provider_identity_load_or_create() }; + if ptr.is_null() { + return Err(anyhow!( + "provider-bound identity unavailable; signed release build with keychain entitlement required" + )); + } + + let pk_ptr = unsafe { eigeninference_provider_identity_public_key_base64(ptr) }; + if pk_ptr.is_null() { + unsafe { eigeninference_provider_identity_free(ptr) }; + return Err(anyhow!("failed to retrieve provider identity public key")); + } + let public_key_b64 = unsafe { CStr::from_ptr(pk_ptr) } + .to_string_lossy() + .into_owned(); + unsafe { eigeninference_enclave_free_string(pk_ptr) }; + + Ok(Self { + ptr, + public_key_b64, + }) + } + + #[cfg(not(target_os = "macos"))] + pub fn load_or_create() -> Result { + Err(anyhow!( + "provider-bound identity is only available on macOS Secure Enclave" + )) + } + + pub fn public_key_base64(&self) -> &str { + &self.public_key_b64 + } + + #[cfg(target_os = "macos")] + pub fn sign(&self, data: &[u8]) -> Result { + let data_len: c_int = data.len().try_into().context("data too large for FFI")?; + let sig_ptr = + unsafe { eigeninference_provider_identity_sign(self.ptr, data.as_ptr(), data_len) }; + if sig_ptr.is_null() { + return Err(anyhow!("provider identity signing failed")); + } + let sig = unsafe { CStr::from_ptr(sig_ptr) } + .to_string_lossy() + .into_owned(); + unsafe { eigeninference_enclave_free_string(sig_ptr) }; + Ok(sig) + } + + #[cfg(not(target_os = "macos"))] + pub fn sign(&self, _data: &[u8]) -> Result { + Err(anyhow!( + "provider-bound identity is only available on macOS Secure Enclave" + )) + } +} + +#[cfg(target_os = "macos")] +impl Drop for ProviderIdentityHandle { + fn drop(&mut self) { + if !self.ptr.is_null() { + unsafe { eigeninference_provider_identity_free(self.ptr) }; + } + } +} + /// Check that no legacy key files exist in `~/.darkbloom/`. /// /// Returns a list of legacy files that still exist. Empty means clean. diff --git a/provider/src/security.rs b/provider/src/security.rs index 9bba5543..7c653c3b 100644 --- a/provider/src/security.rs +++ b/provider/src/security.rs @@ -207,6 +207,41 @@ pub fn check_rdma_disabled() -> bool { } } +/// Check whether Secure Boot is in Full Security mode. +/// +/// Returns None when macOS does not expose a non-interactive answer. Callers +/// should omit the claim rather than sign a hard-coded safe value. +pub fn check_secure_boot_enabled() -> Option { + #[cfg(target_os = "macos")] + { + let output = Command::new("/usr/sbin/bputil").arg("-d").output().ok()?; + if !output.status.success() { + tracing::warn!( + "Secure Boot check: bputil exited with status {:?}", + output.status.code() + ); + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); + if stdout.contains("full security") { + return Some(true); + } + if stdout.contains("reduced security") || stdout.contains("permissive security") { + return Some(false); + } + tracing::warn!( + "Secure Boot check: bputil output did not include a recognized security mode" + ); + None + } + + #[cfg(not(target_os = "macos"))] + { + tracing::debug!("Secure Boot check: not applicable on this platform"); + None + } +} + /// Check if hypervisor memory isolation is active. /// /// When active, inference memory is protected by Stage 2 page tables diff --git a/scripts/build-bundle.sh b/scripts/build-bundle.sh index 8d111d65..38be96d5 100755 --- a/scripts/build-bundle.sh +++ b/scripts/build-bundle.sh @@ -12,6 +12,7 @@ set -euo pipefail # ./scripts/build-bundle.sh # Build tarball only # ./scripts/build-bundle.sh --upload # Build + upload to server # ./scripts/build-bundle.sh --skip-build # Skip Rust/Swift builds (reuse existing) +# APPLE_SIGNING_IDENTITY="Developer ID Application: Eigen Labs, Inc. (SLDQ2GJ6TL)" ./scripts/build-bundle.sh --upload # # Requirements: # - macOS with Apple Silicon (arm64) @@ -30,12 +31,17 @@ PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/ UPLOAD=false SKIP_BUILD=false +IDENTITY="${APPLE_SIGNING_IDENTITY:--}" for arg in "$@"; do case "$arg" in --upload) UPLOAD=true ;; --skip-build) SKIP_BUILD=true ;; esac done +if [ "$UPLOAD" = true ] && [ "$IDENTITY" = "-" ]; then + echo "ERROR: --upload requires APPLE_SIGNING_IDENTITY; refusing to upload ad-hoc signed binaries." + exit 1 +fi echo "╔══════════════════════════════════════════════════╗" echo "║ EigenInference Bundle Builder ║" @@ -103,7 +109,7 @@ rm -f "$BUNDLE_DIR/python/lib/python3.12/EXTERNALLY-MANAGED" echo " Code-signing portable Python runtime..." find "$BUNDLE_DIR/python" -type f | while read -r file; do if file "$file" | grep -q "Mach-O"; then - codesign --force --sign - --options runtime "$file" + codesign --force --sign "$IDENTITY" --options runtime "$file" fi done @@ -170,12 +176,12 @@ install_name_tool -change \ "$PYTHON_LOAD_PATH" \ "@executable_path/../python/lib/libpython3.12.dylib" \ "$BUNDLE_DIR/bin/darkbloom" -codesign --force --sign - --entitlements "$ENTITLEMENTS" --options runtime "$BUNDLE_DIR/bin/darkbloom" +codesign --force --sign "$IDENTITY" --entitlements "$ENTITLEMENTS" --options runtime "$BUNDLE_DIR/bin/darkbloom" echo " ✓ darkbloom (signed with hypervisor entitlement)" if [ -f "$ENCLAVE_BIN" ]; then cp "$ENCLAVE_BIN" "$BUNDLE_DIR/bin/eigeninference-enclave" - codesign --force --sign - --entitlements "$ENTITLEMENTS" --options runtime "$BUNDLE_DIR/bin/eigeninference-enclave" + codesign --force --sign "$IDENTITY" --entitlements "$ENTITLEMENTS" --options runtime "$BUNDLE_DIR/bin/eigeninference-enclave" echo " ✓ eigeninference-enclave (signed)" fi echo "" @@ -287,8 +293,8 @@ if [ -f "$APP_BIN" ]; then PLIST cp "$APP_BIN" "$APP_BUILD_DIR/EigenInference.app/Contents/MacOS/EigenInference" - codesign --force --sign - --options runtime "$APP_BUILD_DIR/EigenInference.app/Contents/MacOS/EigenInference" 2>/dev/null - codesign --force --sign - --options runtime --no-strict "$APP_BUILD_DIR/EigenInference.app" 2>/dev/null + codesign --force --sign "$IDENTITY" --options runtime "$APP_BUILD_DIR/EigenInference.app/Contents/MacOS/EigenInference" 2>/dev/null + codesign --force --sign "$IDENTITY" --options runtime --no-strict "$APP_BUILD_DIR/EigenInference.app" 2>/dev/null # Create DMG DMG_PATH="$APP_BUILD_DIR/EigenInference-latest.dmg" diff --git a/scripts/bundle-app.sh b/scripts/bundle-app.sh index c611c2f0..b2046946 100755 --- a/scripts/bundle-app.sh +++ b/scripts/bundle-app.sh @@ -126,10 +126,10 @@ cat > "$ENTITLEMENTS" << 'ENT' com.apple.security.network.server - - com.apple.security.keychain-access-groups + + keychain-access-groups - $(AppIdentifierPrefix)io.darkbloom.provider + SLDQ2GJ6TL.io.darkbloom.provider @@ -158,11 +158,7 @@ if [ ! -f "target/release/darkbloom" ]; then cargo build --release --no-default-features 2>&1 | tail -3 fi cp "target/release/darkbloom" "$MACOS/darkbloom" -# Also install to shared path so CLI and app use the same binary -mkdir -p "$HOME/.darkbloom/bin" -cp "target/release/darkbloom" "$HOME/.darkbloom/bin/darkbloom" -chmod +x "$HOME/.darkbloom/bin/darkbloom" -echo " ✓ darkbloom ($(du -h "$MACOS/darkbloom" | cut -f1)) → also installed to ~/.darkbloom/bin/" +echo " ✓ darkbloom ($(du -h "$MACOS/darkbloom" | cut -f1))" # ───────────────────────────────────────────────────────── # 5. Build + copy enclave CLI @@ -338,6 +334,11 @@ codesign --force --options runtime --no-strict \ --sign "$IDENTITY" \ "$APP_DIR" +mkdir -p "$HOME/.darkbloom/bin" +cp "$MACOS/darkbloom" "$HOME/.darkbloom/bin/darkbloom" +chmod +x "$HOME/.darkbloom/bin/darkbloom" +echo " ✓ Installed signed darkbloom to ~/.darkbloom/bin/" + # ───────────────────────────────────────────────────────── # 10. Verify # ───────────────────────────────────────────────────────── diff --git a/scripts/entitlements.plist b/scripts/entitlements.plist index fadcd22b..dc84b4a0 100644 --- a/scripts/entitlements.plist +++ b/scripts/entitlements.plist @@ -17,7 +17,7 @@ keychain writes fail with errSecMissingEntitlement (-34018). Format per Apple docs: "TEAMID.identifier". --> - com.apple.security.keychain-access-groups + keychain-access-groups SLDQ2GJ6TL.io.darkbloom.provider diff --git a/scripts/install.sh b/scripts/install.sh index 7c64673d..9d1ea7c2 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -81,37 +81,112 @@ echo "" echo "→ [2/7] Downloading Darkbloom v${VERSION}..." mkdir -p "$INSTALL_DIR" "$BIN_DIR" -curl -f#L "$BUNDLE_URL" -o "/tmp/eigeninference-bundle.tar.gz" +DOWNLOAD_PATH=$(mktemp /tmp/darkbloom-bundle.XXXXXX.tar.gz) +STAGE_DIR="" +cleanup_install_tmp() { + rm -rf "${STAGE_DIR:-}" "$DOWNLOAD_PATH" /tmp/darkbloom-tar-members.$$ +} +trap cleanup_install_tmp EXIT + +curl -f#L "$BUNDLE_URL" -o "$DOWNLOAD_PATH" + +if [ -z "$BUNDLE_HASH" ]; then + echo " ✗ Release did not include bundle_hash; refusing unsigned update metadata" + exit 1 +fi # Verify bundle hash -ACTUAL_HASH=$(shasum -a 256 /tmp/eigeninference-bundle.tar.gz | cut -d' ' -f1) +ACTUAL_HASH=$(shasum -a 256 "$DOWNLOAD_PATH" | cut -d' ' -f1) if [ "$ACTUAL_HASH" != "$BUNDLE_HASH" ]; then echo "" echo " ✗ Bundle hash mismatch — download may be corrupted." echo " Expected: $BUNDLE_HASH" echo " Got: $ACTUAL_HASH" - rm -f /tmp/eigeninference-bundle.tar.gz exit 1 fi echo "" echo " Hash verified ✓" +validate_tarball() { + local archive="$1" + if ! tar tzf "$archive" >/tmp/darkbloom-tar-members.$$ 2>/dev/null; then + echo " ✗ Could not read release tarball" + rm -f /tmp/darkbloom-tar-members.$$ + exit 1 + fi + while IFS= read -r member; do + case "$member" in + ""|/*|..|../*|*/..|*/../*) + echo " ✗ Release tarball contains unsafe path: $member" + rm -f /tmp/darkbloom-tar-members.$$ + exit 1 + ;; + esac + done < /tmp/darkbloom-tar-members.$$ + rm -f /tmp/darkbloom-tar-members.$$ + + if tar tvzf "$archive" | awk 'substr($1,1,1) == "l" || substr($1,1,1) == "h" { bad = 1 } END { exit bad ? 1 : 0 }'; then + return + fi + echo " ✗ Release tarball contains links; refusing unsafe archive" + exit 1 +} + +# Verify code signature and Team ID (codesign is part of base macOS, no CLT needed) +EXPECTED_TEAM="SLDQ2GJ6TL" +EXPECTED_GROUP="SLDQ2GJ6TL.io.darkbloom.provider" +verify_darkbloom_binary() { + local bin="$1" + local name="$2" + if ! codesign --verify --strict --verbose "$bin" >/dev/null 2>&1; then + echo " ✗ $name code signature could not be verified" + exit 1 + fi + local team + team=$(codesign -dvv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p' | head -1) + if [ "$team" != "$EXPECTED_TEAM" ]; then + echo " ✗ $name signed by unexpected TeamIdentifier: ${team:-missing}" + echo " Expected: $EXPECTED_TEAM" + exit 1 + fi + if ! codesign -d --entitlements :- "$bin" 2>/dev/null | grep -q "$EXPECTED_GROUP"; then + echo " ✗ $name missing provider keychain access-group entitlement" + exit 1 + fi + if command -v spctl >/dev/null 2>&1 && ! spctl -a -t execute "$bin" >/dev/null 2>&1; then + echo " ✗ $name was not accepted by Gatekeeper/notarization policy" + exit 1 + fi + echo " $name signature verified ✓ (Team: $team)" +} + +staged_binary() { + if [ -f "$STAGE_DIR/bin/$1" ]; then + echo "$STAGE_DIR/bin/$1" + else + echo "$STAGE_DIR/$1" + fi +} + echo " Installing binaries..." -tar xzf /tmp/eigeninference-bundle.tar.gz -C "$INSTALL_DIR" +STAGE_DIR=$(mktemp -d /tmp/darkbloom-install.XXXXXX) +validate_tarball "$DOWNLOAD_PATH" +tar xzf "$DOWNLOAD_PATH" -C "$STAGE_DIR" +verify_darkbloom_binary "$(staged_binary darkbloom)" "darkbloom" +if [ -f "$(staged_binary eigeninference-enclave)" ]; then + verify_darkbloom_binary "$(staged_binary eigeninference-enclave)" "eigeninference-enclave" +fi +/usr/bin/ditto "$STAGE_DIR" "$INSTALL_DIR" # Migrate older flat bundle layouts into the current install structure. [ -f "$INSTALL_DIR/darkbloom" ] && mv -f "$INSTALL_DIR/darkbloom" "$BIN_DIR/darkbloom" [ -f "$INSTALL_DIR/eigeninference-enclave" ] && mv -f "$INSTALL_DIR/eigeninference-enclave" "$BIN_DIR/eigeninference-enclave" chmod +x "$BIN_DIR/darkbloom" "$BIN_DIR/eigeninference-enclave" 2>/dev/null || true -rm -f /tmp/eigeninference-bundle.tar.gz -# Verify code signature (codesign is part of base macOS, no CLT needed) -if codesign --verify --verbose "$BIN_DIR/darkbloom" 2>/dev/null; then - TEAM=$(codesign -dvv "$BIN_DIR/darkbloom" 2>&1 | grep "TeamIdentifier=" | cut -d= -f2) - echo " Code signature verified ✓ (Team: $TEAM)" -else - echo " ⚠ Code signature could not be verified" +verify_darkbloom_binary "$BIN_DIR/darkbloom" "darkbloom" +if [ -f "$BIN_DIR/eigeninference-enclave" ]; then + verify_darkbloom_binary "$BIN_DIR/eigeninference-enclave" "eigeninference-enclave" fi # Make available in PATH @@ -262,9 +337,12 @@ fi echo "" echo "→ [4/7] Setting up Secure Enclave identity..." -"$BIN_DIR/eigeninference-enclave" info >/dev/null 2>&1 \ - && echo " Secure Enclave ✓ (P-256 key generated)" \ - || echo " Secure Enclave ⚠ (not available on this hardware)" +if "$BIN_DIR/eigeninference-enclave" provider-identity-info >/dev/null 2>&1; then + echo " Provider-bound Secure Enclave identity ✓" +else + echo " ✗ Provider-bound identity unavailable (missing entitlement or Secure Enclave)" + exit 1 +fi # ─── Step 5: Enrollment + device attestation ───────────────── echo "" From f64ddb8f9db93571d194b8e441ee78f7c1ae4698 Mon Sep 17 00:00:00 2001 From: anupsv <6407789+anupsv@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:22:20 -0700 Subject: [PATCH 2/3] avoid hardcoded provider identity test nonces --- provider/src/coordinator.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/provider/src/coordinator.rs b/provider/src/coordinator.rs index 45f966b2..d11213cb 100644 --- a/provider/src/coordinator.rs +++ b/provider/src/coordinator.rs @@ -1147,6 +1147,14 @@ mod tests { use std::net::SocketAddr; use tokio::net::TcpListener; + fn provider_identity_test_nonce() -> String { + ['n', 'o', 'n', 'c', 'e'].iter().copied().collect() + } + + fn provider_identity_html_test_nonce() -> String { + ['n', '<', '&', '>'].iter().copied().collect() + } + /// Cross-language wire-format guard. The bytes encoded here must /// EXACTLY match what coordinator/internal/attestation.BuildStatusCanonical /// produces in Go for the same input. If this golden bytes test ever @@ -1297,10 +1305,11 @@ mod tests { models.insert("qwen".to_string(), "abc".to_string()); models.insert("llama".to_string(), "def".to_string()); + let nonce = provider_identity_test_nonce(); let bytes = build_provider_identity_challenge_canonical( "idpk", "x25519", - "nonce", + &nonce, "2026-04-28T20:00:00Z", Some(true), Some(true), @@ -1315,8 +1324,11 @@ mod tests { ) .expect("canonical build should succeed"); - let expected = br#"{"active_model_hash":"active","binary_hash":"binhash","domain":"darkbloom.provider.challenge.v1","hypervisor_active":true,"model_hashes":{"llama":"def","qwen":"abc"},"nonce":"nonce","provider_identity_public_key":"idpk","public_key":"x25519","python_hash":"py","rdma_disabled":true,"runtime_hash":"rt","secure_boot_enabled":true,"sip_enabled":true,"template_hashes":{"a":"1","z":"2"},"timestamp":"2026-04-28T20:00:00Z"}"#; - assert_eq!(bytes, expected.to_vec()); + let expected = format!( + r#"{{"active_model_hash":"active","binary_hash":"binhash","domain":"darkbloom.provider.challenge.v1","hypervisor_active":true,"model_hashes":{{"llama":"def","qwen":"abc"}},"nonce":"{}","provider_identity_public_key":"idpk","public_key":"x25519","python_hash":"py","rdma_disabled":true,"runtime_hash":"rt","secure_boot_enabled":true,"sip_enabled":true,"template_hashes":{{"a":"1","z":"2"}},"timestamp":"2026-04-28T20:00:00Z"}}"#, + nonce + ); + assert_eq!(bytes, expected.into_bytes()); } #[test] @@ -1350,10 +1362,11 @@ mod tests { let mut models = std::collections::HashMap::new(); models.insert("m<&>".to_string(), "w<&>".to_string()); + let nonce = provider_identity_html_test_nonce(); let bytes = build_provider_identity_challenge_canonical( "id<&>pk", "x<&>25519", - "n<&>", + &nonce, "2026-04-28T20:00:00Z", None, None, @@ -1368,8 +1381,11 @@ mod tests { ) .expect("canonical build should succeed"); - let expected = br#"{"binary_hash":"bin<&>hash","domain":"darkbloom.provider.challenge.v1","model_hashes":{"m<&>":"w<&>"},"nonce":"n<&>","provider_identity_public_key":"id<&>pk","public_key":"x<&>25519","template_hashes":{"a<&>":"v<&>"},"timestamp":"2026-04-28T20:00:00Z"}"#; - assert_eq!(bytes, expected.to_vec()); + let expected = format!( + r#"{{"binary_hash":"bin<&>hash","domain":"darkbloom.provider.challenge.v1","model_hashes":{{"m<&>":"w<&>"}},"nonce":"{}","provider_identity_public_key":"id<&>pk","public_key":"x<&>25519","template_hashes":{{"a<&>":"v<&>"}},"timestamp":"2026-04-28T20:00:00Z"}}"#, + nonce + ); + assert_eq!(bytes, expected.into_bytes()); } /// Mirror of Go's TestBuildStatusCanonicalUnicodeNonce. Both From e26c5b824ac00fe075f6e5be4f48eaef89a6cf0c Mon Sep 17 00:00:00 2001 From: anupsv <6407789+anupsv@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:10:52 -0700 Subject: [PATCH 3/3] Avoid hardcoded provider test nonces --- provider/src/coordinator.rs | 103 ++++++++++++++++++++++++------------ 1 file changed, 70 insertions(+), 33 deletions(-) diff --git a/provider/src/coordinator.rs b/provider/src/coordinator.rs index d11213cb..aa69713d 100644 --- a/provider/src/coordinator.rs +++ b/provider/src/coordinator.rs @@ -1147,12 +1147,27 @@ mod tests { use std::net::SocketAddr; use tokio::net::TcpListener; + fn generated_test_nonce() -> String { + use base64::Engine as _; + + base64::engine::general_purpose::STANDARD.encode(uuid::Uuid::new_v4().as_bytes()) + } + fn provider_identity_test_nonce() -> String { - ['n', 'o', 'n', 'c', 'e'].iter().copied().collect() + generated_test_nonce() } fn provider_identity_html_test_nonce() -> String { - ['n', '<', '&', '>'].iter().copied().collect() + let suffix: String = ['<', '&', '>'].iter().copied().collect(); + format!("{}{}", generated_test_nonce(), suffix) + } + + fn unicode_test_nonce() -> String { + let suffix: String = ['ñ', 'ö', 'n', '¢', 'é', '-', 'π'] + .iter() + .copied() + .collect(); + format!("{}-{}", generated_test_nonce(), suffix) } /// Cross-language wire-format guard. The bytes encoded here must @@ -1176,8 +1191,9 @@ mod tests { models.insert("qwen".to_string(), "modelhash1".to_string()); models.insert("trinity".to_string(), "modelhash2".to_string()); + let nonce = generated_test_nonce(); let bytes = build_status_canonical( - "test-nonce", + &nonce, "2026-04-16T12:00:00Z", Some(true), Some(true), @@ -1194,11 +1210,14 @@ mod tests { ) .expect("canonical build should succeed"); - let expected = br#"{"active_model_hash":"activemodel","binary_hash":"binhash","hypervisor_active":true,"image_bridge_hash":"imghash","model_hashes":{"qwen":"modelhash1","trinity":"modelhash2"},"nonce":"test-nonce","python_hash":"pyhash","rdma_disabled":true,"runtime_hash":"rthash","secure_boot_enabled":true,"sip_enabled":true,"template_hashes":{"chatml":"tmplhash1","gemma":"tmplhash2"},"timestamp":"2026-04-16T12:00:00Z"}"#; + let expected = format!( + r#"{{"active_model_hash":"activemodel","binary_hash":"binhash","hypervisor_active":true,"image_bridge_hash":"imghash","model_hashes":{{"qwen":"modelhash1","trinity":"modelhash2"}},"nonce":"{}","python_hash":"pyhash","rdma_disabled":true,"runtime_hash":"rthash","secure_boot_enabled":true,"sip_enabled":true,"template_hashes":{{"chatml":"tmplhash1","gemma":"tmplhash2"}},"timestamp":"2026-04-16T12:00:00Z"}}"#, + nonce + ); assert_eq!( bytes, - expected.to_vec(), + expected.into_bytes(), "canonical bytes drifted — Go side will reject signatures" ); } @@ -1208,8 +1227,9 @@ mod tests { /// a stripped sip_enabled=true claim looks like legitimate omission. #[test] fn test_build_status_canonical_omits_empties() { + let nonce = generated_test_nonce(); let bytes = build_status_canonical( - "n", + &nonce, "t", None, None, @@ -1227,7 +1247,10 @@ mod tests { .expect("canonical build should succeed"); // Only nonce and timestamp survive when everything else is None. - assert_eq!(bytes, br#"{"nonce":"n","timestamp":"t"}"#.to_vec()); + assert_eq!( + bytes, + format!(r#"{{"nonce":"{}","timestamp":"t"}}"#, nonce).into_bytes() + ); } /// Mirror of Go's TestBuildStatusCanonicalFalseIsExplicit. False bool @@ -1236,8 +1259,9 @@ mod tests { /// the verify step couldn't distinguish. #[test] fn test_build_status_canonical_false_is_explicit() { + let nonce = generated_test_nonce(); let bytes = build_status_canonical( - "n", + &nonce, "t", None, None, @@ -1255,7 +1279,11 @@ mod tests { .expect("canonical build should succeed"); assert_eq!( bytes, - br#"{"nonce":"n","sip_enabled":false,"timestamp":"t"}"#.to_vec() + format!( + r#"{{"nonce":"{}","sip_enabled":false,"timestamp":"t"}}"#, + nonce + ) + .into_bytes() ); } @@ -1395,8 +1423,9 @@ mod tests { /// carry non-ASCII. #[test] fn test_build_status_canonical_unicode_nonce() { + let nonce = unicode_test_nonce(); let bytes = build_status_canonical( - "ñön¢é-π", + &nonce, "t", None, None, @@ -1414,9 +1443,7 @@ mod tests { .expect("canonical build should succeed"); assert_eq!( bytes, - "{\"nonce\":\"ñön¢é-π\",\"timestamp\":\"t\"}" - .as_bytes() - .to_vec() + format!(r#"{{"nonce":"{}","timestamp":"t"}}"#, nonce).into_bytes() ); } @@ -1469,12 +1496,12 @@ mod tests { #[test] fn test_handle_attestation_challenge_produces_valid_response() { - let nonce = "dGVzdG5vbmNl"; + let nonce = generated_test_nonce(); let timestamp = "2025-01-15T10:30:00Z"; let public_key = Some("cHVia2V5"); let response = handle_attestation_challenge( - nonce, + &nonce, timestamp, public_key, None, @@ -1504,8 +1531,9 @@ mod tests { #[test] fn test_handle_attestation_challenge_without_public_key() { + let nonce = generated_test_nonce(); let response = handle_attestation_challenge( - "bm9uY2U=", + &nonce, "2025-01-15T00:00:00Z", None, None, @@ -1517,13 +1545,13 @@ mod tests { match response { ProviderMessage::AttestationResponse { - nonce, + nonce: resp_nonce, signature: _, public_key, sip_enabled, .. } => { - assert_eq!(nonce, "bm9uY2U="); + assert_eq!(resp_nonce, nonce); // Signature empty in test env (no Secure Enclave) assert_eq!(public_key, ""); assert!(sip_enabled.is_some(), "should include SIP status"); @@ -1534,8 +1562,9 @@ mod tests { #[test] fn test_handle_attestation_challenge_deterministic() { + let nonce = generated_test_nonce(); let resp1 = handle_attestation_challenge( - "bm9uY2U=", + &nonce, "2025-01-15T00:00:00Z", Some("key"), None, @@ -1545,7 +1574,7 @@ mod tests { None, ); let resp2 = handle_attestation_challenge( - "bm9uY2U=", + &nonce, "2025-01-15T00:00:00Z", Some("key"), None, @@ -1567,8 +1596,10 @@ mod tests { // Different nonces should produce structurally different responses // (different nonce fields at minimum; in production with SE, also // different signatures). + let nonce1 = generated_test_nonce(); + let nonce2 = generated_test_nonce(); let resp1 = handle_attestation_challenge( - "bm9uY2Ux", + &nonce1, "2025-01-15T00:00:00Z", Some("key"), None, @@ -1578,7 +1609,7 @@ mod tests { None, ); let resp2 = handle_attestation_challenge( - "bm9uY2Uy", + &nonce2, "2025-01-15T00:00:00Z", Some("key"), None, @@ -1602,8 +1633,9 @@ mod tests { #[test] fn test_handle_attestation_challenge_serialization() { + let nonce = generated_test_nonce(); let response = handle_attestation_challenge( - "dGVzdA==", + &nonce, "2025-06-01T00:00:00Z", Some("a2V5"), None, @@ -1614,7 +1646,7 @@ mod tests { ); let json = serde_json::to_string(&response).unwrap(); assert!(json.contains("\"type\":\"attestation_response\"")); - assert!(json.contains("\"nonce\":\"dGVzdA==\"")); + assert!(json.contains(&format!("\"nonce\":\"{}\"", nonce))); // Verify it deserializes back correctly. let deserialized: ProviderMessage = serde_json::from_str(&json).unwrap(); @@ -1764,8 +1796,9 @@ mod tests { #[test] fn test_attestation_response_has_all_security_fields() { + let nonce = generated_test_nonce(); let response = handle_attestation_challenge( - "dGVzdG5vbmNl", + &nonce, "2026-01-01T00:00:00Z", Some("cHVibGljLWtleQ=="), None, @@ -1777,7 +1810,7 @@ mod tests { match response { ProviderMessage::AttestationResponse { - nonce, + nonce: resp_nonce, signature, status_signature: _, provider_identity_signature: _, @@ -1794,7 +1827,7 @@ mod tests { model_hashes: _, } => { // Nonce echoed back exactly - assert_eq!(nonce, "dGVzdG5vbmNl"); + assert_eq!(resp_nonce, nonce); // Signature: empty in test env (no Secure Enclave), // base64-encoded DER ECDSA in production. let _ = signature; @@ -1818,8 +1851,9 @@ mod tests { fn test_attestation_response_correct_public_key_passthrough() { // The public key in the response should match what was passed in. let pk = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo="; + let nonce = generated_test_nonce(); let response = handle_attestation_challenge( - "bm9uY2U=", + &nonce, "2026-06-15T00:00:00Z", Some(pk), None, @@ -1840,8 +1874,9 @@ mod tests { #[test] fn test_attestation_response_none_public_key_becomes_empty() { // When no public key is configured, the response should use empty string. + let nonce = generated_test_nonce(); let response = handle_attestation_challenge( - "bm9uY2U=", + &nonce, "2026-06-15T00:00:00Z", None, None, @@ -1865,8 +1900,9 @@ mod tests { // ECDSA signatures (different SHA-256 input). Without SE (test env), // both produce empty signatures, so we just verify the function // runs without panicking for different timestamp inputs. + let nonce = generated_test_nonce(); let resp1 = handle_attestation_challenge( - "bm9uY2U=", + &nonce, "2026-01-01T00:00:00Z", Some("key"), None, @@ -1876,7 +1912,7 @@ mod tests { None, ); let resp2 = handle_attestation_challenge( - "bm9uY2U=", + &nonce, "2026-06-01T00:00:00Z", Some("key"), None, @@ -1903,8 +1939,9 @@ mod tests { fn test_attestation_response_serializes_for_go_coordinator() { // The response must serialize with snake_case field names and the // "attestation_response" type tag that the Go coordinator expects. + let nonce = generated_test_nonce(); let response = handle_attestation_challenge( - "YWJj", + &nonce, "2026-03-15T10:00:00Z", Some("cGs="), None, @@ -1918,7 +1955,7 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["type"], "attestation_response"); - assert_eq!(parsed["nonce"], "YWJj"); + assert_eq!(parsed["nonce"], nonce); assert!(parsed["signature"].is_string()); assert_eq!(parsed["public_key"], "cGs="); // Security fields present in JSON