@skrr-ai/cli 0.1.83 → 0.1.85
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/README.md +2 -2
- package/dist/base-command.js +2 -2
- package/dist/commands/agents/chat.js +10 -3
- package/dist/commands/agents/endpoint/connect.d.ts +24 -0
- package/dist/commands/agents/endpoint/connect.js +93 -0
- package/dist/commands/agents/endpoint/disable.d.ts +18 -0
- package/dist/commands/agents/endpoint/disable.js +44 -0
- package/dist/commands/agents/endpoint/enable.d.ts +18 -0
- package/dist/commands/agents/endpoint/enable.js +46 -0
- package/dist/commands/agents/endpoint/show.d.ts +16 -0
- package/dist/commands/agents/endpoint/show.js +45 -0
- package/dist/commands/harnesses/install-token.d.ts +4 -4
- package/dist/commands/harnesses/install-token.js +4 -4
- package/dist/commands/store/acquisitions/list.d.ts +18 -0
- package/dist/commands/store/acquisitions/list.js +58 -0
- package/dist/commands/store/governance/requests.js +6 -0
- package/dist/commands/store/maintainers/remove.d.ts +8 -1
- package/dist/commands/store/maintainers/remove.js +29 -4
- package/dist/commands/store/reports/confirm-ban.d.ts +19 -0
- package/dist/commands/store/reports/confirm-ban.js +57 -0
- package/dist/commands/store/reports/disposition.d.ts +21 -0
- package/dist/commands/store/reports/disposition.js +76 -0
- package/dist/commands/store/reports/list.d.ts +17 -0
- package/dist/commands/store/reports/list.js +79 -0
- package/dist/commands/store/reports/withdraw-ban.d.ts +18 -0
- package/dist/commands/store/reports/withdraw-ban.js +48 -0
- package/dist/commands/store/updates.js +9 -5
- package/dist/commands/workspaces/audit.d.ts +30 -0
- package/dist/commands/workspaces/audit.js +145 -0
- package/dist/lib/agent-endpoints.d.ts +62 -0
- package/dist/lib/agent-endpoints.js +64 -0
- package/dist/lib/agentic-stream.d.ts +84 -17
- package/dist/lib/agentic-stream.js +207 -36
- package/dist/lib/auth-storage.d.ts +1 -1
- package/dist/lib/auth-storage.js +1 -1
- package/dist/lib/daemonBroker.d.ts +1 -1
- package/dist/lib/daemonBroker.js +3 -4
- package/dist/lib/harness-context.js +1 -5
- package/dist/lib/store-acquisition.d.ts +2 -0
- package/dist/lib/store-acquisition.js +18 -1
- package/dist/lib/store-moderation.d.ts +95 -0
- package/dist/lib/store-moderation.js +51 -0
- package/dist/lib/workspaces.d.ts +73 -0
- package/dist/lib/workspaces.js +19 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonPair.js +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonPair.js +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +4324 -4294
- package/oclif.manifest.json +43429 -42673
- package/package.json +4 -3
- package/dist/commands/pair.d.ts +0 -67
- package/dist/commands/pair.js +0 -351
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skrr-ai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.85",
|
|
4
4
|
"description": "Command-line interface for skrr",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "skrr",
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
"@oclif/plugin-warn-if-update-available": "^3.1.0",
|
|
54
54
|
"@skrr-ai/auth-core": "^0.4.12",
|
|
55
55
|
"proper-lockfile": "^4.1.2",
|
|
56
|
-
"qrcode-terminal": "^0.12.0",
|
|
57
56
|
"socket.io-client": "^4.8.3",
|
|
58
57
|
"tweetnacl": "^1.0.3"
|
|
59
58
|
},
|
|
@@ -61,7 +60,6 @@
|
|
|
61
60
|
"@oclif/plugin-legacy": "^2.0.37",
|
|
62
61
|
"@types/node": "^20.11.0",
|
|
63
62
|
"@types/proper-lockfile": "^4.1.4",
|
|
64
|
-
"@types/qrcode-terminal": "^0.12.2",
|
|
65
63
|
"esbuild": "^0.25.12",
|
|
66
64
|
"oclif": "^4.23.29",
|
|
67
65
|
"rimraf": "^5.0.5",
|
|
@@ -157,6 +155,9 @@
|
|
|
157
155
|
"agents:goals": {
|
|
158
156
|
"description": "List the goals an agent is on the hook for (agent-scoped facade over `goals`)"
|
|
159
157
|
},
|
|
158
|
+
"agents:endpoint": {
|
|
159
|
+
"description": "Your agent as a hosted MCP endpoint: show its URL and payer, switch it on for a Store copy, and mint an agents:invoke key"
|
|
160
|
+
},
|
|
160
161
|
"agents:tone": {
|
|
161
162
|
"description": "Read and set how an agent WRITES (its tone; audio timbre is `voiceId`)"
|
|
162
163
|
},
|
package/dist/commands/pair.d.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { BaseCommand } from '../base-command';
|
|
2
|
-
interface DecryptedPlaintext {
|
|
3
|
-
token: string;
|
|
4
|
-
refreshToken: string;
|
|
5
|
-
daemonId: string | null;
|
|
6
|
-
dataEncryptionKey: string | null;
|
|
7
|
-
}
|
|
8
|
-
/**
|
|
9
|
-
* Canonical AAD bytes for the pair-response (C4). Mobile builds the
|
|
10
|
-
* same string, ASCII-encodes, prepends a 2-byte big-endian length, and
|
|
11
|
-
* appends to the ciphertext bundle. The CLI rebuilds locally and
|
|
12
|
-
* compares before decrypting — so a tampered or misdirected ciphertext
|
|
13
|
-
* fails AAD before tweetnacl ever sees it.
|
|
14
|
-
*
|
|
15
|
-
* Format: `oversky:pair:v1:daemonId=<daemonId>:responseUserId=<responseUserId>`
|
|
16
|
-
*
|
|
17
|
-
* The version tag (`v1`) lets us evolve the AAD shape later without
|
|
18
|
-
* silently accepting old payloads.
|
|
19
|
-
*/
|
|
20
|
-
export declare function buildPairAad(daemonId: string, responseUserId: string): Uint8Array;
|
|
21
|
-
export default class Pair extends BaseCommand {
|
|
22
|
-
static description: string;
|
|
23
|
-
static hidden: boolean;
|
|
24
|
-
static examples: string[];
|
|
25
|
-
static flags: {
|
|
26
|
-
'no-qr': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
27
|
-
'poll-timeout': import("@oclif/core/lib/interfaces").OptionFlag<number, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
28
|
-
};
|
|
29
|
-
run(): Promise<void>;
|
|
30
|
-
}
|
|
31
|
-
export interface DecryptOptions {
|
|
32
|
-
expectedDaemonId: string;
|
|
33
|
-
expectedResponseUserId: string;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Decrypt the pairing response. Returns null on any failure (bad format,
|
|
37
|
-
* decryption mismatch, malformed JSON, AAD mismatch) — the caller surfaces
|
|
38
|
-
* a uniform "decrypt failed" error to the user. See
|
|
39
|
-
* `docs/daemon-pair-protocol.md` for the byte layout.
|
|
40
|
-
*
|
|
41
|
-
* Bundle layout (mobile-encoded):
|
|
42
|
-
* nonce (24 bytes) || mobilePublicKey (32 bytes) || ciphertext
|
|
43
|
-
* || lenAad (2 bytes, big-endian) || aad
|
|
44
|
-
*
|
|
45
|
-
* The trailing `lenAad || aad` is XSalsa20-Poly1305's "no native AAD slot"
|
|
46
|
-
* workaround (C4): the canonical AAD is sealed alongside the ciphertext so
|
|
47
|
-
* any tampered/misdirected payload fails verification BEFORE tweetnacl
|
|
48
|
-
* sees a single byte of plaintext. The CLI rebuilds the AAD locally from
|
|
49
|
-
* `expectedDaemonId` + `expectedResponseUserId` and constant-time compares
|
|
50
|
-
* — a leaked QR + race attack that swapped the daemonId at the server gets
|
|
51
|
-
* rejected here.
|
|
52
|
-
*/
|
|
53
|
-
export declare function decryptResponse(responseB64: string, cliSecretKey: Uint8Array, opts?: DecryptOptions): DecryptedPlaintext | null;
|
|
54
|
-
/**
|
|
55
|
-
* Persist the pairing credentials. Token + refreshToken go to the
|
|
56
|
-
* canonical auth backend (keychain or file); the optional
|
|
57
|
-
* dataEncryptionKey is preserved alongside (C4) — until Tier 1 #1's
|
|
58
|
-
* mobile half ships, this field will typically be null and the daemon
|
|
59
|
-
* derives its machine-DEK locally instead.
|
|
60
|
-
*
|
|
61
|
-
* Exported for tests so the persistence path can be exercised without
|
|
62
|
-
* spinning the full oclif runtime.
|
|
63
|
-
*/
|
|
64
|
-
export declare function persistPairCredentials(plaintext: DecryptedPlaintext, serverOrigin: string): void;
|
|
65
|
-
/** base64url encode (no padding) — what the protocol uses for publicKey. */
|
|
66
|
-
export declare function base64urlEncode(bytes: Uint8Array): string;
|
|
67
|
-
export {};
|
package/dist/commands/pair.js
DELETED
|
@@ -1,351 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.buildPairAad = buildPairAad;
|
|
7
|
-
exports.decryptResponse = decryptResponse;
|
|
8
|
-
exports.persistPairCredentials = persistPairCredentials;
|
|
9
|
-
exports.base64urlEncode = base64urlEncode;
|
|
10
|
-
const core_1 = require("@oclif/core");
|
|
11
|
-
const tweetnacl_1 = __importDefault(require("tweetnacl"));
|
|
12
|
-
const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
|
|
13
|
-
const auth_storage_1 = require("../lib/auth-storage");
|
|
14
|
-
const base_command_1 = require("../base-command");
|
|
15
|
-
const api_fetch_1 = require("../lib/api-fetch");
|
|
16
|
-
const PUBLIC_KEY_BYTES = tweetnacl_1.default.box.publicKeyLength; // 32
|
|
17
|
-
const SECRET_KEY_BYTES = tweetnacl_1.default.box.secretKeyLength; // 32
|
|
18
|
-
const NONCE_BYTES = tweetnacl_1.default.box.nonceLength; // 24
|
|
19
|
-
// H16 — feature-flag check evaluated AT MODULE LOAD so oclif sees a
|
|
20
|
-
// stable `hidden` value when it builds the manifest. Toggling at
|
|
21
|
-
// runtime would require a manifest rebuild anyway; capturing once
|
|
22
|
-
// matches oclif's static-class-field model.
|
|
23
|
-
const PAIR_FEATURE_ENABLED = process.env.OVERSKY_ENABLE_PAIR === '1';
|
|
24
|
-
/**
|
|
25
|
-
* Canonical AAD bytes for the pair-response (C4). Mobile builds the
|
|
26
|
-
* same string, ASCII-encodes, prepends a 2-byte big-endian length, and
|
|
27
|
-
* appends to the ciphertext bundle. The CLI rebuilds locally and
|
|
28
|
-
* compares before decrypting — so a tampered or misdirected ciphertext
|
|
29
|
-
* fails AAD before tweetnacl ever sees it.
|
|
30
|
-
*
|
|
31
|
-
* Format: `oversky:pair:v1:daemonId=<daemonId>:responseUserId=<responseUserId>`
|
|
32
|
-
*
|
|
33
|
-
* The version tag (`v1`) lets us evolve the AAD shape later without
|
|
34
|
-
* silently accepting old payloads.
|
|
35
|
-
*/
|
|
36
|
-
function buildPairAad(daemonId, responseUserId) {
|
|
37
|
-
const canonical = `oversky:pair:v1:daemonId=${daemonId}:responseUserId=${responseUserId}`;
|
|
38
|
-
return new TextEncoder().encode(canonical);
|
|
39
|
-
}
|
|
40
|
-
class Pair extends base_command_1.BaseCommand {
|
|
41
|
-
static description = PAIR_FEATURE_ENABLED
|
|
42
|
-
? 'Pair this daemon to a skrr account via the mobile app.'
|
|
43
|
-
: "Pair this daemon to a skrr account via the mobile app (mobile app integration pending; use 'skrr login' for now).";
|
|
44
|
-
// H16 — hide from `--help` output unless the feature flag is on at
|
|
45
|
-
// module load. oclif consults this static field when generating the
|
|
46
|
-
// top-level command list.
|
|
47
|
-
static hidden = !PAIR_FEATURE_ENABLED;
|
|
48
|
-
static examples = ['<%= config.bin %> pair', '<%= config.bin %> pair --no-qr'];
|
|
49
|
-
static flags = {
|
|
50
|
-
'no-qr': core_1.Flags.boolean({
|
|
51
|
-
description: 'Skip the QR rendering and only print the pairing URL.',
|
|
52
|
-
default: false,
|
|
53
|
-
}),
|
|
54
|
-
'poll-timeout': core_1.Flags.integer({
|
|
55
|
-
description: 'Maximum seconds to poll before giving up (default: 300).',
|
|
56
|
-
default: 300,
|
|
57
|
-
}),
|
|
58
|
-
};
|
|
59
|
-
async run() {
|
|
60
|
-
// H16 — runtime gate. Even if the user discovered the command name,
|
|
61
|
-
// we explicitly bail with a clear message and `EX_USAGE` (64) so
|
|
62
|
-
// CI scripts can distinguish "user error" from "command failed".
|
|
63
|
-
if (!PAIR_FEATURE_ENABLED) {
|
|
64
|
-
this.error('`skrr pair` is not yet available — mobile app integration is pending. Use `oversky login` instead.', { exit: 64 });
|
|
65
|
-
}
|
|
66
|
-
const { flags } = await this.parse(Pair);
|
|
67
|
-
// Step 1 — generate the CLI's ephemeral X25519 box keypair.
|
|
68
|
-
const keypair = tweetnacl_1.default.box.keyPair();
|
|
69
|
-
const publicKeyB64url = base64urlEncode(keypair.publicKey);
|
|
70
|
-
// Step 2 — ask the server to open a pairing window.
|
|
71
|
-
let start;
|
|
72
|
-
try {
|
|
73
|
-
start = await (0, api_fetch_1.apiFetch)('/api/auth/pair/start', {
|
|
74
|
-
method: 'POST',
|
|
75
|
-
body: { publicKey: publicKeyB64url },
|
|
76
|
-
// The /start endpoint is anonymous; no credential needed. Pass
|
|
77
|
-
// the resolved one anyway — apiFetch tolerates an authed call to
|
|
78
|
-
// an anon endpoint.
|
|
79
|
-
credential: this.resolvedCredential,
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
catch (err) {
|
|
83
|
-
if (err instanceof api_fetch_1.ApiFetchError && err.status === 409) {
|
|
84
|
-
this.error('A pairing request already exists for this CLI session — wait for it to expire or restart the CLI.', { exit: 1 });
|
|
85
|
-
}
|
|
86
|
-
if (err instanceof api_fetch_1.ApiFetchError && err.status === 503) {
|
|
87
|
-
// H5 — global active-row cap. Surface the Retry-After hint.
|
|
88
|
-
this.error('skrr pair-request capacity is temporarily exhausted. Wait ~60s and try `skrr pair` again.', { exit: 1 });
|
|
89
|
-
}
|
|
90
|
-
this.handleApiError(err);
|
|
91
|
-
}
|
|
92
|
-
if (typeof start.pairId !== 'string' ||
|
|
93
|
-
start.pairId.length === 0 ||
|
|
94
|
-
typeof start.pairSecret !== 'string' ||
|
|
95
|
-
start.pairSecret.length === 0) {
|
|
96
|
-
this.error('Server did not return a pairId/pairSecret. Update your skrr server.', {
|
|
97
|
-
exit: 1,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
const expiresAt = new Date(start.expiresAt);
|
|
101
|
-
const pairingUrl = `oversky://pair?pk=${start.publicKey}`;
|
|
102
|
-
// Step 3 — render the QR + URL.
|
|
103
|
-
process.stderr.write('\n');
|
|
104
|
-
process.stderr.write(' Open the skrr mobile app and scan this QR code to pair this daemon:\n');
|
|
105
|
-
process.stderr.write('\n');
|
|
106
|
-
if (!flags['no-qr']) {
|
|
107
|
-
const qr = renderQr(pairingUrl);
|
|
108
|
-
if (qr)
|
|
109
|
-
process.stderr.write(qr);
|
|
110
|
-
}
|
|
111
|
-
process.stderr.write(' Or open this URL on your phone:\n');
|
|
112
|
-
process.stderr.write(` ${pairingUrl}\n`);
|
|
113
|
-
process.stderr.write('\n');
|
|
114
|
-
process.stderr.write(` Pairing window expires at ${expiresAt.toISOString()}.\n`);
|
|
115
|
-
process.stderr.write('\n');
|
|
116
|
-
process.stderr.write(' Waiting for approval...\n');
|
|
117
|
-
// Step 4 — poll for the response.
|
|
118
|
-
const pollIntervalMs = Math.max(500, start.pollIntervalMs || 1000);
|
|
119
|
-
const pollDeadline = Math.min(Date.now() + flags['poll-timeout'] * 1000, expiresAt.getTime());
|
|
120
|
-
let pollResult = null;
|
|
121
|
-
while (Date.now() < pollDeadline) {
|
|
122
|
-
await sleep(pollIntervalMs);
|
|
123
|
-
try {
|
|
124
|
-
const res = await (0, api_fetch_1.apiFetch)('/api/auth/pair/poll', {
|
|
125
|
-
method: 'POST',
|
|
126
|
-
// H5 — authenticate by holder-of-secret, not holder-of-publicKey.
|
|
127
|
-
// The pairSecret was returned to us once on /start; the server
|
|
128
|
-
// stored only its hash and compares constant-time.
|
|
129
|
-
body: { pairId: start.pairId, pairSecret: start.pairSecret },
|
|
130
|
-
credential: this.resolvedCredential,
|
|
131
|
-
});
|
|
132
|
-
if (res.state === 'authorized') {
|
|
133
|
-
pollResult = res;
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
if (res.state === 'expired') {
|
|
137
|
-
this.error('Pairing window expired before the mobile app responded. Run `skrr pair` again.', {
|
|
138
|
-
exit: 1,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
// 'pending' — keep polling.
|
|
142
|
-
}
|
|
143
|
-
catch (err) {
|
|
144
|
-
if (err instanceof api_fetch_1.ApiFetchError && err.status === 429) {
|
|
145
|
-
// Back off doubly on rate limit.
|
|
146
|
-
await sleep(pollIntervalMs);
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
this.handleApiError(err);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (!pollResult || !pollResult.response) {
|
|
153
|
-
this.error('Pairing timed out without a response. Run `skrr pair` again.', { exit: 1 });
|
|
154
|
-
}
|
|
155
|
-
// Step 5 — verify AAD, then decrypt the response (C4).
|
|
156
|
-
if (!pollResult.daemonId || !pollResult.responseUserId) {
|
|
157
|
-
this.error('Pairing response missing daemonId or responseUserId — refusing to decrypt without AAD context.', { exit: 1 });
|
|
158
|
-
}
|
|
159
|
-
const plaintext = decryptResponse(pollResult.response, keypair.secretKey, {
|
|
160
|
-
expectedDaemonId: pollResult.daemonId,
|
|
161
|
-
expectedResponseUserId: pollResult.responseUserId,
|
|
162
|
-
});
|
|
163
|
-
if (!plaintext) {
|
|
164
|
-
this.error('Pairing response failed to decrypt — the mobile app may be sending a malformed payload, or the AAD did not match.', { exit: 1 });
|
|
165
|
-
}
|
|
166
|
-
// Step 6 — install the credentials via the canonical token-save path.
|
|
167
|
-
// C4 — persist the dataEncryptionKey when the mobile app sends one.
|
|
168
|
-
// Until Tier 1 #1's mobile half ships, the field will be null and
|
|
169
|
-
// the daemon's machine-DEK path takes over instead.
|
|
170
|
-
persistPairCredentials(plaintext, this.cliConfig.baseURL);
|
|
171
|
-
process.stderr.write('\n');
|
|
172
|
-
process.stderr.write(' Pairing complete. Daemon ready.\n');
|
|
173
|
-
if (plaintext.daemonId) {
|
|
174
|
-
process.stderr.write(` Daemon ID: ${plaintext.daemonId}\n`);
|
|
175
|
-
}
|
|
176
|
-
if (plaintext.dataEncryptionKey) {
|
|
177
|
-
process.stderr.write(' Data encryption key installed.\n');
|
|
178
|
-
}
|
|
179
|
-
process.stderr.write('\n');
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
exports.default = Pair;
|
|
183
|
-
/**
|
|
184
|
-
* Decrypt the pairing response. Returns null on any failure (bad format,
|
|
185
|
-
* decryption mismatch, malformed JSON, AAD mismatch) — the caller surfaces
|
|
186
|
-
* a uniform "decrypt failed" error to the user. See
|
|
187
|
-
* `docs/daemon-pair-protocol.md` for the byte layout.
|
|
188
|
-
*
|
|
189
|
-
* Bundle layout (mobile-encoded):
|
|
190
|
-
* nonce (24 bytes) || mobilePublicKey (32 bytes) || ciphertext
|
|
191
|
-
* || lenAad (2 bytes, big-endian) || aad
|
|
192
|
-
*
|
|
193
|
-
* The trailing `lenAad || aad` is XSalsa20-Poly1305's "no native AAD slot"
|
|
194
|
-
* workaround (C4): the canonical AAD is sealed alongside the ciphertext so
|
|
195
|
-
* any tampered/misdirected payload fails verification BEFORE tweetnacl
|
|
196
|
-
* sees a single byte of plaintext. The CLI rebuilds the AAD locally from
|
|
197
|
-
* `expectedDaemonId` + `expectedResponseUserId` and constant-time compares
|
|
198
|
-
* — a leaked QR + race attack that swapped the daemonId at the server gets
|
|
199
|
-
* rejected here.
|
|
200
|
-
*/
|
|
201
|
-
function decryptResponse(responseB64, cliSecretKey, opts) {
|
|
202
|
-
if (typeof responseB64 !== 'string' || responseB64.length === 0)
|
|
203
|
-
return null;
|
|
204
|
-
if (cliSecretKey.length !== SECRET_KEY_BYTES)
|
|
205
|
-
return null;
|
|
206
|
-
let bundle;
|
|
207
|
-
try {
|
|
208
|
-
bundle = base64Decode(responseB64);
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
if (bundle.length < NONCE_BYTES + PUBLIC_KEY_BYTES + 1)
|
|
214
|
-
return null;
|
|
215
|
-
let cipher;
|
|
216
|
-
let trailerAad = null;
|
|
217
|
-
if (opts) {
|
|
218
|
-
// C4 — bundle MUST carry a trailing `lenAad || aad`. The 2-byte
|
|
219
|
-
// length is big-endian unsigned — caps AAD at 65535 bytes (way
|
|
220
|
-
// more headroom than the canonical AAD ever needs).
|
|
221
|
-
const expectedAad = buildPairAad(opts.expectedDaemonId, opts.expectedResponseUserId);
|
|
222
|
-
if (bundle.length < NONCE_BYTES + PUBLIC_KEY_BYTES + 2 + 1)
|
|
223
|
-
return null;
|
|
224
|
-
const lenOffset = bundle.length - expectedAad.length - 2;
|
|
225
|
-
if (lenOffset < NONCE_BYTES + PUBLIC_KEY_BYTES + 1)
|
|
226
|
-
return null;
|
|
227
|
-
const lenAad = (bundle[lenOffset] << 8) | bundle[lenOffset + 1];
|
|
228
|
-
if (lenAad !== expectedAad.length)
|
|
229
|
-
return null;
|
|
230
|
-
const trailerStart = lenOffset;
|
|
231
|
-
const aadStart = lenOffset + 2;
|
|
232
|
-
if (trailerStart < NONCE_BYTES + PUBLIC_KEY_BYTES + 1)
|
|
233
|
-
return null;
|
|
234
|
-
trailerAad = bundle.slice(aadStart, aadStart + lenAad);
|
|
235
|
-
if (!constantTimeBytesEqual(trailerAad, expectedAad)) {
|
|
236
|
-
// AAD mismatch — refuse to decrypt. Do NOT log or surface the
|
|
237
|
-
// mismatch shape; uniform null fits the "uniform terminal state"
|
|
238
|
-
// model the rest of the command relies on.
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
cipher = bundle.slice(NONCE_BYTES + PUBLIC_KEY_BYTES, trailerStart);
|
|
242
|
-
}
|
|
243
|
-
else {
|
|
244
|
-
cipher = bundle.slice(NONCE_BYTES + PUBLIC_KEY_BYTES);
|
|
245
|
-
}
|
|
246
|
-
const nonce = bundle.slice(0, NONCE_BYTES);
|
|
247
|
-
const mobilePublicKey = bundle.slice(NONCE_BYTES, NONCE_BYTES + PUBLIC_KEY_BYTES);
|
|
248
|
-
const plaintextBytes = tweetnacl_1.default.box.open(cipher, nonce, mobilePublicKey, cliSecretKey);
|
|
249
|
-
if (!plaintextBytes)
|
|
250
|
-
return null;
|
|
251
|
-
let json;
|
|
252
|
-
try {
|
|
253
|
-
json = JSON.parse(Buffer.from(plaintextBytes).toString('utf8'));
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
if (!isValidPlaintext(json))
|
|
259
|
-
return null;
|
|
260
|
-
return json;
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* Persist the pairing credentials. Token + refreshToken go to the
|
|
264
|
-
* canonical auth backend (keychain or file); the optional
|
|
265
|
-
* dataEncryptionKey is preserved alongside (C4) — until Tier 1 #1's
|
|
266
|
-
* mobile half ships, this field will typically be null and the daemon
|
|
267
|
-
* derives its machine-DEK locally instead.
|
|
268
|
-
*
|
|
269
|
-
* Exported for tests so the persistence path can be exercised without
|
|
270
|
-
* spinning the full oclif runtime.
|
|
271
|
-
*/
|
|
272
|
-
function persistPairCredentials(plaintext, serverOrigin) {
|
|
273
|
-
(0, auth_storage_1.writeToBackend)({
|
|
274
|
-
token: plaintext.token,
|
|
275
|
-
...(plaintext.refreshToken ? { refreshToken: plaintext.refreshToken } : {}),
|
|
276
|
-
}, { serverOrigin, clearReauth: true, provenance: 'user_login' });
|
|
277
|
-
if (plaintext.dataEncryptionKey) {
|
|
278
|
-
// The unified `writeToBackend` API only carries token+refresh
|
|
279
|
-
// today. For now, persist the DEK as a sibling field on the next
|
|
280
|
-
// bundle write so it survives a CLI restart. When the daemon
|
|
281
|
-
// machine-DEK seam lands, this is the call site that flips to the
|
|
282
|
-
// L12 Keychain helper.
|
|
283
|
-
const existing = (0, auth_storage_1.readFromBackend)(serverOrigin);
|
|
284
|
-
if (existing.bundle) {
|
|
285
|
-
(0, auth_storage_1.writeToBackend)({
|
|
286
|
-
...existing.bundle,
|
|
287
|
-
// dataEncryptionKey is not in the AuthBundle type yet; this is
|
|
288
|
-
// the deliberate seam for the daemon-side L12 path. We append
|
|
289
|
-
// through a typed cast so the bundle survives a round-trip.
|
|
290
|
-
...{ dataEncryptionKey: plaintext.dataEncryptionKey },
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
// TODO: mobile DEK not yet available — daemon machine-DEK path will
|
|
295
|
-
// skip this branch entirely until Tier 1 #1's mobile half ships.
|
|
296
|
-
}
|
|
297
|
-
function isValidPlaintext(v) {
|
|
298
|
-
if (!v || typeof v !== 'object')
|
|
299
|
-
return false;
|
|
300
|
-
const o = v;
|
|
301
|
-
if (typeof o.token !== 'string' || o.token.length === 0)
|
|
302
|
-
return false;
|
|
303
|
-
if (typeof o.refreshToken !== 'string' || o.refreshToken.length === 0)
|
|
304
|
-
return false;
|
|
305
|
-
if (o.daemonId !== null && typeof o.daemonId !== 'string')
|
|
306
|
-
return false;
|
|
307
|
-
if (o.dataEncryptionKey !== null && typeof o.dataEncryptionKey !== 'string')
|
|
308
|
-
return false;
|
|
309
|
-
return true;
|
|
310
|
-
}
|
|
311
|
-
/** base64url encode (no padding) — what the protocol uses for publicKey. */
|
|
312
|
-
function base64urlEncode(bytes) {
|
|
313
|
-
return Buffer.from(bytes)
|
|
314
|
-
.toString('base64')
|
|
315
|
-
.replace(/\+/g, '-')
|
|
316
|
-
.replace(/\//g, '_')
|
|
317
|
-
.replace(/=+$/, '');
|
|
318
|
-
}
|
|
319
|
-
/** Standard base64 decode — what the protocol uses for the response bundle. */
|
|
320
|
-
function base64Decode(s) {
|
|
321
|
-
// Permissive: the protocol says base64 (with padding), but a well-meaning
|
|
322
|
-
// mobile-side base64url encoder would also work. Accept both.
|
|
323
|
-
const normalized = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
324
|
-
const buf = Buffer.from(normalized, 'base64');
|
|
325
|
-
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
326
|
-
}
|
|
327
|
-
/** Constant-time Uint8Array equality. */
|
|
328
|
-
function constantTimeBytesEqual(a, b) {
|
|
329
|
-
if (a.length !== b.length)
|
|
330
|
-
return false;
|
|
331
|
-
let diff = 0;
|
|
332
|
-
for (let i = 0; i < a.length; i++) {
|
|
333
|
-
diff |= a[i] ^ b[i];
|
|
334
|
-
}
|
|
335
|
-
return diff === 0;
|
|
336
|
-
}
|
|
337
|
-
function renderQr(text) {
|
|
338
|
-
try {
|
|
339
|
-
let out = '';
|
|
340
|
-
qrcode_terminal_1.default.generate(text, { small: true }, (rendered) => {
|
|
341
|
-
out = rendered;
|
|
342
|
-
});
|
|
343
|
-
return out;
|
|
344
|
-
}
|
|
345
|
-
catch {
|
|
346
|
-
return '';
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
function sleep(ms) {
|
|
350
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
351
|
-
}
|