From 3cc4127eceab12e36676e0e9a77c4ebe18387ba0 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 18 Jul 2026 22:12:28 +0700 Subject: [PATCH 1/9] feat: bind native entitlement to broker identity --- README.md | 7 + .../Models/NeonDiffDesktopModel.swift | 21 ++- .../DesktopActivationLicenseClient.swift | 8 + .../Services/ActivationLicenseClient.swift | 29 ++- .../Services/GitHubBrokerClient.swift | 15 +- .../DesktopActivationLicenseClientTests.swift | 37 ++++ .../ActivationLicenseClientTests.swift | 14 +- .../GitHubBrokerClientTests.swift | 4 +- docs/SETUP.md | 10 ++ docs/github-app-setup.md | 18 +- docs/license-boundary.md | 8 + .../license-api/src/github-broker/index.ts | 1 + .../src/github-broker/license-entitlement.ts | 60 +++++++ .../license-api/src/github-broker/service.ts | 38 +++- services/license-api/src/server.ts | 10 +- .../test/github-broker-entitlement.test.ts | 16 +- ...thub-broker-production-entitlement.test.ts | 167 ++++++++++++++++++ .../license-api/test/github-broker-support.ts | 4 +- src/cli.ts | 15 ++ src/license.ts | 7 +- tests/license.test.ts | 37 ++++ 21 files changed, 497 insertions(+), 29 deletions(-) create mode 100644 apps/neondiff-desktop/Tests/NeonDiffDesktopAppCoreTests/DesktopActivationLicenseClientTests.swift create mode 100644 services/license-api/src/github-broker/license-entitlement.ts create mode 100644 services/license-api/test/github-broker-production-entitlement.test.ts diff --git a/README.md b/README.md index 6000df4e..f7708561 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,13 @@ surface for CLI-first and non-Mac setups, not the product UI. It shows license status, GitHub App status, daemon status, and provider readiness, and its provider card includes a `Verify API Key` control that reports redacted pass/fail output. +The managed native path binds activation and private-repository token issuance +to the same Keychain-backed broker device identity and exact GitHub-selected +repository. The raw Activation Key remains Keychain-owned: it crosses bounded +stdin for activation and the fixed-origin broker HTTPS request body for private +token issuance only, and is never placed in argv, config, logs, or broker +storage. That production path remains behind its rollout kill switch until the +paid-beta canaries pass. The native Providers pane reads and edits the saved `providers` registry, not the legacy `desktop.openAICompatibleEndpoint` field. Load config, Preview, and Apply the exact selected provider before Verify is enabled; verification pins diff --git a/apps/neondiff-desktop/Sources/NeonDiffDesktopAppCore/Models/NeonDiffDesktopModel.swift b/apps/neondiff-desktop/Sources/NeonDiffDesktopAppCore/Models/NeonDiffDesktopModel.swift index 5fecf4e8..8ca7f037 100644 --- a/apps/neondiff-desktop/Sources/NeonDiffDesktopAppCore/Models/NeonDiffDesktopModel.swift +++ b/apps/neondiff-desktop/Sources/NeonDiffDesktopAppCore/Models/NeonDiffDesktopModel.swift @@ -1138,11 +1138,28 @@ package final class NeonDiffDesktopModel: ObservableObject { // and activation canaries pass. When enabled, it uses the CLI's explicit // no-local-state mode: the app-owned Keychain item remains the only raw // credential copy and the key crosses only over bounded stdin. - guard dependencies.preferences.bool(forKey: activationCliBackedEnabledKey) else { return nil } + guard dependencies.productionBoundary.nativeActivationBrokerVerified, + dependencies.preferences.bool(forKey: activationCliBackedEnabledKey) + else { + return nil + } + let enabledRepositories = repos + .filter(\.enabled) + .map(\.name) + .filter(isValidRepoName) + guard enabledRepositories.count == 1, + let identity = try? GitHubBrokerDeviceIdentityStore( + secretStore: dependencies.secretStore + ).loadOrCreate() + else { + return nil + } return DesktopActivationLicenseClient( cli: dependencies.cli, executablePath: cliPath, - configPath: configPath + configPath: configPath, + machineId: identity.deviceId, + repository: enabledRepositories[0] ) } diff --git a/apps/neondiff-desktop/Sources/NeonDiffDesktopAppCore/Services/DesktopActivationLicenseClient.swift b/apps/neondiff-desktop/Sources/NeonDiffDesktopAppCore/Services/DesktopActivationLicenseClient.swift index 11bc24ee..4f437fac 100644 --- a/apps/neondiff-desktop/Sources/NeonDiffDesktopAppCore/Services/DesktopActivationLicenseClient.swift +++ b/apps/neondiff-desktop/Sources/NeonDiffDesktopAppCore/Services/DesktopActivationLicenseClient.swift @@ -10,17 +10,23 @@ package struct DesktopActivationLicenseClient: ActivationLicenseClienting { private let cli: any DesktopCLIExecuting private let executablePath: String private let configPath: String + private let machineId: String + private let repository: String private let timeout: TimeInterval package init( cli: any DesktopCLIExecuting, executablePath: String, configPath: String, + machineId: String, + repository: String, timeout: TimeInterval = 20 ) { self.cli = cli self.executablePath = executablePath self.configPath = configPath + self.machineId = machineId + self.repository = repository self.timeout = timeout } @@ -41,6 +47,8 @@ package struct DesktopActivationLicenseClient: ActivationLicenseClienting { "--license-storage", "keychain", "--license-key-stdin", "true", "--persist-local-state", "false", + "--license-machine-id", machineId, + "--repo", repository, "--json" ] do { diff --git a/apps/neondiff-desktop/Sources/NeonDiffDesktopCore/Services/ActivationLicenseClient.swift b/apps/neondiff-desktop/Sources/NeonDiffDesktopCore/Services/ActivationLicenseClient.swift index 9e55876e..bb819486 100644 --- a/apps/neondiff-desktop/Sources/NeonDiffDesktopCore/Services/ActivationLicenseClient.swift +++ b/apps/neondiff-desktop/Sources/NeonDiffDesktopCore/Services/ActivationLicenseClient.swift @@ -7,8 +7,11 @@ import Foundation // logs, screenshots, accessibility, analytics, or crash evidence. The CLI is the // production-approved secure path — it rejects `--license-key`/`--license-key-env` // and reads exactly one bounded key from stdin (`--license-key-stdin true`). The -// client parses the CLI's redacted `LicenseStatusResult` JSON, the same wire the -// production license lifecycle (PR #574) emits, and maps it to activation states. +// same ephemeral material may cross the fixed-origin broker HTTPS request body +// for private token issuance; it is never placed in argv, logs, or persisted by +// the broker. The client parses the CLI's redacted `LicenseStatusResult` JSON, +// the same wire the production license lifecycle (PR #574) emits, and maps it to +// activation states. /// A NeonDiff Activation Key held for the lifetime of a single activation call. /// Not `Codable`; its `description` is redacted so it can never be logged or @@ -34,11 +37,17 @@ public struct ActivationKeyMaterial: Sendable, CustomStringConvertible, CustomDe return "\(prefix)••••" } - /// The only accessor for the raw bytes — for the bounded stdin pipe only. + /// Raw bytes for the bounded stdin pipe to the local CLI. public func standardInputData() -> Data { Data(raw.utf8) } + /// Scope raw material to one fixed-origin HTTPS request-body construction. + /// The type remains non-Codable and redacted outside this closure. + public func withRawValue(_ body: (String) throws -> T) rethrows -> T { + try body(raw) + } + public var description: String { "\(ActivationTerminology.activationKey) \(redactedPrefix)" } public var debugDescription: String { description } } @@ -125,11 +134,21 @@ public protocol ActivationLicenseClienting: Sendable { public final class CLIActivationLicenseClient: ActivationLicenseClienting, @unchecked Sendable { private let cli: any NeonDiffCLIClienting private let configPath: String + private let machineId: String + private let repository: String private let timeout: TimeInterval - public init(cli: any NeonDiffCLIClienting, configPath: String, timeout: TimeInterval = 20) { + public init( + cli: any NeonDiffCLIClienting, + configPath: String, + machineId: String, + repository: String, + timeout: TimeInterval = 20 + ) { self.cli = cli self.configPath = configPath + self.machineId = machineId + self.repository = repository self.timeout = timeout } @@ -152,6 +171,8 @@ public final class CLIActivationLicenseClient: ActivationLicenseClienting, @unch "--license-storage", "keychain", "--license-key-stdin", "true", "--persist-local-state", "false", + "--license-machine-id", machineId, + "--repo", repository, "--json" ] do { diff --git a/apps/neondiff-desktop/Sources/NeonDiffDesktopCore/Services/GitHubBrokerClient.swift b/apps/neondiff-desktop/Sources/NeonDiffDesktopCore/Services/GitHubBrokerClient.swift index ea133202..d0c73bf3 100644 --- a/apps/neondiff-desktop/Sources/NeonDiffDesktopCore/Services/GitHubBrokerClient.swift +++ b/apps/neondiff-desktop/Sources/NeonDiffDesktopCore/Services/GitHubBrokerClient.swift @@ -526,7 +526,8 @@ public struct GitHubBrokerClient: Sendable { public func issueToken( identity: GitHubBrokerDeviceIdentity, installationId: Int, - repositories: [String] + repositories: [String], + activationKey: ActivationKeyMaterial? = nil ) async throws -> GitHubInstallationAccessGrant { guard installationId > 0, repositories.isEmpty == false, @@ -536,12 +537,16 @@ public struct GitHubBrokerClient: Sendable { else { throw GitHubBrokerClientError.invalidRequest } + var body: [String: Any] = [ + "installationId": installationId, + "repositories": repositories + ] + if let activationKey { + activationKey.withRawValue { body["activationKey"] = $0 } + } let response: TokenResponse = try await post( path: "/github/token", - body: [ - "installationId": installationId, - "repositories": repositories - ], + body: body, credential: try identity.makeCredential(now: now()) ) guard response.status == "issued", diff --git a/apps/neondiff-desktop/Tests/NeonDiffDesktopAppCoreTests/DesktopActivationLicenseClientTests.swift b/apps/neondiff-desktop/Tests/NeonDiffDesktopAppCoreTests/DesktopActivationLicenseClientTests.swift new file mode 100644 index 00000000..cbbe306e --- /dev/null +++ b/apps/neondiff-desktop/Tests/NeonDiffDesktopAppCoreTests/DesktopActivationLicenseClientTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import NeonDiffDesktopAppCore +import NeonDiffDesktopCore + +@Suite struct DesktopActivationLicenseClientTests { + @Test func bindsActivationToBrokerDeviceAndRepositoryWithoutArgvSecret() async throws { + let rawKey = "nd_live_keychain-desktop-fixture" + let cli = RecordingCLIExecutor(result: CLIRunResult( + exitCode: 0, + stdout: """ + {"command":"license activate","ok":true,"status":"active","source":"api", + "checkedAt":"2026-07-18T00:00:00.000Z", + "entitlement":{"status":"active","repoVisibilityScope":"private", + "privateRepoAllowed":true,"updateEntitlement":true}} + """, + stderr: "" + )) + let client = DesktopActivationLicenseClient( + cli: cli, + executablePath: "/fixture/neondiff", + configPath: "/fixture/config.json", + machineId: "broker-device-binding-123", + repository: "octo/private" + ) + + _ = try await client.activate(key: ActivationKeyMaterial(rawKey)) + + let call = try #require(cli.calls.first) + #expect(call.standardInput == Data(rawKey.utf8)) + #expect(call.arguments.contains("--license-machine-id")) + #expect(call.arguments.contains("broker-device-binding-123")) + #expect(call.arguments.contains("--repo")) + #expect(call.arguments.contains("octo/private")) + #expect(call.arguments.allSatisfy { !$0.contains(rawKey) }) + } +} diff --git a/apps/neondiff-desktop/Tests/NeonDiffDesktopCoreTests/ActivationLicenseClientTests.swift b/apps/neondiff-desktop/Tests/NeonDiffDesktopCoreTests/ActivationLicenseClientTests.swift index 1a0f17bb..9a566deb 100644 --- a/apps/neondiff-desktop/Tests/NeonDiffDesktopCoreTests/ActivationLicenseClientTests.swift +++ b/apps/neondiff-desktop/Tests/NeonDiffDesktopCoreTests/ActivationLicenseClientTests.swift @@ -31,7 +31,15 @@ import Testing private func client(_ fixture: Result) -> (CLIActivationLicenseClient, StubCLI) { let stub = StubCLI(fixture) - return (CLIActivationLicenseClient(cli: stub, configPath: "config.local.json"), stub) + return ( + CLIActivationLicenseClient( + cli: stub, + configPath: "config.local.json", + machineId: "broker-device-binding-123", + repository: "octo/private" + ), + stub + ) } private func success(_ json: String) -> Result { @@ -77,6 +85,10 @@ import Testing #expect(stub.lastArguments.contains("false")) #expect(stub.lastArguments.contains("--license-storage")) #expect(stub.lastArguments.contains("keychain")) + #expect(stub.lastArguments.contains("--license-machine-id")) + #expect(stub.lastArguments.contains("broker-device-binding-123")) + #expect(stub.lastArguments.contains("--repo")) + #expect(stub.lastArguments.contains("octo/private")) #expect(stub.lastArguments.contains("--json")) #expect(!stub.lastArguments.contains("--license-key")) } diff --git a/apps/neondiff-desktop/Tests/NeonDiffDesktopCoreTests/GitHubBrokerClientTests.swift b/apps/neondiff-desktop/Tests/NeonDiffDesktopCoreTests/GitHubBrokerClientTests.swift index cd0bec0e..166f82be 100644 --- a/apps/neondiff-desktop/Tests/NeonDiffDesktopCoreTests/GitHubBrokerClientTests.swift +++ b/apps/neondiff-desktop/Tests/NeonDiffDesktopCoreTests/GitHubBrokerClientTests.swift @@ -195,7 +195,8 @@ private let brokerCredentialResponseField = ["to", "ken"].joined() let grant = try await client.issueToken( identity: identity, installationId: 4242, - repositories: ["octo/private"] + repositories: ["octo/private"], + activationKey: ActivationKeyMaterial("nd_live_keychain-broker-fixture") ) #expect(grant.repositories == ["octo/private"]) #expect(grant.permissions == GitHubBrokerPermissions.minimumReview) @@ -219,6 +220,7 @@ private let brokerCredentialResponseField = ["to", "ken"].joined() JSONSerialization.jsonObject(with: requests[5].body) as? [String: Any] ) #expect(tokenRequest["repositories"] as? [String] == ["octo/private"]) + #expect(tokenRequest["activationKey"] as? String == "nd_live_keychain-broker-fixture") #expect(tokenRequest["permissions"] == nil) let repositoryRequest = try #require( JSONSerialization.jsonObject(with: requests[4].body) as? [String: Any] diff --git a/docs/SETUP.md b/docs/SETUP.md index e13d556b..d05ac00f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -205,6 +205,16 @@ The local `machineId` sent to the license API is advisory beta metadata derived from host name and platform, not hardware attestation or a durable seat-binding primitive. +The managed native beta path replaces that advisory host hash with the +Keychain-backed GitHub broker device ID and binds activation to the exact +GitHub-selected `owner/repo`. The Activation Key stays Keychain-owned and +crosses only two bounded channels: stdin to `license activate`, then the +fixed-origin broker HTTPS request body when a private token is requested. The +broker uses it for an in-memory license lookup and never logs, reflects, or +persists it. Public-repository token requests omit the key and do not call the +license authority. This path is still rollout-disabled; these contracts do not +prove production enablement or customer readiness. + ## 4. Check Readiness Run the GitHub-only doctor first. It verifies App installation visibility and diff --git a/docs/github-app-setup.md b/docs/github-app-setup.md index f9757a42..2e1e0d2d 100644 --- a/docs/github-app-setup.md +++ b/docs/github-app-setup.md @@ -56,12 +56,24 @@ because milestone or planning days can create large issue bursts. > This page covers the shipped **local-worker direct install**, where the worker > holds the App private key itself and no OAuth-during-install step is needed. The -> separate **managed authorization broker** (owner-gated, not yet wired to the -> desktop — #612) instead requires the App to enable "Request user authorization -> (OAuth) during installation" and set the `/github/connect/callback` URL; that +> separate **managed authorization broker** (production App registered, rollout +> kill switch still off, and native integration in progress under #612/#613) +> instead requires the App to enable "Request user authorization (OAuth) during +> installation" and set the `/github/connect/callback` URL; that > registration is documented in `docs/security/github-app-staging-registration.md`. > Do not enable OAuth-during-install for the local direct-install path below. +The managed broker contract is intentionally narrower than the local-worker +path. A Keychain-backed P-256 device identity establishes the installation +binding. Native activation sends that non-secret device ID plus the exact +GitHub-selected repository to the license API. A later private +`/github/token` request carries the Keychain-owned Activation Key only in the +fixed-origin HTTPS body; the broker performs an in-memory lookup against the +same device/repository activation and never logs, reflects, or persists the raw +key. Public requests omit the key and never consult the license authority. +The production kill switch remains off until the paid-beta integration and +canary gates pass, so this source contract is not production-wiring proof. + 1. Open the NeonDiff GitHub App install URL. 2. Choose the user or organization that owns the repositories. 3. Select `Only select repositories`. diff --git a/docs/license-boundary.md b/docs/license-boundary.md index d3596446..4ca69abf 100644 --- a/docs/license-boundary.md +++ b/docs/license-boundary.md @@ -81,6 +81,14 @@ will allow public open-source review with no Activation Key once server-side visibility verification ships; that public-free path is not enforced by the current CLI. +For the managed native path, private entitlement is bound to the authenticated +broker device and exact GitHub-selected repository. The raw Activation Key is +Keychain-owned, crosses bounded stdin for activation and a fixed-origin HTTPS +request body for private token issuance, and is never persisted by the broker. +Public token issuance omits the key and does not query the license authority. +These source contracts remain rollout-disabled until production integration and +customer canaries pass. + Evidence should name the command, repo visibility source, license gate result, pre-checkout gate result, and redacted evidence path. It must not include raw private diffs, provider keys, GitHub App private keys, license keys, or customer diff --git a/services/license-api/src/github-broker/index.ts b/services/license-api/src/github-broker/index.ts index bae13e3a..12c76c91 100644 --- a/services/license-api/src/github-broker/index.ts +++ b/services/license-api/src/github-broker/index.ts @@ -25,6 +25,7 @@ export { type EntitlementResolutionContext } from "./service.js"; export { GitHubBrokerStore } from "./store.js"; +export { createLicenseStoreEntitlementResolver } from "./license-entitlement.js"; export { createGitHubBrokerService, handleGitHubBrokerRequest, diff --git a/services/license-api/src/github-broker/license-entitlement.ts b/services/license-api/src/github-broker/license-entitlement.ts new file mode 100644 index 00000000..1ce8da9a --- /dev/null +++ b/services/license-api/src/github-broker/license-entitlement.ts @@ -0,0 +1,60 @@ +import type { EntitlementResolver } from "./service.js"; +import type { LicenseStore } from "../store.js"; + +const MAX_ACTIVATION_KEY_LENGTH = 512; + +/** + * Resolve a private-repository broker request against the production license + * store. The raw Activation Key is used only for this in-memory lookup and is + * never returned, logged, or persisted by the broker. + * + * Private coverage is deliberately narrower than license validity: the license + * must be active and unexpired, and its activation must already bind this exact + * authenticated broker device to the exact requested repository. + */ +export function createLicenseStoreEntitlementResolver( + store: LicenseStore, + now: () => Date = () => new Date() +): EntitlementResolver { + return (context) => { + const activationKey = normalizeActivationKey(context.activationKey); + if (!activationKey) return { status: "none" }; + + const license = store.getLicenseByKey(activationKey); + if (!license) return { status: "invalid" }; + if (license.status === "revoked") return { status: "revoked" }; + if ( + license.status === "expired" + || (license.expiresAt !== undefined + && Number.isFinite(Date.parse(license.expiresAt)) + && Date.parse(license.expiresAt) <= now().getTime()) + ) { + return { status: "expired" }; + } + + const activation = store.getActivation(license.licenseKeyHash, context.deviceId); + if (!activation) return { status: "replay_conflict" }; + + if ( + !license.privateRepoAllowed + || license.repoVisibilityScope === "public" + || !activation.repo + ) { + return { status: "active", coveredPrivateRepositories: [] }; + } + + return { + status: "active", + coveredPrivateRepositories: context.privateRepositories.includes(activation.repo) + ? [activation.repo] + : [] + }; + }; +} + +function normalizeActivationKey(value: string | undefined): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_ACTIVATION_KEY_LENGTH) return undefined; + return trimmed; +} diff --git a/services/license-api/src/github-broker/service.ts b/services/license-api/src/github-broker/service.ts index eb98a0f7..31bff4e2 100644 --- a/services/license-api/src/github-broker/service.ts +++ b/services/license-api/src/github-broker/service.ts @@ -40,8 +40,8 @@ const PERMISSION_SCOPE_RANK: Record = { read: 1, write: 2, admin /** * Context handed to the entitlement resolver for a private/internal token - * request. Only the ids and the private repository names are exposed — never a - * license key or provider secret — so a resolver stays public-safe. + * request. The raw Activation Key is optional and may cross only this in-memory + * seam; production resolvers must never log, persist, or reflect it. */ export interface EntitlementResolutionContext { deviceId: string; @@ -49,6 +49,8 @@ export interface EntitlementResolutionContext { accountLogin?: string; /** The requested repositories whose visibility is private or internal. */ privateRepositories: string[]; + /** Keychain-owned client credential, accepted only over the authenticated HTTPS route. */ + activationKey?: string; } /** @@ -355,7 +357,7 @@ export class GitHubBrokerService { ): Promise> { const at = this.now(); const deviceId = await authenticateDevice(this.store, authorization, at); - const { installationId, repositories, permissions } = parseTokenRequest(body); + const { installationId, repositories, permissions, activationKey } = parseTokenRequest(body); if (!this.tokenRateLimiter.allow(hashKey(`token:${deviceId}`), at.getTime())) { throw new BrokerError("rate_limited", "too many token requests for this device"); @@ -397,7 +399,8 @@ export class GitHubBrokerService { deviceId, installationId, installation.account_login, - requested + requested, + activationKey ); // The issuance seam: the ONLY path to minting. @@ -481,7 +484,8 @@ export class GitHubBrokerService { deviceId: string, installationId: number, accountLogin: string | undefined, - requested: RequestedRepository[] + requested: RequestedRepository[], + activationKey: string | undefined ): Promise { if (requested.some((repository) => repository.visibility === "unknown")) { return { status: "none" }; @@ -495,7 +499,8 @@ export class GitHubBrokerService { deviceId, installationId, ...(accountLogin ? { accountLogin } : {}), - privateRepositories + privateRepositories, + ...(activationKey ? { activationKey } : {}) }); } catch { return { status: "service_unavailable" }; @@ -541,6 +546,7 @@ function parseTokenRequest(body: unknown): { installationId: number; repositories: string[]; permissions?: Record; + activationKey?: string; } { const record = asObject(body); const installationId = parseInstallationId(record.installationId); @@ -558,7 +564,25 @@ function parseTokenRequest(body: unknown): { return value; }); const permissions = parsePermissions(record.permissions); - return { installationId, repositories, ...(permissions ? { permissions } : {}) }; + const activationKey = parseActivationKey(record.activationKey); + return { + installationId, + repositories, + ...(permissions ? { permissions } : {}), + ...(activationKey ? { activationKey } : {}) + }; +} + +function parseActivationKey(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") { + throw new BrokerError("invalid_request", "activationKey must be a string"); + } + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 512) { + throw new BrokerError("invalid_request", "activationKey is invalid"); + } + return trimmed; } function parseRepositoryListRequest(body: unknown): { diff --git a/services/license-api/src/server.ts b/services/license-api/src/server.ts index 47235a7d..00c05305 100644 --- a/services/license-api/src/server.ts +++ b/services/license-api/src/server.ts @@ -3,6 +3,7 @@ import { startLicenseServer } from "./http.js"; import { createGitHubActionsOidcVerifier } from "./oidc-lifecycle.js"; import { RateLimiter } from "./service.js"; import { loadGitHubBrokerRuntimeConfig } from "./github-broker/runtime-config.js"; +import { createLicenseStoreEntitlementResolver } from "./github-broker/license-entitlement.js"; /** * Production entrypoint. SQLite lives on a mounted volume in deploy @@ -16,6 +17,7 @@ async function main(): Promise { // Fly injects FLY_APP_NAME into Machines. Outside that operator-controlled // runtime, request-supplied Fly headers are untrusted and ignored. const trustFlyProxyHeaders = Boolean(process.env.FLY_APP_NAME?.trim()); + const store = new LicenseStore(dbPath); const githubBrokerRuntime = loadGitHubBrokerRuntimeConfig(process.env, dbPath); if (githubBrokerRuntime.status === "invalid") { // Setting name + fixed reason are public-safe. Never log the submitted value. @@ -26,7 +28,6 @@ async function main(): Promise { `github broker unavailable: ${githubBrokerRuntime.setting} ${githubBrokerRuntime.reason}` ); } - const store = new LicenseStore(dbPath); const { url } = await startLicenseServer({ store, port, @@ -39,7 +40,12 @@ async function main(): Promise { }), lifecycleOidcVerifier: createGitHubActionsOidcVerifier(), ...(githubBrokerRuntime.status === "ready" - ? { githubBroker: githubBrokerRuntime.deps } + ? { + githubBroker: { + ...githubBrokerRuntime.deps, + resolveEntitlement: createLicenseStoreEntitlementResolver(store) + } + } : {}) }); // eslint-disable-next-line no-console diff --git a/services/license-api/test/github-broker-entitlement.test.ts b/services/license-api/test/github-broker-entitlement.test.ts index 331d3478..1b16a338 100644 --- a/services/license-api/test/github-broker-entitlement.test.ts +++ b/services/license-api/test/github-broker-entitlement.test.ts @@ -85,7 +85,11 @@ describe("github broker entitlement binding at the mint path (#614)", () => { const response = await post( harness.url, "/github/token", - { installationId: INSTALL.id, repositories: ["octo/private"] }, + { + installationId: INSTALL.id, + repositories: ["octo/private"], + activationKey: "nd_live_keychain-owned-test-material" + }, bearer(await device.sign()) ); assert.equal(response.status, 200, response.text); @@ -95,6 +99,10 @@ describe("github broker entitlement binding at the mint path (#614)", () => { assert.equal(resolver.contexts.length, 1); assert.deepEqual(resolver.contexts[0].privateRepositories, ["octo/private"]); assert.equal(resolver.contexts[0].accountLogin, "octo"); + assert.equal( + resolver.contexts[0].activationKey, + "nd_live_keychain-owned-test-material" + ); assert.deepEqual(resolver.mintCountsAtResolve, [0], "no token minted before entitlement decided"); const listIndex = fake.calls.findIndex((call) => call.op === "listInstallationRepositories"); @@ -116,7 +124,11 @@ describe("github broker entitlement binding at the mint path (#614)", () => { const response = await post( harness.url, "/github/token", - { installationId: INSTALL.id, repositories: ["octo/site"] }, + { + installationId: INSTALL.id, + repositories: ["octo/site"], + activationKey: "nd_live_public-path-must-ignore-this" + }, bearer(await device.sign()) ); assert.equal(response.status, 200, response.text); diff --git a/services/license-api/test/github-broker-production-entitlement.test.ts b/services/license-api/test/github-broker-production-entitlement.test.ts new file mode 100644 index 00000000..a2bce1ba --- /dev/null +++ b/services/license-api/test/github-broker-production-entitlement.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + createLicenseStoreEntitlementResolver +} from "../src/github-broker/license-entitlement.js"; +import { activate } from "../src/service.js"; +import { LicenseStore } from "../src/store.js"; +import { + bearer, + connectInstallation, + fakeGitHubClient, + FIXED_NOW, + makeDevice, + post, + registerDevice, + startBroker, + type FakeInstallation +} from "./github-broker-support.ts"; + +const NOW = new Date("2026-07-18T15:00:00.000Z"); +const DEVICE_ID = "broker-device-1"; +const PRIVATE_REPO = "octo/private"; +const INSTALL: FakeInstallation = { + id: 7001, + account_login: "octo", + repositories: [ + { id: 82, full_name: PRIVATE_REPO, visibility: "private" } + ] +}; + +function issuePrivate(store: LicenseStore, expiresAt?: string) { + return store.issueLicense({ + plan: "yearly", + repoVisibilityScope: "private", + privateRepoAllowed: true, + ...(expiresAt ? { expiresAt } : {}) + }); +} + +function context(activationKey?: string, overrides: Partial<{ + deviceId: string; + privateRepositories: string[]; +}> = {}) { + return { + deviceId: overrides.deviceId ?? DEVICE_ID, + installationId: 7001, + accountLogin: "octo", + privateRepositories: overrides.privateRepositories ?? [PRIVATE_REPO], + ...(activationKey ? { activationKey } : {}) + }; +} + +describe("production GitHub broker entitlement resolver (#612/#614)", () => { + it("covers only the exact private repository activated for the authenticated broker device", async () => { + const store = new LicenseStore(":memory:"); + const issued = issuePrivate(store); + assert.equal( + activate( + store, + { licenseKey: issued.rawKey, machineId: DEVICE_ID, repo: PRIVATE_REPO }, + NOW + ).httpStatus, + 200 + ); + + const resolve = createLicenseStoreEntitlementResolver(store, () => NOW); + assert.deepEqual( + await resolve(context(issued.rawKey)), + { status: "active", coveredPrivateRepositories: [PRIVATE_REPO] } + ); + assert.deepEqual( + await resolve(context(issued.rawKey, { privateRepositories: ["octo/other"] })), + { status: "active", coveredPrivateRepositories: [] } + ); + }); + + it("fails closed for missing, invalid, wrong-device, revoked, and expired credentials", async () => { + const store = new LicenseStore(":memory:"); + const active = issuePrivate(store); + const expired = issuePrivate(store, "2026-07-18T14:59:59.000Z"); + assert.equal( + activate( + store, + { licenseKey: active.rawKey, machineId: DEVICE_ID, repo: PRIVATE_REPO }, + NOW + ).httpStatus, + 200 + ); + + const resolve = createLicenseStoreEntitlementResolver(store, () => NOW); + assert.deepEqual(await resolve(context()), { status: "none" }); + assert.deepEqual(await resolve(context("NDL-NOT-A-REAL-LICENSE")), { status: "invalid" }); + assert.deepEqual( + await resolve(context(active.rawKey, { deviceId: "different-device" })), + { status: "replay_conflict" } + ); + + store.revokeLicense(active.rawKey, "cancelled"); + assert.deepEqual(await resolve(context(active.rawKey)), { status: "revoked" }); + assert.deepEqual(await resolve(context(expired.rawKey)), { status: "expired" }); + }); + + it("never treats a public-only or private-disabled license as private coverage", async () => { + const store = new LicenseStore(":memory:"); + const issued = store.issueLicense({ + plan: "yearly", + repoVisibilityScope: "public", + privateRepoAllowed: false + }); + assert.equal( + activate( + store, + { licenseKey: issued.rawKey, machineId: DEVICE_ID, repo: PRIVATE_REPO }, + NOW + ).httpStatus, + 200 + ); + + const resolve = createLicenseStoreEntitlementResolver(store, () => NOW); + assert.deepEqual( + await resolve(context(issued.rawKey)), + { status: "active", coveredPrivateRepositories: [] } + ); + }); + + it("binds the authenticated HTTPS token request to the same license store activation", async () => { + const store = new LicenseStore(":memory:"); + const fake = fakeGitHubClient([INSTALL]); + const resolveEntitlement = createLicenseStoreEntitlementResolver(store, () => NOW); + const harness = await startBroker({ + fake, + licenseStore: store, + resolveEntitlement, + clock: () => FIXED_NOW + }); + try { + const device = await makeDevice(); + await registerDevice(harness.url, device); + await connectInstallation(harness.url, device, INSTALL.id); + const issued = issuePrivate(store); + assert.equal( + activate( + store, + { licenseKey: issued.rawKey, machineId: device.deviceId, repo: PRIVATE_REPO }, + NOW + ).httpStatus, + 200 + ); + + const response = await post( + harness.url, + "/github/token", + { + installationId: INSTALL.id, + repositories: [PRIVATE_REPO], + activationKey: issued.rawKey + }, + bearer(await device.sign()) + ); + assert.equal(response.status, 200, response.text); + assert.equal(response.json.token, harness.mintedToken); + assert.equal(response.text.includes(issued.rawKey), false); + } finally { + harness.close(); + } + }); +}); diff --git a/services/license-api/test/github-broker-support.ts b/services/license-api/test/github-broker-support.ts index 5afc7cc6..8011cb61 100644 --- a/services/license-api/test/github-broker-support.ts +++ b/services/license-api/test/github-broker-support.ts @@ -192,12 +192,14 @@ export async function startBroker( store?: unknown; /** Entitlement authority for private/internal issuance (#614 fixtures). */ resolveEntitlement?: unknown; + /** Optional production license store for end-to-end entitlement wiring tests. */ + licenseStore?: LicenseStore; deviceRegisterRateLimiter?: RateLimiter; tokenRateLimiter?: RateLimiter; connectRateLimiter?: RateLimiter; } = {} ): Promise { - const licenseStore = new LicenseStore(":memory:"); + const licenseStore = options.licenseStore ?? new LicenseStore(":memory:"); const fake = options.fake ?? fakeGitHubClient(options.installations ?? []); const nowFn = options.clock ?? (() => options.now ?? FIXED_NOW); const started = await startLicenseServer({ diff --git a/src/cli.ts b/src/cli.ts index de208144..edae9b16 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -364,6 +364,9 @@ async function main(): Promise { persistLocalState: args["persist-local-state"] === undefined ? true : parseBooleanArg(args["persist-local-state"], "--persist-local-state"), + ...(args["license-machine-id"] + ? { machineId: parseLicenseMachineIdArg(args["license-machine-id"]) } + : {}), ...(args.repo ? { repo: parseSingleArg(args.repo, "--repo") } : {}) }); console.log(stringifyRedactedJson({ command: "license activate", ...result })); @@ -3309,6 +3312,7 @@ const COMMAND_USAGE: Record = { { name: "--license-key-stdin", description: "true to read one bounded license key from stdin (activate only)." }, { name: "--license-storage", description: "keychain for the native Keychain-owned activation path; file otherwise." }, { name: "--persist-local-state", description: "false only when the submitted key matches the canonical native Keychain item; defaults true." }, + { name: "--license-machine-id", description: "Native-only non-secret broker device binding; never an Activation Key." }, { name: "--repo", description: "Repo to scope activation/status to, owner/name." }, { name: "--refresh", description: "true to force a fresh status check instead of cached." } ] @@ -3683,6 +3687,17 @@ function parseLicenseStorageBackend(value: string): "keychain" | "file" { throw new Error("--license-storage must be keychain or file"); } +function parseLicenseMachineIdArg(value: ParsedArgs[string]): string { + if (value === undefined) { + throw new Error("--license-machine-id requires a value"); + } + const machineId = parseSingleArg(value, "--license-machine-id"); + if (!/^[A-Za-z0-9_-]{8,128}$/.test(machineId)) { + throw new Error("--license-machine-id must be one 8-128 character non-secret broker device id"); + } + return machineId; +} + function setParsedArg(parsed: ParsedArgs, key: string, value: string, repeatableArgs: Set): void { const existing = parsed[key]; if (existing !== undefined) { diff --git a/src/license.ts b/src/license.ts index ca7b249f..4a02880f 100644 --- a/src/license.ts +++ b/src/license.ts @@ -121,6 +121,8 @@ export async function activateLicense(input: { config: LicenseConfig; licenseKey: string; repo?: string; + /** Native-only non-secret binding. Defaults to the legacy local host hash. */ + machineId?: string; /** * Persist the raw key plus redacted entitlement cache for the headless CLI. * Native callers keep the only raw copy in Keychain and set this false. @@ -185,6 +187,7 @@ export async function activateLicense(input: { path: "/v1/license/activate", licenseKey, repo: input.repo, + machineId: input.machineId, now: input.now, fetchImpl: input.fetchImpl }); @@ -210,6 +213,7 @@ export async function activateLicense(input: { path: "/v1/license/deactivate", licenseKey, repo: input.repo, + machineId: input.machineId, now, fetchImpl: input.fetchImpl }); @@ -497,6 +501,7 @@ async function callLicenseApi(input: { path: string; licenseKey: string; repo?: string; + machineId?: string; now?: Date; fetchImpl?: typeof fetch; }): Promise { @@ -513,7 +518,7 @@ async function callLicenseApi(input: { body: JSON.stringify({ licenseKey: input.licenseKey, repo: input.repo, - machineId: localMachineId() + machineId: input.machineId ?? localMachineId() }) }); const text = await readBoundedResponseText(response); diff --git a/tests/license.test.ts b/tests/license.test.ts index 15fb9fda..a4f5e72c 100644 --- a/tests/license.test.ts +++ b/tests/license.test.ts @@ -448,6 +448,43 @@ describe("license activation and entitlement cache", () => { expect(existsSync(join(root, "entitlement.json"))).toBe(false); }); + it("binds native activation to the explicit broker device and canonical repository", async () => { + const root = mkRoot(roots); + const requestBodies: unknown[] = []; + const config = licenseConfig(root, "https://license.example.invalid"); + config.storageBackend = "keychain"; + config.keyPath = undefined; + + const result = await activateLicense({ + config, + licenseKey: "LIC-keychain-binding-test-123456", + repo: "octo/private", + machineId: "broker-device-binding-123", + persistLocalState: false, + keychainCredentialVerifier: () => true, + fetchImpl: (async (_url, init) => { + requestBodies.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ + status: "active", + repoVisibilityScope: "private", + privateRepoAllowed: true, + updateEntitlement: true + }), { + status: 200, + headers: { "content-type": "application/json" } + }); + }) as typeof fetch + }); + + expect(result.ok).toBe(true); + expect(requestBodies).toEqual([{ + licenseKey: "LIC-keychain-binding-test-123456", + repo: "octo/private", + machineId: "broker-device-binding-123" + }]); + expect(JSON.stringify(result)).not.toContain("LIC-keychain-binding-test-123456"); + }); + it("refuses no-local-state activation unless Keychain owns the recoverable credential", async () => { const root = mkRoot(roots); let requests = 0; From 6ca5c8056f200c182550846fee315a5b26ed3d23 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 18 Jul 2026 22:29:20 +0700 Subject: [PATCH 2/9] fix: fail closed on malformed entitlement expiry --- .../src/github-broker/license-entitlement.ts | 12 ++++++------ .../github-broker-production-entitlement.test.ts | 12 +++++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/services/license-api/src/github-broker/license-entitlement.ts b/services/license-api/src/github-broker/license-entitlement.ts index 1ce8da9a..b4ad5808 100644 --- a/services/license-api/src/github-broker/license-entitlement.ts +++ b/services/license-api/src/github-broker/license-entitlement.ts @@ -23,12 +23,12 @@ export function createLicenseStoreEntitlementResolver( const license = store.getLicenseByKey(activationKey); if (!license) return { status: "invalid" }; if (license.status === "revoked") return { status: "revoked" }; - if ( - license.status === "expired" - || (license.expiresAt !== undefined - && Number.isFinite(Date.parse(license.expiresAt)) - && Date.parse(license.expiresAt) <= now().getTime()) - ) { + const expiresAt = license.expiresAt === undefined + ? undefined + : Date.parse(license.expiresAt); + if (license.status === "expired" + || (expiresAt !== undefined + && (!Number.isFinite(expiresAt) || expiresAt <= now().getTime()))) { return { status: "expired" }; } diff --git a/services/license-api/test/github-broker-production-entitlement.test.ts b/services/license-api/test/github-broker-production-entitlement.test.ts index a2bce1ba..009a8baa 100644 --- a/services/license-api/test/github-broker-production-entitlement.test.ts +++ b/services/license-api/test/github-broker-production-entitlement.test.ts @@ -74,10 +74,11 @@ describe("production GitHub broker entitlement resolver (#612/#614)", () => { ); }); - it("fails closed for missing, invalid, wrong-device, revoked, and expired credentials", async () => { + it("fails closed for missing, invalid, wrong-device, revoked, expired, and malformed-expiry credentials", async () => { const store = new LicenseStore(":memory:"); const active = issuePrivate(store); const expired = issuePrivate(store, "2026-07-18T14:59:59.000Z"); + const malformedExpiry = issuePrivate(store, "not-a-timestamp"); assert.equal( activate( store, @@ -86,6 +87,14 @@ describe("production GitHub broker entitlement resolver (#612/#614)", () => { ).httpStatus, 200 ); + assert.equal( + activate( + store, + { licenseKey: malformedExpiry.rawKey, machineId: DEVICE_ID, repo: PRIVATE_REPO }, + NOW + ).httpStatus, + 200 + ); const resolve = createLicenseStoreEntitlementResolver(store, () => NOW); assert.deepEqual(await resolve(context()), { status: "none" }); @@ -98,6 +107,7 @@ describe("production GitHub broker entitlement resolver (#612/#614)", () => { store.revokeLicense(active.rawKey, "cancelled"); assert.deepEqual(await resolve(context(active.rawKey)), { status: "revoked" }); assert.deepEqual(await resolve(context(expired.rawKey)), { status: "expired" }); + assert.deepEqual(await resolve(context(malformedExpiry.rawKey)), { status: "expired" }); }); it("never treats a public-only or private-disabled license as private coverage", async () => { From b5ff29ca71f76c17468dd133d0b186b07052cafa Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 18 Jul 2026 22:40:45 +0700 Subject: [PATCH 3/9] fix: require broker identity for native activation --- README.md | 4 +++- docs/SETUP.md | 4 +++- docs/github-app-setup.md | 2 ++ docs/license-boundary.md | 4 +++- src/license.ts | 10 ++++++++++ tests/license.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 60 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f7708561..e2d2258c 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,9 @@ repository. The raw Activation Key remains Keychain-owned: it crosses bounded stdin for activation and the fixed-origin broker HTTPS request body for private token issuance only, and is never placed in argv, config, logs, or broker storage. That production path remains behind its rollout kill switch until the -paid-beta canaries pass. +paid-beta canaries pass. This slice does not migrate generic CLI +status/deactivate or daemon-admission validation from the legacy local identity; +#630 must wire those runtime callers before the managed path can be enabled. The native Providers pane reads and edits the saved `providers` registry, not the legacy `desktop.openAICompatibleEndpoint` field. Load config, Preview, and Apply the exact selected provider before Verify is enabled; verification pins diff --git a/docs/SETUP.md b/docs/SETUP.md index d05ac00f..ef1d4846 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -213,7 +213,9 @@ fixed-origin broker HTTPS request body when a private token is requested. The broker uses it for an in-memory license lookup and never logs, reflects, or persists it. Public-repository token requests omit the key and do not call the license authority. This path is still rollout-disabled; these contracts do not -prove production enablement or customer readiness. +prove production enablement or customer readiness. Generic CLI +status/deactivate and daemon-admission validation still use the legacy local +identity; #630 must migrate those runtime callers before rollout. ## 4. Check Readiness diff --git a/docs/github-app-setup.md b/docs/github-app-setup.md index 2e1e0d2d..7fef707a 100644 --- a/docs/github-app-setup.md +++ b/docs/github-app-setup.md @@ -73,6 +73,8 @@ same device/repository activation and never logs, reflects, or persists the raw key. Public requests omit the key and never consult the license authority. The production kill switch remains off until the paid-beta integration and canary gates pass, so this source contract is not production-wiring proof. +Generic CLI status/deactivate and daemon-admission validation still use the +legacy local identity; #630 owns that runtime migration before enablement. 1. Open the NeonDiff GitHub App install URL. 2. Choose the user or organization that owns the repositories. diff --git a/docs/license-boundary.md b/docs/license-boundary.md index 4ca69abf..f445c904 100644 --- a/docs/license-boundary.md +++ b/docs/license-boundary.md @@ -87,7 +87,9 @@ Keychain-owned, crosses bounded stdin for activation and a fixed-origin HTTPS request body for private token issuance, and is never persisted by the broker. Public token issuance omits the key and does not query the license authority. These source contracts remain rollout-disabled until production integration and -customer canaries pass. +customer canaries pass. Generic CLI status/deactivate and daemon-admission +validation still use the legacy local identity; #630 must migrate those runtime +callers before the managed path can be enabled. Evidence should name the command, repo visibility source, license gate result, pre-checkout gate result, and redacted evidence path. It must not include raw diff --git a/src/license.ts b/src/license.ts index 4a02880f..d0789817 100644 --- a/src/license.ts +++ b/src/license.ts @@ -155,6 +155,16 @@ export async function activateLicense(input: { detail: "Keychain license activation is disabled in headless CLI until native no-argv secret storage is available; use storageBackend=file" }; } + if (!persistLocalState && !input.machineId?.trim()) { + return { + ok: false, + status: "invalid", + source: "none", + checkedAt: now.toISOString(), + classification: "invalid", + detail: "no-local-state activation requires a broker device identity" + }; + } const licenseKey = input.licenseKey.trim(); if (!licenseKey) return missingResult(now, "license key is empty"); if (!input.config.apiBaseUrl) return serverResult(now, "license API base URL is not configured"); diff --git a/tests/license.test.ts b/tests/license.test.ts index a4f5e72c..b4a649b9 100644 --- a/tests/license.test.ts +++ b/tests/license.test.ts @@ -423,6 +423,8 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: key, + machineId: "broker-device-native-123", + repo: "octo/private", persistLocalState: false, keychainCredentialVerifier: (credential) => { verifiedKeychainCredentials.push(credential); @@ -448,6 +450,42 @@ describe("license activation and entitlement cache", () => { expect(existsSync(join(root, "entitlement.json"))).toBe(false); }); + it("fails closed when native no-local-state activation omits the broker device identity", async () => { + const root = mkRoot(roots); + const config = licenseConfig(root, "https://license.example.invalid"); + config.storageBackend = "keychain"; + config.keyPath = undefined; + let requests = 0; + + const result = await activateLicense({ + config, + licenseKey: "LIC-keychain-missing-binding-test-123456", + repo: "octo/private", + persistLocalState: false, + keychainCredentialVerifier: () => true, + fetchImpl: (async () => { + requests += 1; + return new Response(JSON.stringify({ + status: "active", + repoVisibilityScope: "private", + privateRepoAllowed: true, + updateEntitlement: true + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch + }); + + expect(result).toMatchObject({ + ok: false, + status: "invalid", + source: "none", + detail: "no-local-state activation requires a broker device identity" + }); + expect(requests).toBe(0); + }); + it("binds native activation to the explicit broker device and canonical repository", async () => { const root = mkRoot(roots); const requestBodies: unknown[] = []; @@ -532,6 +570,7 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: "LIC-keychain-mismatch-test-123456", + machineId: "broker-device-mismatch-123", persistLocalState: false, keychainCredentialVerifier: () => false }); From 8bc02cf9bc9cc3a02f21d5ddcf41d30fb9c4b407 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 18 Jul 2026 22:42:31 +0700 Subject: [PATCH 4/9] test: bind native CLI activation fixture --- tests/public-cli.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/public-cli.test.ts b/tests/public-cli.test.ts index c76a5008..6cdc64d9 100644 --- a/tests/public-cli.test.ts +++ b/tests/public-cli.test.ts @@ -156,6 +156,10 @@ describe("public NeonDiff CLI surface", () => { "true", "--persist-local-state", "false", + "--license-machine-id", + "broker-device-cli-fixture-123", + "--repo", + "acme/private", "--json" ], `${key}\n`, { env: activatedLicenseTestEnv() }); } catch (error) { From 3f0252e0ee2ed3430524735d771771d975d54f5e Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 18 Jul 2026 23:04:41 +0700 Subject: [PATCH 5/9] fix(license): parse dash-prefixed broker identities --- src/cli.ts | 5 ++- tests/public-cli.test.ts | 69 +++++++++++++++++++++------------------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index edae9b16..1eb17bb9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3631,7 +3631,10 @@ function parseArgs(argv: string[]): ParsedArgs { } const key = arg.slice(2); const next = argv[index + 1]; - if (next && !next.startsWith("--")) { + // RFC 7638 base64url device thumbprints may legitimately begin with "--". + // This option is value-bearing, so consume its next token and let the + // dedicated validator reject missing or malformed values fail closed. + if (next && (!next.startsWith("--") || key === "license-machine-id")) { setParsedArg(parsed, key, next, repeatableArgs); index += 1; } else { diff --git a/tests/public-cli.test.ts b/tests/public-cli.test.ts index 6cdc64d9..8768fc22 100644 --- a/tests/public-cli.test.ts +++ b/tests/public-cli.test.ts @@ -143,39 +143,44 @@ describe("public NeonDiff CLI surface", () => { } })}\n`); - let failure: (Error & { code?: number; stdout?: string; stderr?: string }) | undefined; - try { - await runCliWithStdin([ - "license", - "activate", - "--config", - configPath, - "--license-storage", - "keychain", - "--license-key-stdin", - "true", - "--persist-local-state", - "false", - "--license-machine-id", - "broker-device-cli-fixture-123", - "--repo", - "acme/private", - "--json" - ], `${key}\n`, { env: activatedLicenseTestEnv() }); - } catch (error) { - failure = error as Error & { code?: number; stdout?: string; stderr?: string }; - } - expect(failure?.code).toBe(1); - const output = JSON.parse(failure?.stdout ?? "{}"); + for (const machineId of [ + "broker-device-cli-fixture-123", + "--broker-device-cli-fixture-123" + ]) { + let failure: (Error & { code?: number; stdout?: string; stderr?: string }) | undefined; + try { + await runCliWithStdin([ + "license", + "activate", + "--config", + configPath, + "--license-storage", + "keychain", + "--license-key-stdin", + "true", + "--persist-local-state", + "false", + "--license-machine-id", + machineId, + "--repo", + "acme/private", + "--json" + ], `${key}\n`, { env: activatedLicenseTestEnv() }); + } catch (error) { + failure = error as Error & { code?: number; stdout?: string; stderr?: string }; + } + expect(failure?.code).toBe(1); + const output = JSON.parse(failure?.stdout ?? "{}"); - expect(output).toMatchObject({ - ok: false, - status: "invalid", - source: "none", - detail: "no-local-state activation requires the matching native Keychain credential" - }); - expect(failure?.stdout).not.toContain(key); - expect(failure?.stderr).not.toContain(key); + expect(output).toMatchObject({ + ok: false, + status: "invalid", + source: "none", + detail: "no-local-state activation requires the matching native Keychain credential" + }); + expect(failure?.stdout).not.toContain(key); + expect(failure?.stderr).not.toContain(key); + } expect(existsSync(keyPath)).toBe(false); expect(existsSync(cachePath)).toBe(false); }); From c091c25e13f7f9fb4000602a0af37bfd72244ec5 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 18 Jul 2026 23:20:31 +0700 Subject: [PATCH 6/9] fix(license): require native repository binding --- src/license.ts | 20 ++++++++++++++++ tests/license.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/license.ts b/src/license.ts index d0789817..2d9ec12b 100644 --- a/src/license.ts +++ b/src/license.ts @@ -165,6 +165,26 @@ export async function activateLicense(input: { detail: "no-local-state activation requires a broker device identity" }; } + if (persistLocalState && input.machineId?.trim()) { + return { + ok: false, + status: "invalid", + source: "none", + checkedAt: now.toISOString(), + classification: "invalid", + detail: "broker device identity requires native no-local-state activation" + }; + } + if (!persistLocalState && !input.repo?.trim()) { + return { + ok: false, + status: "invalid", + source: "none", + checkedAt: now.toISOString(), + classification: "invalid", + detail: "no-local-state activation requires one canonical repository" + }; + } const licenseKey = input.licenseKey.trim(); if (!licenseKey) return missingResult(now, "license key is empty"); if (!input.config.apiBaseUrl) return serverResult(now, "license API base URL is not configured"); diff --git a/tests/license.test.ts b/tests/license.test.ts index b4a649b9..50887e5f 100644 --- a/tests/license.test.ts +++ b/tests/license.test.ts @@ -486,6 +486,59 @@ describe("license activation and entitlement cache", () => { expect(requests).toBe(0); }); + it("fails closed when a broker device identity is supplied outside native no-local-state activation", async () => { + const root = mkRoot(roots); + const config = licenseConfig(root, "https://license.example.invalid"); + let requests = 0; + + const result = await activateLicense({ + config, + licenseKey: "LIC-file-backed-broker-device-test-123456", + machineId: "broker-device-binding-123", + repo: "octo/private", + fetchImpl: (async () => { + requests += 1; + return new Response("{}", { status: 500 }); + }) as typeof fetch + }); + + expect(result).toMatchObject({ + ok: false, + status: "invalid", + source: "none", + detail: "broker device identity requires native no-local-state activation" + }); + expect(requests).toBe(0); + }); + + it("fails closed when native no-local-state activation omits the canonical repository", async () => { + const root = mkRoot(roots); + const config = licenseConfig(root, "https://license.example.invalid"); + config.storageBackend = "keychain"; + config.keyPath = undefined; + let requests = 0; + + const result = await activateLicense({ + config, + licenseKey: "LIC-keychain-missing-repository-test-123456", + machineId: "broker-device-binding-123", + persistLocalState: false, + keychainCredentialVerifier: () => true, + fetchImpl: (async () => { + requests += 1; + return new Response("{}", { status: 500 }); + }) as typeof fetch + }); + + expect(result).toMatchObject({ + ok: false, + status: "invalid", + source: "none", + detail: "no-local-state activation requires one canonical repository" + }); + expect(requests).toBe(0); + }); + it("binds native activation to the explicit broker device and canonical repository", async () => { const root = mkRoot(roots); const requestBodies: unknown[] = []; @@ -571,6 +624,7 @@ describe("license activation and entitlement cache", () => { config, licenseKey: "LIC-keychain-mismatch-test-123456", machineId: "broker-device-mismatch-123", + repo: "octo/private", persistLocalState: false, keychainCredentialVerifier: () => false }); From 24c57e081762ec6e2959953d4aa9476f4da47aa5 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 18 Jul 2026 23:41:19 +0700 Subject: [PATCH 7/9] fix(license): keep activation repository immutable --- services/license-api/src/service.ts | 13 +++++++++++++ services/license-api/src/store.ts | 2 +- services/license-api/test/service.test.ts | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/services/license-api/src/service.ts b/services/license-api/src/service.ts index b0810275..1b8955ce 100644 --- a/services/license-api/src/service.ts +++ b/services/license-api/src/service.ts @@ -113,6 +113,16 @@ function seatExhaustedResult(record: LicenseRecord): ServiceResult { }; } +function repositoryBindingMismatchResult(): ServiceResult { + return { + httpStatus: 409, + body: { + status: "scope_mismatch", + detail: "license activation is already bound to a different repository" + } + }; +} + function isExpired(record: LicenseRecord, now: Date): boolean { if (!record.expiresAt) return false; const ms = Date.parse(record.expiresAt); @@ -143,6 +153,9 @@ export function activate(store: LicenseStore, req: LicenseRequest, now: Date): S const existing = store.getActivation(record.licenseKeyHash, req.machineId); if (existing) { + if (existing.repo && req.repo && existing.repo !== req.repo) { + return repositoryBindingMismatchResult(); + } // Same machine re-activating → idempotent active, refresh last_seen_at. store.upsertActivation(record.licenseKeyHash, req.machineId, req.repo, nowIso); return activeEntitlementBody(record); diff --git a/services/license-api/src/store.ts b/services/license-api/src/store.ts index a4b45550..eae9b0d5 100644 --- a/services/license-api/src/store.ts +++ b/services/license-api/src/store.ts @@ -1002,7 +1002,7 @@ export class LicenseStore { `insert into activations (license_key_hash, machine_id, repo, activated_at, last_seen_at) values (?, ?, ?, ?, ?) on conflict (license_key_hash, machine_id) - do update set last_seen_at = excluded.last_seen_at, repo = coalesce(excluded.repo, activations.repo)` + do update set last_seen_at = excluded.last_seen_at, repo = coalesce(activations.repo, excluded.repo)` ) .run(licenseKeyHash, machineId, repo ?? null, now, now); } diff --git a/services/license-api/test/service.test.ts b/services/license-api/test/service.test.ts index 45fdba03..ac1b7c93 100644 --- a/services/license-api/test/service.test.ts +++ b/services/license-api/test/service.test.ts @@ -54,6 +54,25 @@ describe("license service endpoints", () => { assert.equal(store.countActivations(hashLicenseKey(key)), 1); }); + it("rejects repository rebinding for an existing machine activation", () => { + const { key } = issue(); + const keyHash = hashLicenseKey(key); + assert.equal(activate(store, req(key, "machine-a", "acme/private-a"), NOW).httpStatus, 200); + + const rebound = activate( + store, + req(key, "machine-a", "acme/private-b"), + new Date("2026-07-06T00:01:00.000Z") + ); + + assert.equal(rebound.httpStatus, 409); + assert.deepEqual(rebound.body, { + status: "scope_mismatch", + detail: "license activation is already bound to a different repository" + }); + assert.equal(store.getActivation(keyHash, "machine-a")?.repo, "acme/private-a"); + }); + it("activate rejects a different machine when seats are exhausted → 409 scope_mismatch", () => { const { key } = issue({ seats: 1 }); activate(store, req(key, "machine-a"), NOW); From 204fdafbcd578fb0013752de2e728b6448009a87 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 19 Jul 2026 00:05:06 +0700 Subject: [PATCH 8/9] fix(license): validate native repository binding --- src/license.ts | 12 +++++++++++- tests/license.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/license.ts b/src/license.ts index 2d9ec12b..e72dd78f 100644 --- a/src/license.ts +++ b/src/license.ts @@ -19,6 +19,8 @@ import { buildApiUrl, normalizeHttpApiBaseUrl } from "./url-safety.js"; import type { LicenseSecretReader } from "./license-secret-store.js"; const MAXIMUM_LICENSE_API_RESPONSE_BYTES = 64 * 1024; +const CANONICAL_GITHUB_REPOSITORY_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/; export type LicenseStorageBackend = "keychain" | "file"; export type LicenseStatus = @@ -175,7 +177,15 @@ export async function activateLicense(input: { detail: "broker device identity requires native no-local-state activation" }; } - if (!persistLocalState && !input.repo?.trim()) { + const repository = input.repo?.trim(); + if ( + !persistLocalState + && ( + !repository + || repository !== input.repo + || !CANONICAL_GITHUB_REPOSITORY_PATTERN.test(repository) + ) + ) { return { ok: false, status: "invalid", diff --git a/tests/license.test.ts b/tests/license.test.ts index 50887e5f..a800da86 100644 --- a/tests/license.test.ts +++ b/tests/license.test.ts @@ -539,6 +539,42 @@ describe("license activation and entitlement cache", () => { expect(requests).toBe(0); }); + it.each([ + "octo", + "octo/private/extra", + "/private", + "octo/", + "-octo/private", + "octo-/private" + ])("fails closed when native no-local-state activation receives malformed repository %j", async (repo) => { + const root = mkRoot(roots); + const config = licenseConfig(root, "https://license.example.invalid"); + config.storageBackend = "keychain"; + config.keyPath = undefined; + let requests = 0; + + const result = await activateLicense({ + config, + licenseKey: "LIC-keychain-malformed-repository-test-123456", + machineId: "broker-device-binding-123", + repo, + persistLocalState: false, + keychainCredentialVerifier: () => true, + fetchImpl: (async () => { + requests += 1; + return new Response("{}", { status: 500 }); + }) as typeof fetch + }); + + expect(result).toMatchObject({ + ok: false, + status: "invalid", + source: "none", + detail: "no-local-state activation requires one canonical repository" + }); + expect(requests).toBe(0); + }); + it("binds native activation to the explicit broker device and canonical repository", async () => { const root = mkRoot(roots); const requestBodies: unknown[] = []; From 3e836a6856780db63f373b06d002fceb12d2abd6 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 19 Jul 2026 00:27:24 +0700 Subject: [PATCH 9/9] fix(license): validate native activation identity --- services/license-api/src/http.ts | 14 +++++++- services/license-api/test/http.test.ts | 17 ++++++++++ src/cli.ts | 4 +-- src/license.ts | 13 ++++++-- tests/license.test.ts | 46 +++++++++++++++++++++----- tests/public-cli.test.ts | 6 ++-- 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/services/license-api/src/http.ts b/services/license-api/src/http.ts index 994e31cc..8e023eaf 100644 --- a/services/license-api/src/http.ts +++ b/services/license-api/src/http.ts @@ -45,6 +45,8 @@ import { } from "./github-broker/index.js"; const MAX_BODY_BYTES = 16 * 1024; +const CANONICAL_GITHUB_REPOSITORY_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/; class RequestBodyTooLargeError extends Error {} @@ -315,7 +317,17 @@ function parseRequest(raw: string): LicenseRequest { const machineId = body.machineId; if (typeof licenseKey !== "string" || licenseKey.trim().length === 0) throw new Error("licenseKey is required"); if (typeof machineId !== "string" || machineId.trim().length === 0) throw new Error("machineId is required"); - const repo = typeof body.repo === "string" && body.repo.length > 0 ? body.repo : undefined; + let repo: string | undefined; + if (body.repo !== undefined) { + if ( + typeof body.repo !== "string" + || body.repo !== body.repo.trim() + || !CANONICAL_GITHUB_REPOSITORY_PATTERN.test(body.repo) + ) { + throw new Error("repo must be a canonical owner/name repository"); + } + repo = body.repo; + } return { licenseKey: licenseKey.trim(), machineId: machineId.trim(), repo }; } diff --git a/services/license-api/test/http.test.ts b/services/license-api/test/http.test.ts index 4611abe8..8af26572 100644 --- a/services/license-api/test/http.test.ts +++ b/services/license-api/test/http.test.ts @@ -73,6 +73,23 @@ describe("license http transport", () => { assert.equal(res.status, 400); }); + it("rejects a malformed repository before it can consume an activation binding", async () => { + const rawKey = store.issueLicense({ plan: "yearly", repoVisibilityScope: "private" }).rawKey; + const malformed = await post(url, "/v1/license/activate", { + licenseKey: rawKey, + machineId: "broker-device-http-fixture-123", + repo: "octo/private/extra" + }); + assert.equal(malformed.status, 400); + + const canonical = await post(url, "/v1/license/activate", { + licenseKey: rawKey, + machineId: "broker-device-http-fixture-123", + repo: "octo/private" + }); + assert.equal(canonical.status, 200); + }); + it("activates over HTTP and echoes no raw key", async () => { const res = await post(url, "/v1/license/activate", { licenseKey: issuedKey, machineId: "m1" }); assert.equal(res.status, 200); diff --git a/src/cli.ts b/src/cli.ts index 1eb17bb9..35f68712 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3695,8 +3695,8 @@ function parseLicenseMachineIdArg(value: ParsedArgs[string]): string { throw new Error("--license-machine-id requires a value"); } const machineId = parseSingleArg(value, "--license-machine-id"); - if (!/^[A-Za-z0-9_-]{8,128}$/.test(machineId)) { - throw new Error("--license-machine-id must be one 8-128 character non-secret broker device id"); + if (!/^[A-Za-z0-9_-]{43}$/.test(machineId)) { + throw new Error("--license-machine-id must be one RFC 7638 SHA-256 broker device id"); } return machineId; } diff --git a/src/license.ts b/src/license.ts index e72dd78f..404ebcca 100644 --- a/src/license.ts +++ b/src/license.ts @@ -21,6 +21,7 @@ import type { LicenseSecretReader } from "./license-secret-store.js"; const MAXIMUM_LICENSE_API_RESPONSE_BYTES = 64 * 1024; const CANONICAL_GITHUB_REPOSITORY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/; +const RFC7638_SHA256_THUMBPRINT_PATTERN = /^[A-Za-z0-9_-]{43}$/; export type LicenseStorageBackend = "keychain" | "file"; export type LicenseStatus = @@ -157,14 +158,22 @@ export async function activateLicense(input: { detail: "Keychain license activation is disabled in headless CLI until native no-argv secret storage is available; use storageBackend=file" }; } - if (!persistLocalState && !input.machineId?.trim()) { + const machineId = input.machineId?.trim(); + if ( + !persistLocalState + && ( + !machineId + || machineId !== input.machineId + || !RFC7638_SHA256_THUMBPRINT_PATTERN.test(machineId) + ) + ) { return { ok: false, status: "invalid", source: "none", checkedAt: now.toISOString(), classification: "invalid", - detail: "no-local-state activation requires a broker device identity" + detail: "no-local-state activation requires an RFC 7638 broker device identity" }; } if (persistLocalState && input.machineId?.trim()) { diff --git a/tests/license.test.ts b/tests/license.test.ts index a800da86..9e6abaee 100644 --- a/tests/license.test.ts +++ b/tests/license.test.ts @@ -25,6 +25,7 @@ import { createTestLicenseAdmission, testLicenseAdmission } from "./helpers/lice const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); const tsxCliPath = require.resolve("tsx/cli"); +const BROKER_DEVICE_ID = "A".repeat(43); function testLicenseFingerprint(key: string): string { return createHash("sha256").update(key).digest("hex").slice(0, 16); @@ -423,7 +424,7 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: key, - machineId: "broker-device-native-123", + machineId: BROKER_DEVICE_ID, repo: "octo/private", persistLocalState: false, keychainCredentialVerifier: (credential) => { @@ -481,7 +482,36 @@ describe("license activation and entitlement cache", () => { ok: false, status: "invalid", source: "none", - detail: "no-local-state activation requires a broker device identity" + detail: "no-local-state activation requires an RFC 7638 broker device identity" + }); + expect(requests).toBe(0); + }); + + it("fails closed when native no-local-state activation receives a non-thumbprint device identity", async () => { + const root = mkRoot(roots); + const config = licenseConfig(root, "https://license.example.invalid"); + config.storageBackend = "keychain"; + config.keyPath = undefined; + let requests = 0; + + const result = await activateLicense({ + config, + licenseKey: "LIC-keychain-malformed-binding-test-123456", + machineId: "broker-device-binding-123", + repo: "octo/private", + persistLocalState: false, + keychainCredentialVerifier: () => true, + fetchImpl: (async () => { + requests += 1; + return new Response("{}", { status: 500 }); + }) as typeof fetch + }); + + expect(result).toMatchObject({ + ok: false, + status: "invalid", + source: "none", + detail: "no-local-state activation requires an RFC 7638 broker device identity" }); expect(requests).toBe(0); }); @@ -494,7 +524,7 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: "LIC-file-backed-broker-device-test-123456", - machineId: "broker-device-binding-123", + machineId: BROKER_DEVICE_ID, repo: "octo/private", fetchImpl: (async () => { requests += 1; @@ -521,7 +551,7 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: "LIC-keychain-missing-repository-test-123456", - machineId: "broker-device-binding-123", + machineId: BROKER_DEVICE_ID, persistLocalState: false, keychainCredentialVerifier: () => true, fetchImpl: (async () => { @@ -556,7 +586,7 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: "LIC-keychain-malformed-repository-test-123456", - machineId: "broker-device-binding-123", + machineId: BROKER_DEVICE_ID, repo, persistLocalState: false, keychainCredentialVerifier: () => true, @@ -586,7 +616,7 @@ describe("license activation and entitlement cache", () => { config, licenseKey: "LIC-keychain-binding-test-123456", repo: "octo/private", - machineId: "broker-device-binding-123", + machineId: BROKER_DEVICE_ID, persistLocalState: false, keychainCredentialVerifier: () => true, fetchImpl: (async (_url, init) => { @@ -607,7 +637,7 @@ describe("license activation and entitlement cache", () => { expect(requestBodies).toEqual([{ licenseKey: "LIC-keychain-binding-test-123456", repo: "octo/private", - machineId: "broker-device-binding-123" + machineId: BROKER_DEVICE_ID }]); expect(JSON.stringify(result)).not.toContain("LIC-keychain-binding-test-123456"); }); @@ -659,7 +689,7 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: "LIC-keychain-mismatch-test-123456", - machineId: "broker-device-mismatch-123", + machineId: BROKER_DEVICE_ID, repo: "octo/private", persistLocalState: false, keychainCredentialVerifier: () => false diff --git a/tests/public-cli.test.ts b/tests/public-cli.test.ts index 8768fc22..80ceda09 100644 --- a/tests/public-cli.test.ts +++ b/tests/public-cli.test.ts @@ -25,6 +25,8 @@ const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); const tsxCliPath = require.resolve("tsx/cli"); const repoRoot = process.cwd(); +const BROKER_DEVICE_ID = "A".repeat(43); +const DASH_PREFIXED_BROKER_DEVICE_ID = `--${"A".repeat(41)}`; const darwinDaemonEnv = { NEONDIFF_TEST_PLATFORM: "darwin" }; const providerVerificationAdmission = await createTestLicenseAdmission({ operation: "provider_verify" }); const admittedProviderVerification = async () => ({ @@ -144,8 +146,8 @@ describe("public NeonDiff CLI surface", () => { })}\n`); for (const machineId of [ - "broker-device-cli-fixture-123", - "--broker-device-cli-fixture-123" + BROKER_DEVICE_ID, + DASH_PREFIXED_BROKER_DEVICE_ID ]) { let failure: (Error & { code?: number; stdout?: string; stderr?: string }) | undefined; try {