@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-CWXut3rG.js +115 -0
- package/gui/dist/assets/index-EdoPnm9_.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/devin.svg +49 -0
- package/gui/dist/provider-icons/omo.svg +42 -0
- package/package.json +3 -1
- package/src/AGENTS.md +1 -1
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
- package/src/adapters/command-code.ts +0 -1
- package/src/adapters/cursor/checkpoint-store.ts +37 -0
- package/src/adapters/cursor/live-transport.ts +74 -2
- package/src/adapters/cursor.ts +6 -0
- package/src/adapters/devin/cloud-direct/auth.ts +264 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
- package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
- package/src/adapters/devin/cloud-direct/index.ts +65 -0
- package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
- package/src/adapters/devin/cloud-direct/wire.ts +206 -0
- package/src/adapters/devin/live-models.ts +133 -0
- package/src/adapters/devin-cli/acp.ts +204 -0
- package/src/adapters/devin-cli/adapter.ts +345 -0
- package/src/adapters/devin-cli/binary.ts +69 -0
- package/src/adapters/devin-cli/models.ts +57 -0
- package/src/adapters/devin.ts +326 -0
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +31 -10
- package/src/adapters/openai-responses.ts +16 -1
- package/src/adapters/registry.ts +25 -1
- package/src/bridge.ts +8 -2
- package/src/claude/inbound-cache-stabilize.ts +130 -0
- package/src/claude/inbound.ts +45 -5
- package/src/cli/account-auth.ts +60 -1
- package/src/cli/account-extended.ts +23 -30
- package/src/cli/account.ts +2 -1
- package/src/cli/capabilities.ts +50 -8
- package/src/cli/config-command.ts +2 -2
- package/src/cli/dispatch.ts +14 -2
- package/src/cli/export-command.ts +11 -25
- package/src/cli/help.ts +2 -2
- package/src/cli/opencode.ts +5 -0
- package/src/cli/registry.ts +15 -4
- package/src/clients/config-export/cline.ts +71 -0
- package/src/clients/config-export/contracts.ts +10 -1
- package/src/clients/config-export/model-metadata.ts +33 -0
- package/src/clients/config-export/zcode.ts +23 -12
- package/src/clients/config-export.ts +156 -4
- package/src/codex/account-store.ts +7 -2
- package/src/codex/auth-context.ts +1 -1
- package/src/codex/catalog/effort.ts +4 -3
- package/src/codex/catalog/metadata.ts +8 -4
- package/src/codex/catalog/native-models.ts +24 -4
- package/src/codex/catalog/parsing.ts +19 -1
- package/src/codex/catalog/provider-fetch.ts +57 -0
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/context-compat.ts +97 -0
- package/src/codex/context-owner.ts +201 -0
- package/src/codex/history-provider.ts +161 -14
- package/src/codex/inject-coordination.ts +3 -2
- package/src/codex/inject.ts +128 -9
- package/src/codex/pool-rotation.ts +8 -292
- package/src/codex/quota.ts +41 -12
- package/src/codex/retired-model-migration.ts +41 -0
- package/src/codex/routing.ts +327 -22
- package/src/codex/warmup.ts +2 -2
- package/src/combos/failover.ts +5 -0
- package/src/combos/resolve.ts +11 -15
- package/src/config.ts +46 -14
- package/src/generated/compatibility-version.json +293 -113
- package/src/grok/grpc-web.ts +120 -0
- package/src/grok/reset-coupon-ledger.ts +139 -0
- package/src/grok/reset-coupons.ts +278 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/cline-document.ts +73 -0
- package/src/integrations/cline-io.ts +149 -0
- package/src/integrations/cline-transaction.ts +42 -0
- package/src/integrations/config-io.ts +21 -2
- package/src/integrations/journal.ts +43 -1
- package/src/integrations/omp-yaml-source.ts +123 -4
- package/src/integrations/ownership-policy.ts +7 -0
- package/src/integrations/ownership.ts +36 -0
- package/src/integrations/registry.ts +27 -0
- package/src/integrations/state.ts +5 -2
- package/src/integrations/store.ts +6 -0
- package/src/integrations/writer.ts +53 -9
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/abort.ts +36 -0
- package/src/lib/local-destinations.ts +1 -1
- package/src/lib/upstream-retry.ts +36 -1
- package/src/oauth/account-quota-rank.ts +11 -0
- package/src/oauth/callback-server.ts +10 -5
- package/src/oauth/devin/api-base.ts +63 -0
- package/src/oauth/devin/login.ts +1 -0
- package/src/oauth/devin/register-user.ts +186 -0
- package/src/oauth/devin/types.ts +71 -0
- package/src/oauth/devin-cli.ts +149 -0
- package/src/oauth/devin.ts +170 -0
- package/src/oauth/generic-account-failover.ts +169 -1
- package/src/oauth/index.ts +20 -1
- package/src/oauth/login-cli.ts +19 -5
- package/src/oauth/pool-kernel.ts +321 -0
- package/src/oauth/pool-settings-capability.ts +127 -9
- package/src/oauth/store.ts +7 -3
- package/src/oauth/token-guardian.ts +1 -1
- package/src/providers/codebuddy-models.ts +0 -3
- package/src/providers/command-code-efforts.ts +23 -4
- package/src/providers/default-aliases.ts +4 -0
- package/src/providers/derive.ts +8 -0
- package/src/providers/devin-cli-authmode-migration.ts +57 -0
- package/src/providers/key-failover.ts +175 -0
- package/src/providers/model-rename-startup.ts +21 -1
- package/src/providers/qoder-models.ts +0 -1
- package/src/providers/quota-key-accounts.ts +50 -0
- package/src/providers/quota-routing-cache.ts +65 -6
- package/src/providers/quota-types.ts +7 -0
- package/src/providers/quota.ts +113 -30
- package/src/providers/registry.ts +237 -77
- package/src/providers/stale-context-window-migration.ts +92 -0
- package/src/providers/zai-responses-migration.ts +45 -0
- package/src/quota/reset-observer.ts +2 -1
- package/src/quota/reset-seen-store.ts +9 -1
- package/src/remote-control/crypto.ts +442 -0
- package/src/remote-control/host.ts +175 -0
- package/src/remote-control/index.ts +100 -0
- package/src/remote-control/protocol.ts +200 -0
- package/src/remote-control/relay.ts +162 -0
- package/src/remote-control/workspace-agent-protocol.ts +246 -0
- package/src/remote-control/workspace-rpc-framing.ts +128 -0
- package/src/remote-control/workspace-tools.ts +237 -0
- package/src/remote-control/workspace-utf8.ts +24 -0
- package/src/responses/custom-tool-compat.ts +23 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/adapter-resolve.ts +6 -2
- package/src/server/auth-cors.ts +71 -6
- package/src/server/chat-completions.ts +5 -3
- package/src/server/chat-native.ts +9 -0
- package/src/server/claude-messages.ts +25 -30
- package/src/server/context-history.ts +207 -0
- package/src/server/images.ts +28 -1
- package/src/server/index.ts +42 -22
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/config-routes.ts +3 -3
- package/src/server/management/grok-coupon-routes.ts +287 -0
- package/src/server/management/integration-routes.ts +6 -1
- package/src/server/management/oauth-account-routes.ts +124 -7
- package/src/server/management/provider-routes.ts +77 -2
- package/src/server/management/route-registry.ts +6 -1
- package/src/server/management-api.ts +7 -0
- package/src/server/request-log.ts +4 -0
- package/src/server/responses/codex-ws-exchange.ts +90 -14
- package/src/server/responses/codex-ws-wire.ts +51 -1
- package/src/server/responses/compact.ts +8 -1
- package/src/server/responses/core.ts +188 -22
- package/src/server/responses/ws-upstream.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +261 -12
- package/src/server/zai-responses-startup.ts +21 -0
- package/src/types/config.ts +33 -3
- package/src/types/provider.ts +54 -4
- package/src/types/request.ts +1 -1
- package/src/types/tools.ts +36 -6
- package/src/update/job.ts +33 -11
- package/src/vision/plan.ts +40 -37
- package/src/web-search/index.ts +1 -0
- package/gui/dist/assets/index-BoBRSehJ.css +0 -1
- package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repair context windows that a saved config inherited from a wrong registry seed.
|
|
3
|
+
*
|
|
4
|
+
* `enrichProviderFromRegistry` is fill-only: it seeds `modelContextWindows` when
|
|
5
|
+
* the row has none and never rewrites it afterwards. That posture is right — a
|
|
6
|
+
* hand-tuned window must survive an upgrade — but it means a registry table that
|
|
7
|
+
* shipped WRONG numbers is frozen into every config that was saved while those
|
|
8
|
+
* numbers were current. Correcting the registry alone fixes new installs and
|
|
9
|
+
* leaves existing ones reporting the old figure forever.
|
|
10
|
+
*
|
|
11
|
+
* This rewrites one thing: a window whose saved value is still byte-for-byte the
|
|
12
|
+
* wrong number this file names, on a provider that still carries the registry's
|
|
13
|
+
* adapter. A value the user changed does not match `from` and is left alone, and
|
|
14
|
+
* nothing else in the row is touched. Same shape and the same restraint as
|
|
15
|
+
* `model-rename-migration`, for the case where the id was right and the number
|
|
16
|
+
* was not.
|
|
17
|
+
*/
|
|
18
|
+
import { PROVIDER_REGISTRY } from "./registry";
|
|
19
|
+
import type { OcxConfig } from "../types";
|
|
20
|
+
|
|
21
|
+
export interface StaleContextWindow {
|
|
22
|
+
/** Registry provider id whose saved rows may carry the wrong window. */
|
|
23
|
+
provider: string;
|
|
24
|
+
model: string;
|
|
25
|
+
/** The wrong value this migration is allowed to replace, and nothing else. */
|
|
26
|
+
from: number;
|
|
27
|
+
to: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StaleContextWindowProjection {
|
|
31
|
+
config: OcxConfig;
|
|
32
|
+
changed: boolean;
|
|
33
|
+
warnings: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cognition windows corrected against a live `GetCascadeModelConfigs` response.
|
|
38
|
+
*
|
|
39
|
+
* The shipped table had been assembled from each model's ORIGINAL vendor window
|
|
40
|
+
* rather than from what Cognition serves, so the Claude rows claimed 200k against
|
|
41
|
+
* an actual 1M and Grok claimed 256k against 500k. Cognition documents no window
|
|
42
|
+
* anywhere, so the per-account catalog is the only first-party source; these are
|
|
43
|
+
* the degraded-mode figures, and live discovery supersedes them when it runs.
|
|
44
|
+
*/
|
|
45
|
+
export const STALE_CONTEXT_WINDOWS: readonly StaleContextWindow[] = [
|
|
46
|
+
{ provider: "devin", model: "swe-1-7", from: 256_000, to: 262_000 },
|
|
47
|
+
{ provider: "devin", model: "swe-1-7-lightning", from: 256_000, to: 202_752 },
|
|
48
|
+
{ provider: "devin", model: "gpt-5-6-sol", from: 1_050_000, to: 1_000_000 },
|
|
49
|
+
{ provider: "devin", model: "gpt-5-6-luna", from: 1_050_000, to: 1_000_000 },
|
|
50
|
+
{ provider: "devin", model: "gpt-5-6-terra", from: 1_050_000, to: 1_000_000 },
|
|
51
|
+
{ provider: "devin", model: "claude-opus-4-8", from: 200_000, to: 1_000_000 },
|
|
52
|
+
{ provider: "devin", model: "claude-fable-5-1", from: 200_000, to: 1_000_000 },
|
|
53
|
+
{ provider: "devin", model: "claude-sonnet-5", from: 200_000, to: 1_000_000 },
|
|
54
|
+
{ provider: "devin", model: "kimi-k2-7", from: 256_000, to: 262_144 },
|
|
55
|
+
{ provider: "devin", model: "grok-4-5", from: 256_000, to: 500_000 },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function providerStillMatchesRegistry(id: string, adapter: unknown): boolean {
|
|
59
|
+
const entry = PROVIDER_REGISTRY.find(row => row.id === id);
|
|
60
|
+
return entry !== undefined && entry.adapter === adapter;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Pure projection. The caller decides whether to persist. */
|
|
64
|
+
export function projectStaleContextWindows(
|
|
65
|
+
config: OcxConfig,
|
|
66
|
+
entries: readonly StaleContextWindow[] = STALE_CONTEXT_WINDOWS,
|
|
67
|
+
): StaleContextWindowProjection {
|
|
68
|
+
const warnings: string[] = [];
|
|
69
|
+
const repaired = new Map<string, string[]>();
|
|
70
|
+
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const prov = config.providers?.[entry.provider];
|
|
73
|
+
if (!prov) continue;
|
|
74
|
+
if (!providerStillMatchesRegistry(entry.provider, prov.adapter)) continue;
|
|
75
|
+
const windows = prov.modelContextWindows;
|
|
76
|
+
if (!windows || windows[entry.model] !== entry.from) continue;
|
|
77
|
+
windows[entry.model] = entry.to;
|
|
78
|
+
const list = repaired.get(entry.provider) ?? [];
|
|
79
|
+
list.push(`${entry.model} ${entry.from} -> ${entry.to}`);
|
|
80
|
+
repaired.set(entry.provider, list);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [provider, list] of repaired) {
|
|
84
|
+
warnings.push(
|
|
85
|
+
`corrected ${list.length} context window(s) on "${provider}" that the saved config `
|
|
86
|
+
+ `inherited from a wrong registry seed: ${list.join(", ")}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { config, changed: repaired.size > 0, warnings };
|
|
91
|
+
}
|
|
92
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry";
|
|
2
|
+
import type { OcxConfig } from "../types";
|
|
3
|
+
|
|
4
|
+
export const ZAI_PROVIDER_ID = "zai";
|
|
5
|
+
export const ZAI_RESPONSES_DEFAULT_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Persist the Responses destination the router already applies to the `zai` row.
|
|
9
|
+
*
|
|
10
|
+
* The Z.AI coding plan moved from Chat Completions at /api/coding/paas/v4 to Responses at
|
|
11
|
+
* /api/v1 (#4297). A config written before that move still stores the Chat adapter and the old
|
|
12
|
+
* base URL, and `routedProviderConfig()` rewrites both on every request because the registry
|
|
13
|
+
* entry owns a fixed destination. The row therefore already talks Responses while the dashboard,
|
|
14
|
+
* `ocx doctor` and any direct config reader show the retired Chat endpoint, and each boot logs a
|
|
15
|
+
* "configured baseUrl is ignored" warning about a value the user never chose.
|
|
16
|
+
*
|
|
17
|
+
* This migration writes the canonical pair once so the stored row matches the live wire. It is
|
|
18
|
+
* behavior-preserving by construction: it only rewrites rows the router canonicalizes anyway.
|
|
19
|
+
* Chat remains reachable per model through `modelAdapters`, and the persisted marker keeps a
|
|
20
|
+
* later explicit Chat choice from being migrated again.
|
|
21
|
+
*
|
|
22
|
+
* A custom-named provider pointing at the retired endpoint is deliberately left alone. The router
|
|
23
|
+
* does not canonicalize it, so rewriting it would change a wire the operator actually configured;
|
|
24
|
+
* `destinationAliases` already gives it this row's metadata.
|
|
25
|
+
*/
|
|
26
|
+
export function migrateZaiResponsesDefault(config: OcxConfig): boolean {
|
|
27
|
+
const provider = config.providers[ZAI_PROVIDER_ID];
|
|
28
|
+
if (!provider || (provider.zaiResponsesDefaultVersion ?? 0) >= ZAI_RESPONSES_DEFAULT_VERSION) return false;
|
|
29
|
+
const entry = getProviderRegistryEntry(ZAI_PROVIDER_ID);
|
|
30
|
+
if (!entry) return false;
|
|
31
|
+
// Fail closed if a later registry edit makes this destination operator-owned: only a fixed,
|
|
32
|
+
// non-templated endpoint is canonicalized at request time, so only that one may be persisted.
|
|
33
|
+
if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false;
|
|
34
|
+
if (!providerMatchesRegistryTransport(ZAI_PROVIDER_ID, provider)) return false;
|
|
35
|
+
config.providers = {
|
|
36
|
+
...config.providers,
|
|
37
|
+
[ZAI_PROVIDER_ID]: {
|
|
38
|
+
...provider,
|
|
39
|
+
adapter: entry.adapter,
|
|
40
|
+
baseUrl: entry.baseUrl,
|
|
41
|
+
zaiResponsesDefaultVersion: ZAI_RESPONSES_DEFAULT_VERSION,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
@@ -55,6 +55,7 @@ export function observeQuotaSnapshot(input: {
|
|
|
55
55
|
readonly accountKey: string;
|
|
56
56
|
readonly windows: ReadonlyArray<QuotaWindowObservation>;
|
|
57
57
|
readonly now?: number;
|
|
58
|
+
readonly retainAbsentShortWindow?: boolean;
|
|
58
59
|
}): QuotaResetEvent[] {
|
|
59
60
|
try {
|
|
60
61
|
if (!sink) return [];
|
|
@@ -65,7 +66,7 @@ export function observeQuotaSnapshot(input: {
|
|
|
65
66
|
// between two observations to tell a rolling window's natural decay from a real reset,
|
|
66
67
|
// and only this layer knows when the snapshot was taken.
|
|
67
68
|
const stamped = input.windows.map(window => ({ ...window, observedAt: now }));
|
|
68
|
-
const previous = swapLastObservedWindows(input.scope, accountTag, stamped);
|
|
69
|
+
const previous = swapLastObservedWindows(input.scope, accountTag, stamped, input.retainAbsentShortWindow);
|
|
69
70
|
if (!previous) return [];
|
|
70
71
|
|
|
71
72
|
const detected = detectQuotaResets({
|
|
@@ -306,6 +306,7 @@ export function swapLastObservedWindows(
|
|
|
306
306
|
scope: string,
|
|
307
307
|
accountTag: string,
|
|
308
308
|
windows: ReadonlyArray<QuotaWindowObservation>,
|
|
309
|
+
retainAbsentShortWindow = false,
|
|
309
310
|
): ReadonlyArray<QuotaWindowObservation> | undefined {
|
|
310
311
|
hydrate();
|
|
311
312
|
const key = observedKey(scope, accountTag);
|
|
@@ -317,7 +318,14 @@ export function swapLastObservedWindows(
|
|
|
317
318
|
// Measured: the hottest scope was evicted and its next genuine scheduled reset was
|
|
318
319
|
// silently missed, because a re-baselined row has no previous value to diff against.
|
|
319
320
|
observed.delete(key);
|
|
320
|
-
|
|
321
|
+
const next = windows.map(window => ({ ...window }));
|
|
322
|
+
// Codex display eviction is not a new short observation. Retain notification history
|
|
323
|
+
// with its original clock until a real reading replaces it or account cleanup forgets it.
|
|
324
|
+
if (retainAbsentShortWindow && !next.some(window => window.window === "5h")) {
|
|
325
|
+
const short = previous?.find(window => window.window === "5h");
|
|
326
|
+
if (short) next.push({ ...short });
|
|
327
|
+
}
|
|
328
|
+
observed.set(key, next);
|
|
321
329
|
if (observed.size > MAX_OBSERVED_SCOPES) {
|
|
322
330
|
// Least-recently-observed first. Evicting only costs a re-baseline, never a duplicate
|
|
323
331
|
// notification, because the claim ledger is separate — but a re-baseline DOES cost the
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
createPrivateKey,
|
|
6
|
+
createPublicKey,
|
|
7
|
+
diffieHellman,
|
|
8
|
+
generateKeyPairSync,
|
|
9
|
+
hkdfSync,
|
|
10
|
+
randomBytes,
|
|
11
|
+
sign,
|
|
12
|
+
verify,
|
|
13
|
+
type KeyObject,
|
|
14
|
+
} from "node:crypto";
|
|
15
|
+
import {
|
|
16
|
+
REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES,
|
|
17
|
+
REMOTE_CONTROL_PROTOCOL_VERSION,
|
|
18
|
+
isRemoteControlCommandProfile,
|
|
19
|
+
isRemoteControlUuid,
|
|
20
|
+
normalizeRemoteControlCapabilities,
|
|
21
|
+
remoteControlUuidBytes,
|
|
22
|
+
type RemoteControlCapability,
|
|
23
|
+
type RemoteControlClientHello,
|
|
24
|
+
type RemoteControlCommandProfile,
|
|
25
|
+
type RemoteControlHostHello,
|
|
26
|
+
} from "./protocol";
|
|
27
|
+
|
|
28
|
+
const HELLO_MAX_BYTES = 16 * 1024;
|
|
29
|
+
const NONCE_BYTES = 32;
|
|
30
|
+
const GCM_TAG_BYTES = 16;
|
|
31
|
+
const COUNTER_BYTES = 8;
|
|
32
|
+
const MAX_COUNTER = 0xffff_ffff_ffff_ffffn;
|
|
33
|
+
const MAX_ENCRYPTED_PLAINTEXT_BYTES = REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - GCM_TAG_BYTES - COUNTER_BYTES;
|
|
34
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
35
|
+
|
|
36
|
+
export interface RemoteControlIdentityKeyPair {
|
|
37
|
+
publicKey: string;
|
|
38
|
+
privateKey: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CreateRemoteControlClientHandshakeOptions {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
deviceId: string;
|
|
44
|
+
commandProfile: RemoteControlCommandProfile;
|
|
45
|
+
capabilities: readonly RemoteControlCapability[];
|
|
46
|
+
accountPrivateKey: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AcceptRemoteControlClientHelloOptions {
|
|
50
|
+
expectedSessionId: string;
|
|
51
|
+
expectedDeviceId: string;
|
|
52
|
+
accountPublicKey: string;
|
|
53
|
+
devicePrivateKey: string;
|
|
54
|
+
allowedCapabilities: readonly RemoteControlCapability[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function encode(value: Uint8Array): string {
|
|
58
|
+
return Buffer.from(value).toString("base64url");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function decode(value: unknown, label: string, maxBytes: number): Buffer {
|
|
62
|
+
if (typeof value !== "string" || !BASE64URL_PATTERN.test(value) || value.length > Math.ceil(maxBytes * 4 / 3) + 4) {
|
|
63
|
+
throw new Error(`invalid ${label}`);
|
|
64
|
+
}
|
|
65
|
+
const decoded = Buffer.from(value, "base64url");
|
|
66
|
+
if (decoded.byteLength === 0 || decoded.byteLength > maxBytes || encode(decoded) !== value) throw new Error(`invalid ${label}`);
|
|
67
|
+
return decoded;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function privateSigningKey(value: string): KeyObject {
|
|
71
|
+
const key = createPrivateKey({ key: decode(value, "remote control private key", 256), format: "der", type: "pkcs8" });
|
|
72
|
+
if (key.asymmetricKeyType !== "ed25519") throw new Error("remote control private key must be Ed25519");
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function publicSigningKey(value: string): KeyObject {
|
|
77
|
+
const key = createPublicKey({ key: decode(value, "remote control public key", 256), format: "der", type: "spki" });
|
|
78
|
+
if (key.asymmetricKeyType !== "ed25519") throw new Error("remote control public key must be Ed25519");
|
|
79
|
+
return key;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function publicEphemeralKey(value: string): KeyObject {
|
|
83
|
+
const key = createPublicKey({ key: decode(value, "remote control ephemeral key", 256), format: "der", type: "spki" });
|
|
84
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
|
|
85
|
+
throw new Error("remote control ephemeral key must use P-256");
|
|
86
|
+
}
|
|
87
|
+
return key;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function lengthPrefixed(value: Uint8Array): Buffer {
|
|
91
|
+
if (value.byteLength > 0xffff) throw new Error("remote control transcript field is too large");
|
|
92
|
+
const length = Buffer.alloc(2);
|
|
93
|
+
length.writeUInt16BE(value.byteLength);
|
|
94
|
+
return Buffer.concat([length, Buffer.from(value)]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function capabilityTranscript(capabilities: readonly RemoteControlCapability[]): Buffer {
|
|
98
|
+
const normalized = normalizeRemoteControlCapabilities([...capabilities]);
|
|
99
|
+
return Buffer.concat([
|
|
100
|
+
Buffer.from([normalized.length]),
|
|
101
|
+
...normalized.map(capability => lengthPrefixed(Buffer.from(capability, "utf8"))),
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clientTranscript(hello: Omit<RemoteControlClientHello, "signature">): Buffer {
|
|
106
|
+
return Buffer.concat([
|
|
107
|
+
Buffer.from("opencodex-remote-control-client-v1\0", "utf8"),
|
|
108
|
+
Buffer.from([hello.version]),
|
|
109
|
+
Buffer.from(remoteControlUuidBytes(hello.sessionId)),
|
|
110
|
+
Buffer.from(remoteControlUuidBytes(hello.deviceId)),
|
|
111
|
+
lengthPrefixed(Buffer.from(hello.commandProfile, "utf8")),
|
|
112
|
+
capabilityTranscript(hello.capabilities),
|
|
113
|
+
lengthPrefixed(decode(hello.ephemeralPublicKey, "remote control ephemeral key", 256)),
|
|
114
|
+
decode(hello.nonce, "remote control client nonce", NONCE_BYTES),
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hostTranscript(
|
|
119
|
+
client: Buffer,
|
|
120
|
+
hello: Omit<RemoteControlHostHello, "signature">,
|
|
121
|
+
): Buffer {
|
|
122
|
+
return Buffer.concat([
|
|
123
|
+
Buffer.from("opencodex-remote-control-host-v1\0", "utf8"),
|
|
124
|
+
createHash("sha256").update(client).digest(),
|
|
125
|
+
capabilityTranscript(hello.capabilities),
|
|
126
|
+
lengthPrefixed(decode(hello.ephemeralPublicKey, "remote control ephemeral key", 256)),
|
|
127
|
+
decode(hello.nonce, "remote control host nonce", NONCE_BYTES),
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseJsonObject(value: Uint8Array, label: string): Record<string, unknown> {
|
|
132
|
+
if (!(value instanceof Uint8Array) || value.byteLength === 0 || value.byteLength > HELLO_MAX_BYTES) {
|
|
133
|
+
throw new Error(`invalid ${label}`);
|
|
134
|
+
}
|
|
135
|
+
let parsed: unknown;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value));
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error(`invalid ${label}`);
|
|
140
|
+
}
|
|
141
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`invalid ${label}`);
|
|
142
|
+
return parsed as Record<string, unknown>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateCommonHello(raw: Record<string, unknown>, label: string): {
|
|
146
|
+
sessionId: string;
|
|
147
|
+
deviceId: string;
|
|
148
|
+
capabilities: RemoteControlCapability[];
|
|
149
|
+
ephemeralPublicKey: string;
|
|
150
|
+
nonce: string;
|
|
151
|
+
signature: string;
|
|
152
|
+
} {
|
|
153
|
+
if (raw.version !== REMOTE_CONTROL_PROTOCOL_VERSION) throw new Error(`unsupported ${label} version`);
|
|
154
|
+
if (!isRemoteControlUuid(raw.sessionId) || !isRemoteControlUuid(raw.deviceId)) throw new Error(`invalid ${label} identity`);
|
|
155
|
+
const capabilities = normalizeRemoteControlCapabilities(raw.capabilities);
|
|
156
|
+
if (typeof raw.ephemeralPublicKey !== "string" || typeof raw.nonce !== "string" || typeof raw.signature !== "string") {
|
|
157
|
+
throw new Error(`invalid ${label} cryptographic fields`);
|
|
158
|
+
}
|
|
159
|
+
const ephemeralPublicKey = raw.ephemeralPublicKey;
|
|
160
|
+
const encodedNonce = raw.nonce;
|
|
161
|
+
const encodedSignature = raw.signature;
|
|
162
|
+
decode(ephemeralPublicKey, `${label} ephemeral key`, 256);
|
|
163
|
+
const nonce = decode(encodedNonce, `${label} nonce`, NONCE_BYTES);
|
|
164
|
+
if (nonce.byteLength !== NONCE_BYTES) throw new Error(`invalid ${label} nonce`);
|
|
165
|
+
const signature = decode(encodedSignature, `${label} signature`, 128);
|
|
166
|
+
if (signature.byteLength !== 64) throw new Error(`invalid ${label} signature`);
|
|
167
|
+
return {
|
|
168
|
+
sessionId: raw.sessionId,
|
|
169
|
+
deviceId: raw.deviceId,
|
|
170
|
+
capabilities,
|
|
171
|
+
ephemeralPublicKey,
|
|
172
|
+
nonce: encodedNonce,
|
|
173
|
+
signature: encodedSignature,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseRemoteControlClientHello(value: Uint8Array): RemoteControlClientHello {
|
|
178
|
+
const raw = parseJsonObject(value, "remote control client hello");
|
|
179
|
+
const common = validateCommonHello(raw, "remote control client hello");
|
|
180
|
+
if (!isRemoteControlCommandProfile(raw.commandProfile)) throw new Error("invalid remote control command profile");
|
|
181
|
+
return {
|
|
182
|
+
version: REMOTE_CONTROL_PROTOCOL_VERSION,
|
|
183
|
+
...common,
|
|
184
|
+
commandProfile: raw.commandProfile,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function parseRemoteControlHostHello(value: Uint8Array): RemoteControlHostHello {
|
|
189
|
+
const raw = parseJsonObject(value, "remote control host hello");
|
|
190
|
+
return { version: REMOTE_CONTROL_PROTOCOL_VERSION, ...validateCommonHello(raw, "remote control host hello") };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function serializeRemoteControlHello(value: RemoteControlClientHello | RemoteControlHostHello): Uint8Array {
|
|
194
|
+
const encoded = new TextEncoder().encode(JSON.stringify(value));
|
|
195
|
+
if (encoded.byteLength > HELLO_MAX_BYTES) throw new Error("remote control hello is too large");
|
|
196
|
+
return encoded;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function generateRemoteControlIdentityKeyPair(): RemoteControlIdentityKeyPair {
|
|
200
|
+
const pair = generateKeyPairSync("ed25519");
|
|
201
|
+
return {
|
|
202
|
+
publicKey: encode(pair.publicKey.export({ format: "der", type: "spki" })),
|
|
203
|
+
privateKey: encode(pair.privateKey.export({ format: "der", type: "pkcs8" })),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function nonce(prefix: Buffer, counter: bigint): Buffer {
|
|
208
|
+
const result = Buffer.alloc(12);
|
|
209
|
+
prefix.copy(result, 0);
|
|
210
|
+
result.writeBigUInt64BE(counter, 4);
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function frameAad(sessionId: string, direction: 0 | 1, counter: bigint): Buffer {
|
|
215
|
+
const counterBytes = Buffer.alloc(COUNTER_BYTES);
|
|
216
|
+
counterBytes.writeBigUInt64BE(counter);
|
|
217
|
+
return Buffer.concat([
|
|
218
|
+
Buffer.from("opencodex-remote-control-frame-v1\0", "utf8"),
|
|
219
|
+
Buffer.from(remoteControlUuidBytes(sessionId)),
|
|
220
|
+
Buffer.from([direction]),
|
|
221
|
+
counterBytes,
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export class RemoteControlCipher {
|
|
226
|
+
private sendCounter = 0n;
|
|
227
|
+
private receiveCounter = 0n;
|
|
228
|
+
private destroyed = false;
|
|
229
|
+
|
|
230
|
+
constructor(
|
|
231
|
+
private readonly sessionId: string,
|
|
232
|
+
private readonly sendDirection: 0 | 1,
|
|
233
|
+
private readonly sendKey: Buffer,
|
|
234
|
+
private readonly receiveKey: Buffer,
|
|
235
|
+
private readonly sendNoncePrefix: Buffer,
|
|
236
|
+
private readonly receiveNoncePrefix: Buffer,
|
|
237
|
+
) {}
|
|
238
|
+
|
|
239
|
+
encrypt(value: Uint8Array): Uint8Array {
|
|
240
|
+
if (this.destroyed) throw new Error("remote control cipher is closed");
|
|
241
|
+
if (!(value instanceof Uint8Array) || value.byteLength > MAX_ENCRYPTED_PLAINTEXT_BYTES) {
|
|
242
|
+
throw new Error("remote control encrypted payload is too large");
|
|
243
|
+
}
|
|
244
|
+
if (this.sendCounter > MAX_COUNTER) throw new Error("remote control send counter exhausted");
|
|
245
|
+
const counter = this.sendCounter;
|
|
246
|
+
const cipher = createCipheriv("aes-256-gcm", this.sendKey, nonce(this.sendNoncePrefix, counter), { authTagLength: GCM_TAG_BYTES });
|
|
247
|
+
cipher.setAAD(frameAad(this.sessionId, this.sendDirection, counter));
|
|
248
|
+
const ciphertext = Buffer.concat([cipher.update(value), cipher.final()]);
|
|
249
|
+
const counterBytes = Buffer.alloc(COUNTER_BYTES);
|
|
250
|
+
counterBytes.writeBigUInt64BE(counter);
|
|
251
|
+
this.sendCounter += 1n;
|
|
252
|
+
return Buffer.concat([counterBytes, ciphertext, cipher.getAuthTag()]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
decrypt(value: Uint8Array): Uint8Array {
|
|
256
|
+
if (this.destroyed) throw new Error("remote control cipher is closed");
|
|
257
|
+
if (
|
|
258
|
+
!(value instanceof Uint8Array)
|
|
259
|
+
|| value.byteLength < COUNTER_BYTES + GCM_TAG_BYTES
|
|
260
|
+
|| value.byteLength > REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES
|
|
261
|
+
) throw new Error("invalid remote control encrypted frame");
|
|
262
|
+
const bytes = Buffer.from(value);
|
|
263
|
+
const counter = bytes.readBigUInt64BE(0);
|
|
264
|
+
if (counter !== this.receiveCounter) throw new Error("replayed or out-of-order remote control frame");
|
|
265
|
+
const ciphertextEnd = bytes.byteLength - GCM_TAG_BYTES;
|
|
266
|
+
const decipher = createDecipheriv(
|
|
267
|
+
"aes-256-gcm",
|
|
268
|
+
this.receiveKey,
|
|
269
|
+
nonce(this.receiveNoncePrefix, counter),
|
|
270
|
+
{ authTagLength: GCM_TAG_BYTES },
|
|
271
|
+
);
|
|
272
|
+
decipher.setAAD(frameAad(this.sessionId, this.sendDirection === 0 ? 1 : 0, counter));
|
|
273
|
+
decipher.setAuthTag(bytes.subarray(ciphertextEnd));
|
|
274
|
+
const plaintext = Buffer.concat([decipher.update(bytes.subarray(COUNTER_BYTES, ciphertextEnd)), decipher.final()]);
|
|
275
|
+
this.receiveCounter += 1n;
|
|
276
|
+
return plaintext;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
destroy(): void {
|
|
280
|
+
if (this.destroyed) return;
|
|
281
|
+
this.destroyed = true;
|
|
282
|
+
this.sendKey.fill(0);
|
|
283
|
+
this.receiveKey.fill(0);
|
|
284
|
+
this.sendNoncePrefix.fill(0);
|
|
285
|
+
this.receiveNoncePrefix.fill(0);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function deriveCipher(options: {
|
|
290
|
+
sessionId: string;
|
|
291
|
+
shared: Buffer;
|
|
292
|
+
clientNonce: Buffer;
|
|
293
|
+
hostNonce: Buffer;
|
|
294
|
+
role: "client" | "host";
|
|
295
|
+
}): RemoteControlCipher {
|
|
296
|
+
const salt = createHash("sha256").update(options.clientNonce).update(options.hostNonce).digest();
|
|
297
|
+
const expand = (info: string, length: number): Buffer => Buffer.from(hkdfSync(
|
|
298
|
+
"sha256",
|
|
299
|
+
options.shared,
|
|
300
|
+
salt,
|
|
301
|
+
Buffer.from(info, "utf8"),
|
|
302
|
+
length,
|
|
303
|
+
));
|
|
304
|
+
const clientToHostKey = expand("opencodex remote control client to host key v1", 32);
|
|
305
|
+
const hostToClientKey = expand("opencodex remote control host to client key v1", 32);
|
|
306
|
+
const clientToHostNonce = expand("opencodex remote control client to host nonce v1", 4);
|
|
307
|
+
const hostToClientNonce = expand("opencodex remote control host to client nonce v1", 4);
|
|
308
|
+
options.shared.fill(0);
|
|
309
|
+
return options.role === "client"
|
|
310
|
+
? new RemoteControlCipher(options.sessionId, 0, clientToHostKey, hostToClientKey, clientToHostNonce, hostToClientNonce)
|
|
311
|
+
: new RemoteControlCipher(options.sessionId, 1, hostToClientKey, clientToHostKey, hostToClientNonce, clientToHostNonce);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export class RemoteControlClientHandshake {
|
|
315
|
+
readonly hello: RemoteControlClientHello;
|
|
316
|
+
private completed = false;
|
|
317
|
+
|
|
318
|
+
private constructor(
|
|
319
|
+
hello: RemoteControlClientHello,
|
|
320
|
+
private readonly ephemeralPrivateKey: KeyObject,
|
|
321
|
+
private readonly transcript: Buffer,
|
|
322
|
+
) {
|
|
323
|
+
this.hello = hello;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
static create(options: CreateRemoteControlClientHandshakeOptions): RemoteControlClientHandshake {
|
|
327
|
+
if (!isRemoteControlUuid(options.sessionId) || !isRemoteControlUuid(options.deviceId)) {
|
|
328
|
+
throw new Error("invalid remote control handshake identity");
|
|
329
|
+
}
|
|
330
|
+
if (!isRemoteControlCommandProfile(options.commandProfile)) throw new Error("invalid remote control command profile");
|
|
331
|
+
const capabilities = normalizeRemoteControlCapabilities([...options.capabilities]);
|
|
332
|
+
const ephemeral = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
333
|
+
const unsigned: Omit<RemoteControlClientHello, "signature"> = {
|
|
334
|
+
version: REMOTE_CONTROL_PROTOCOL_VERSION,
|
|
335
|
+
sessionId: options.sessionId,
|
|
336
|
+
deviceId: options.deviceId,
|
|
337
|
+
commandProfile: options.commandProfile,
|
|
338
|
+
capabilities,
|
|
339
|
+
ephemeralPublicKey: encode(ephemeral.publicKey.export({ format: "der", type: "spki" })),
|
|
340
|
+
nonce: encode(randomBytes(NONCE_BYTES)),
|
|
341
|
+
};
|
|
342
|
+
const transcript = clientTranscript(unsigned);
|
|
343
|
+
const hello: RemoteControlClientHello = {
|
|
344
|
+
...unsigned,
|
|
345
|
+
signature: encode(sign(null, transcript, privateSigningKey(options.accountPrivateKey))),
|
|
346
|
+
};
|
|
347
|
+
return new RemoteControlClientHandshake(hello, ephemeral.privateKey, transcript);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
complete(hostHello: RemoteControlHostHello, devicePublicKey: string): RemoteControlCipher {
|
|
351
|
+
if (this.completed) throw new Error("remote control client handshake already completed");
|
|
352
|
+
const validated = parseRemoteControlHostHello(serializeRemoteControlHello(hostHello));
|
|
353
|
+
if (validated.sessionId !== this.hello.sessionId || validated.deviceId !== this.hello.deviceId) {
|
|
354
|
+
throw new Error("remote control host hello identity mismatch");
|
|
355
|
+
}
|
|
356
|
+
const requested = new Set(this.hello.capabilities);
|
|
357
|
+
if (validated.capabilities.some(capability => !requested.has(capability))) {
|
|
358
|
+
throw new Error("remote control host granted an unrequested capability");
|
|
359
|
+
}
|
|
360
|
+
const unsigned: Omit<RemoteControlHostHello, "signature"> = {
|
|
361
|
+
version: validated.version,
|
|
362
|
+
sessionId: validated.sessionId,
|
|
363
|
+
deviceId: validated.deviceId,
|
|
364
|
+
capabilities: validated.capabilities,
|
|
365
|
+
ephemeralPublicKey: validated.ephemeralPublicKey,
|
|
366
|
+
nonce: validated.nonce,
|
|
367
|
+
};
|
|
368
|
+
if (!verify(
|
|
369
|
+
null,
|
|
370
|
+
hostTranscript(this.transcript, unsigned),
|
|
371
|
+
publicSigningKey(devicePublicKey),
|
|
372
|
+
decode(validated.signature, "remote control host signature", 128),
|
|
373
|
+
)) throw new Error("remote control host identity verification failed");
|
|
374
|
+
const cipher = deriveCipher({
|
|
375
|
+
sessionId: this.hello.sessionId,
|
|
376
|
+
shared: Buffer.from(diffieHellman({
|
|
377
|
+
privateKey: this.ephemeralPrivateKey,
|
|
378
|
+
publicKey: publicEphemeralKey(validated.ephemeralPublicKey),
|
|
379
|
+
})),
|
|
380
|
+
clientNonce: decode(this.hello.nonce, "remote control client nonce", NONCE_BYTES),
|
|
381
|
+
hostNonce: decode(validated.nonce, "remote control host nonce", NONCE_BYTES),
|
|
382
|
+
role: "client",
|
|
383
|
+
});
|
|
384
|
+
this.completed = true;
|
|
385
|
+
return cipher;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function acceptRemoteControlClientHello(
|
|
390
|
+
clientHello: RemoteControlClientHello,
|
|
391
|
+
options: AcceptRemoteControlClientHelloOptions,
|
|
392
|
+
): { hello: RemoteControlHostHello; cipher: RemoteControlCipher } {
|
|
393
|
+
const validated = parseRemoteControlClientHello(serializeRemoteControlHello(clientHello));
|
|
394
|
+
if (validated.sessionId !== options.expectedSessionId || validated.deviceId !== options.expectedDeviceId) {
|
|
395
|
+
throw new Error("remote control client hello identity mismatch");
|
|
396
|
+
}
|
|
397
|
+
const allowed = new Set(normalizeRemoteControlCapabilities([...options.allowedCapabilities]));
|
|
398
|
+
const capabilities = validated.capabilities.filter(capability => allowed.has(capability));
|
|
399
|
+
const unsignedClient: Omit<RemoteControlClientHello, "signature"> = {
|
|
400
|
+
version: validated.version,
|
|
401
|
+
sessionId: validated.sessionId,
|
|
402
|
+
deviceId: validated.deviceId,
|
|
403
|
+
commandProfile: validated.commandProfile,
|
|
404
|
+
capabilities: validated.capabilities,
|
|
405
|
+
ephemeralPublicKey: validated.ephemeralPublicKey,
|
|
406
|
+
nonce: validated.nonce,
|
|
407
|
+
};
|
|
408
|
+
const transcript = clientTranscript(unsignedClient);
|
|
409
|
+
if (!verify(
|
|
410
|
+
null,
|
|
411
|
+
transcript,
|
|
412
|
+
publicSigningKey(options.accountPublicKey),
|
|
413
|
+
decode(validated.signature, "remote control client signature", 128),
|
|
414
|
+
)) throw new Error("remote control account identity verification failed");
|
|
415
|
+
|
|
416
|
+
const ephemeral = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
417
|
+
const unsignedHost: Omit<RemoteControlHostHello, "signature"> = {
|
|
418
|
+
version: REMOTE_CONTROL_PROTOCOL_VERSION,
|
|
419
|
+
sessionId: validated.sessionId,
|
|
420
|
+
deviceId: validated.deviceId,
|
|
421
|
+
capabilities,
|
|
422
|
+
ephemeralPublicKey: encode(ephemeral.publicKey.export({ format: "der", type: "spki" })),
|
|
423
|
+
nonce: encode(randomBytes(NONCE_BYTES)),
|
|
424
|
+
};
|
|
425
|
+
const hello: RemoteControlHostHello = {
|
|
426
|
+
...unsignedHost,
|
|
427
|
+
signature: encode(sign(null, hostTranscript(transcript, unsignedHost), privateSigningKey(options.devicePrivateKey))),
|
|
428
|
+
};
|
|
429
|
+
return {
|
|
430
|
+
hello,
|
|
431
|
+
cipher: deriveCipher({
|
|
432
|
+
sessionId: validated.sessionId,
|
|
433
|
+
shared: Buffer.from(diffieHellman({
|
|
434
|
+
privateKey: ephemeral.privateKey,
|
|
435
|
+
publicKey: publicEphemeralKey(validated.ephemeralPublicKey),
|
|
436
|
+
})),
|
|
437
|
+
clientNonce: decode(validated.nonce, "remote control client nonce", NONCE_BYTES),
|
|
438
|
+
hostNonce: decode(hello.nonce, "remote control host nonce", NONCE_BYTES),
|
|
439
|
+
role: "host",
|
|
440
|
+
}),
|
|
441
|
+
};
|
|
442
|
+
}
|