diff --git a/README.md b/README.md index 6000df4e..e2d2258c 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,15 @@ 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. 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/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..ef1d4846 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -205,6 +205,18 @@ 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. 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 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..7fef707a 100644 --- a/docs/github-app-setup.md +++ b/docs/github-app-setup.md @@ -56,12 +56,26 @@ 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. +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. 3. Select `Only select repositories`. diff --git a/docs/license-boundary.md b/docs/license-boundary.md index d3596446..f445c904 100644 --- a/docs/license-boundary.md +++ b/docs/license-boundary.md @@ -81,6 +81,16 @@ 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. 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 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..b4ad5808 --- /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" }; + 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" }; + } + + 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/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/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/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/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..009a8baa --- /dev/null +++ b/services/license-api/test/github-broker-production-entitlement.test.ts @@ -0,0 +1,177 @@ +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, 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, + { licenseKey: active.rawKey, machineId: DEVICE_ID, repo: PRIVATE_REPO }, + NOW + ).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" }); + 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" }); + assert.deepEqual(await resolve(context(malformedExpiry.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/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/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); diff --git a/src/cli.ts b/src/cli.ts index de208144..35f68712 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." } ] @@ -3627,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 { @@ -3683,6 +3690,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_-]{43}$/.test(machineId)) { + throw new Error("--license-machine-id must be one RFC 7638 SHA-256 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..404ebcca 100644 --- a/src/license.ts +++ b/src/license.ts @@ -19,6 +19,9 @@ 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}$/; +const RFC7638_SHA256_THUMBPRINT_PATTERN = /^[A-Za-z0-9_-]{43}$/; export type LicenseStorageBackend = "keychain" | "file"; export type LicenseStatus = @@ -121,6 +124,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. @@ -153,6 +158,52 @@ 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" }; } + 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 an RFC 7638 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" + }; + } + const repository = input.repo?.trim(); + if ( + !persistLocalState + && ( + !repository + || repository !== input.repo + || !CANONICAL_GITHUB_REPOSITORY_PATTERN.test(repository) + ) + ) { + 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"); @@ -185,6 +236,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 +262,7 @@ export async function activateLicense(input: { path: "/v1/license/deactivate", licenseKey, repo: input.repo, + machineId: input.machineId, now, fetchImpl: input.fetchImpl }); @@ -497,6 +550,7 @@ async function callLicenseApi(input: { path: string; licenseKey: string; repo?: string; + machineId?: string; now?: Date; fetchImpl?: typeof fetch; }): Promise { @@ -513,7 +567,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..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,6 +424,8 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: key, + machineId: BROKER_DEVICE_ID, + repo: "octo/private", persistLocalState: false, keychainCredentialVerifier: (credential) => { verifiedKeychainCredentials.push(credential); @@ -448,6 +451,197 @@ 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 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); + }); + + 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_ID, + 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_ID, + 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.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_ID, + 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[] = []; + 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_ID, + 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_ID + }]); + 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; @@ -495,6 +689,8 @@ describe("license activation and entitlement cache", () => { const result = await activateLicense({ config, licenseKey: "LIC-keychain-mismatch-test-123456", + 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 c76a5008..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 () => ({ @@ -143,35 +145,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", - "--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_ID, + DASH_PREFIXED_BROKER_DEVICE_ID + ]) { + 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); });