Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
100yenadmin marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
100yenadmin marked this conversation as resolved.
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]
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -41,6 +47,8 @@ package struct DesktopActivationLicenseClient: ActivationLicenseClienting {
"--license-storage", "keychain",
"--license-key-stdin", "true",
"--persist-local-state", "false",
"--license-machine-id", machineId,
Comment thread
100yenadmin marked this conversation as resolved.
"--repo", repository,
"--json"
]
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T>(_ body: (String) throws -> T) rethrows -> T {
try body(raw)
}

public var description: String { "\(ActivationTerminology.activationKey) \(redactedPrefix)" }
public var debugDescription: String { description }
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ public struct GitHubBrokerClient: Sendable {
public func issueToken(
identity: GitHubBrokerDeviceIdentity,
installationId: Int,
repositories: [String]
repositories: [String],
activationKey: ActivationKeyMaterial? = nil
Comment thread
100yenadmin marked this conversation as resolved.
) async throws -> GitHubInstallationAccessGrant {
guard installationId > 0,
repositories.isEmpty == false,
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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) })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ import Testing

private func client(_ fixture: Result<CLIRunResult, Error>) -> (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<CLIRunResult, Error> {
Expand Down Expand Up @@ -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"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]
Expand Down
12 changes: 12 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
100yenadmin marked this conversation as resolved.
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
Expand Down
20 changes: 17 additions & 3 deletions docs/github-app-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
10 changes: 10 additions & 0 deletions docs/license-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions services/license-api/src/github-broker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
type EntitlementResolutionContext
} from "./service.js";
export { GitHubBrokerStore } from "./store.js";
export { createLicenseStoreEntitlementResolver } from "./license-entitlement.js";
export {
createGitHubBrokerService,
handleGitHubBrokerRequest,
Expand Down
60 changes: 60 additions & 0 deletions services/license-api/src/github-broker/license-entitlement.ts
Original file line number Diff line number Diff line change
@@ -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]
Comment thread
100yenadmin marked this conversation as resolved.
Comment thread
100yenadmin marked this conversation as resolved.
: []
Comment thread
100yenadmin marked this conversation as resolved.
};
};
}

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;
}
Loading
Loading