@shipvo/cli 0.1.5 → 0.1.6
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 +3 -0
- package/dist/adopt.js +1 -2
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +68 -9
- package/dist/approvals.d.ts +9 -0
- package/dist/approvals.js +106 -0
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +28 -11
- package/dist/autohalt.d.ts +1 -1
- package/dist/autohalt.js +28 -16
- package/dist/bsdiff.d.ts +4 -0
- package/dist/bsdiff.js +465 -0
- package/dist/ci-comment.d.ts +38 -0
- package/dist/ci-comment.js +169 -0
- package/dist/ci-compare.d.ts +10 -0
- package/dist/ci-compare.js +25 -0
- package/dist/ci-shots.d.ts +9 -0
- package/dist/ci-shots.js +81 -0
- package/dist/ci.d.ts +8 -0
- package/dist/ci.js +64 -6
- package/dist/cli.js +23 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.js +43 -3
- package/dist/doctor.js +63 -7
- package/dist/ensure-playwright.d.ts +4 -0
- package/dist/ensure-playwright.js +7 -2
- package/dist/explore-issues.d.ts +12 -0
- package/dist/explore-issues.js +50 -0
- package/dist/explore.d.ts +83 -7
- package/dist/explore.js +326 -77
- package/dist/flows.d.ts +31 -0
- package/dist/flows.js +264 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +19 -10
- package/dist/hermes.d.ts +10 -0
- package/dist/hermes.js +50 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +41 -6
- package/dist/json-out.d.ts +53 -0
- package/dist/json-out.js +91 -0
- package/dist/keys-json.d.ts +1 -0
- package/dist/keys-json.js +71 -0
- package/dist/login.js +27 -11
- package/dist/lumi.d.ts +104 -0
- package/dist/lumi.js +364 -0
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +521 -0
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +4 -2
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +38 -16
- package/dist/publish.d.ts +14 -2
- package/dist/publish.js +126 -62
- package/dist/qr.d.ts +9 -0
- package/dist/qr.js +213 -0
- package/dist/report-md.d.ts +1 -0
- package/dist/report-md.js +1 -1
- package/dist/rotation.d.ts +57 -0
- package/dist/rotation.js +157 -0
- package/dist/run-bridge.js +356 -2
- package/dist/run-net.d.ts +39 -0
- package/dist/run-net.js +266 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +101 -17
- package/dist/signing.js +13 -0
- package/dist/sim.js +7 -3
- package/dist/smoke.d.ts +28 -0
- package/dist/smoke.js +117 -0
- package/dist/spawn-bin.d.ts +11 -0
- package/dist/spawn-bin.js +21 -0
- package/dist/status.d.ts +26 -4
- package/dist/status.js +69 -5
- package/dist/templates/github-action/shipvo-check.yml +18 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.js +81 -18
- package/package.json +4 -3
- package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/qr.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// A small QR code encoder: byte mode, error correction level L, versions 1 to 6 (up to 134 bytes). No dependencies.
|
|
2
|
+
// Enough for a LAN URL. `qrMatrix` returns the modules; `qrToTerminal` draws them with half-block characters.
|
|
3
|
+
const SIZES = [
|
|
4
|
+
[19, 7, 1], [34, 10, 1], [55, 15, 1], [80, 20, 1], [108, 26, 1], [136, 18, 2],
|
|
5
|
+
];
|
|
6
|
+
const ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34]];
|
|
7
|
+
export const QR_MAX_BYTES = 134;
|
|
8
|
+
const EXP = new Uint8Array(512);
|
|
9
|
+
const LOG = new Uint8Array(256);
|
|
10
|
+
for (let i = 0, x = 1; i < 255; i++) {
|
|
11
|
+
EXP[i] = x;
|
|
12
|
+
LOG[x] = i;
|
|
13
|
+
x <<= 1;
|
|
14
|
+
if (x & 256)
|
|
15
|
+
x ^= 0x11d;
|
|
16
|
+
}
|
|
17
|
+
for (let i = 255; i < 512; i++)
|
|
18
|
+
EXP[i] = EXP[i - 255];
|
|
19
|
+
const mul = (a, b) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);
|
|
20
|
+
/** Reed-Solomon error codewords for `data`. */
|
|
21
|
+
export function rsRemainder(data, degree) {
|
|
22
|
+
let gen = [1];
|
|
23
|
+
for (let i = 0; i < degree; i++) {
|
|
24
|
+
const next = new Array(gen.length + 1).fill(0);
|
|
25
|
+
gen.forEach((c, j) => { next[j] ^= c; next[j + 1] ^= mul(c, EXP[i]); });
|
|
26
|
+
gen = next;
|
|
27
|
+
}
|
|
28
|
+
const rem = new Array(degree).fill(0);
|
|
29
|
+
for (const b of data) {
|
|
30
|
+
const f = b ^ rem.shift();
|
|
31
|
+
rem.push(0);
|
|
32
|
+
for (let i = 0; i < degree; i++)
|
|
33
|
+
rem[i] ^= mul(gen[i + 1], f);
|
|
34
|
+
}
|
|
35
|
+
return rem;
|
|
36
|
+
}
|
|
37
|
+
function codewordsFor(bytes, version) {
|
|
38
|
+
const [total, ecc, blocks] = SIZES[version - 1];
|
|
39
|
+
const bits = [];
|
|
40
|
+
const put = (v, n) => { for (let i = n - 1; i >= 0; i--)
|
|
41
|
+
bits.push((v >>> i) & 1); };
|
|
42
|
+
put(4, 4);
|
|
43
|
+
put(bytes.length, 8);
|
|
44
|
+
for (const b of bytes)
|
|
45
|
+
put(b, 8);
|
|
46
|
+
put(0, Math.min(4, total * 8 - bits.length));
|
|
47
|
+
while (bits.length % 8)
|
|
48
|
+
bits.push(0);
|
|
49
|
+
const data = [];
|
|
50
|
+
for (let i = 0; i < bits.length; i += 8)
|
|
51
|
+
data.push(parseInt(bits.slice(i, i + 8).join(""), 2));
|
|
52
|
+
for (let pad = 0xec; data.length < total; pad ^= 0xec ^ 0x11)
|
|
53
|
+
data.push(pad);
|
|
54
|
+
const per = total / blocks;
|
|
55
|
+
const dataBlocks = Array.from({ length: blocks }, (_, i) => data.slice(i * per, (i + 1) * per));
|
|
56
|
+
const eccBlocks = dataBlocks.map((b) => rsRemainder(b, ecc));
|
|
57
|
+
const out = [];
|
|
58
|
+
for (let i = 0; i < per; i++)
|
|
59
|
+
for (const b of dataBlocks)
|
|
60
|
+
out.push(b[i]);
|
|
61
|
+
for (let i = 0; i < ecc; i++)
|
|
62
|
+
for (const b of eccBlocks)
|
|
63
|
+
out.push(b[i]);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
const MASKS = [
|
|
67
|
+
(x, y) => (x + y) % 2 === 0, (_x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0,
|
|
68
|
+
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
69
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => ((((x + y) % 2) + ((x * y) % 3)) % 2) === 0,
|
|
70
|
+
];
|
|
71
|
+
/** The 15 format bits for level L and a mask number. */
|
|
72
|
+
export function formatBits(mask) {
|
|
73
|
+
const data = (1 << 3) | mask; // level L is 01
|
|
74
|
+
let rem = data;
|
|
75
|
+
for (let i = 0; i < 10; i++)
|
|
76
|
+
rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
77
|
+
return ((data << 10) | rem) ^ 0x5412;
|
|
78
|
+
}
|
|
79
|
+
function penalty(m) {
|
|
80
|
+
const n = m.length;
|
|
81
|
+
let score = 0;
|
|
82
|
+
let dark = 0;
|
|
83
|
+
const line = (get) => {
|
|
84
|
+
let run = 1;
|
|
85
|
+
for (let i = 1; i <= n; i++) {
|
|
86
|
+
if (i < n && get(i) === get(i - 1))
|
|
87
|
+
run++;
|
|
88
|
+
else {
|
|
89
|
+
if (run >= 5)
|
|
90
|
+
score += run - 2;
|
|
91
|
+
run = 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const s = Array.from({ length: n }, (_, i) => (get(i) ? 1 : 0)).join("");
|
|
95
|
+
for (const p of ["10111010000", "00001011101"])
|
|
96
|
+
for (let i = s.indexOf(p); i >= 0; i = s.indexOf(p, i + 1))
|
|
97
|
+
score += 40;
|
|
98
|
+
};
|
|
99
|
+
for (let a = 0; a < n; a++) {
|
|
100
|
+
line((i) => m[a][i]);
|
|
101
|
+
line((i) => m[i][a]);
|
|
102
|
+
}
|
|
103
|
+
for (let y = 0; y < n; y++)
|
|
104
|
+
for (let x = 0; x < n; x++) {
|
|
105
|
+
if (m[y][x])
|
|
106
|
+
dark++;
|
|
107
|
+
if (x + 1 < n && y + 1 < n && m[y][x] === m[y][x + 1] && m[y][x] === m[y + 1][x] && m[y][x] === m[y + 1][x + 1])
|
|
108
|
+
score += 3;
|
|
109
|
+
}
|
|
110
|
+
return score + (Math.ceil(Math.abs(dark * 20 - n * n * 10) / (n * n)) - 1) * 10;
|
|
111
|
+
}
|
|
112
|
+
/** Encodes text as a QR code. Returns rows of modules, true for dark. Throws when the text is over 134 bytes. */
|
|
113
|
+
export function qrMatrix(text) {
|
|
114
|
+
const bytes = new TextEncoder().encode(text);
|
|
115
|
+
const version = SIZES.findIndex(([total]) => bytes.length <= total - 2) + 1;
|
|
116
|
+
if (!version)
|
|
117
|
+
throw new Error(`QR code input is ${bytes.length} bytes; the limit is ${QR_MAX_BYTES}.`);
|
|
118
|
+
const size = 17 + 4 * version;
|
|
119
|
+
const codewords = codewordsFor(bytes, version);
|
|
120
|
+
const base = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
121
|
+
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
122
|
+
const set = (x, y, dark) => { if (x >= 0 && y >= 0 && x < size && y < size) {
|
|
123
|
+
base[y][x] = dark;
|
|
124
|
+
fn[y][x] = true;
|
|
125
|
+
} };
|
|
126
|
+
for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
|
|
127
|
+
for (let dy = -4; dy <= 4; dy++)
|
|
128
|
+
for (let dx = -4; dx <= 4; dx++) {
|
|
129
|
+
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
|
130
|
+
set(cx + dx, cy + dy, d !== 4 && d !== 2);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (let i = 8; i < size - 8; i++) {
|
|
134
|
+
set(i, 6, i % 2 === 0);
|
|
135
|
+
set(6, i, i % 2 === 0);
|
|
136
|
+
}
|
|
137
|
+
const pos = ALIGN[version - 1];
|
|
138
|
+
for (const cy of pos)
|
|
139
|
+
for (const cx of pos) {
|
|
140
|
+
if ((cx === 6 && cy === 6) || (cx === 6 && cy === size - 7) || (cx === size - 7 && cy === 6))
|
|
141
|
+
continue;
|
|
142
|
+
for (let dy = -2; dy <= 2; dy++)
|
|
143
|
+
for (let dx = -2; dx <= 2; dx++)
|
|
144
|
+
set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
145
|
+
}
|
|
146
|
+
const format = (mask) => {
|
|
147
|
+
const f = formatBits(mask);
|
|
148
|
+
const bit = (i) => ((f >>> i) & 1) === 1;
|
|
149
|
+
for (let i = 0; i <= 5; i++)
|
|
150
|
+
set(8, i, bit(i));
|
|
151
|
+
set(8, 7, bit(6));
|
|
152
|
+
set(8, 8, bit(7));
|
|
153
|
+
set(7, 8, bit(8));
|
|
154
|
+
for (let i = 9; i < 15; i++)
|
|
155
|
+
set(14 - i, 8, bit(i));
|
|
156
|
+
for (let i = 0; i < 8; i++)
|
|
157
|
+
set(size - 1 - i, 8, bit(i));
|
|
158
|
+
for (let i = 8; i < 15; i++)
|
|
159
|
+
set(8, size - 15 + i, bit(i));
|
|
160
|
+
set(8, size - 8, true);
|
|
161
|
+
};
|
|
162
|
+
format(0); // reserves the areas; redone below with the chosen mask
|
|
163
|
+
let k = 0;
|
|
164
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
165
|
+
if (right === 6)
|
|
166
|
+
right = 5;
|
|
167
|
+
for (let v = 0; v < size; v++)
|
|
168
|
+
for (let j = 0; j < 2; j++) {
|
|
169
|
+
const x = right - j;
|
|
170
|
+
const y = ((right + 1) & 2) === 0 ? size - 1 - v : v;
|
|
171
|
+
if (!fn[y][x] && k < codewords.length * 8) {
|
|
172
|
+
base[y][x] = ((codewords[k >> 3] >>> (7 - (k & 7))) & 1) === 1;
|
|
173
|
+
k++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
let best;
|
|
178
|
+
let bestScore = Infinity;
|
|
179
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
180
|
+
const m = base.map((row) => row.slice());
|
|
181
|
+
for (let y = 0; y < size; y++)
|
|
182
|
+
for (let x = 0; x < size; x++)
|
|
183
|
+
if (!fn[y][x] && MASKS[mask](x, y))
|
|
184
|
+
m[y][x] = !m[y][x];
|
|
185
|
+
const saveBase = base.map((r) => r.slice());
|
|
186
|
+
for (let y = 0; y < size; y++)
|
|
187
|
+
base[y] = m[y];
|
|
188
|
+
format(mask);
|
|
189
|
+
const s = penalty(base);
|
|
190
|
+
if (s < bestScore) {
|
|
191
|
+
bestScore = s;
|
|
192
|
+
best = base.map((r) => r.slice());
|
|
193
|
+
}
|
|
194
|
+
for (let y = 0; y < size; y++)
|
|
195
|
+
base[y] = saveBase[y];
|
|
196
|
+
}
|
|
197
|
+
return best;
|
|
198
|
+
}
|
|
199
|
+
/** Draws the matrix for a terminal: black on white with ANSI colours, two rows per line, a 2 module quiet zone. */
|
|
200
|
+
export function qrToTerminal(matrix, quiet = 2) {
|
|
201
|
+
const n = matrix.length + quiet * 2;
|
|
202
|
+
const at = (x, y) => matrix[y - quiet]?.[x - quiet] === true;
|
|
203
|
+
const lines = [];
|
|
204
|
+
for (let y = 0; y < n; y += 2) {
|
|
205
|
+
let s = "\x1b[30;47m";
|
|
206
|
+
for (let x = 0; x < n; x++) {
|
|
207
|
+
const t = at(x, y), b = y + 1 < n && at(x, y + 1);
|
|
208
|
+
s += t && b ? "█" : t ? "▀" : b ? "▄" : " ";
|
|
209
|
+
}
|
|
210
|
+
lines.push(s + "\x1b[0m");
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|
package/dist/report-md.d.ts
CHANGED
package/dist/report-md.js
CHANGED
|
@@ -5,7 +5,7 @@ function code(s) {
|
|
|
5
5
|
const fence = "`".repeat(Math.max(0, ...[...t.matchAll(/`+/g)].map((m) => m[0].length)) + 1);
|
|
6
6
|
return `${fence}${t.startsWith("`") || t.endsWith("`") ? ` ${t} ` : t}${fence}`;
|
|
7
7
|
}
|
|
8
|
-
function stepText(s) {
|
|
8
|
+
export function stepText(s) {
|
|
9
9
|
const kind = s.kind ?? "step";
|
|
10
10
|
if (kind === "tap")
|
|
11
11
|
return `tap "${s.target ?? ""}"`;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type RotationEnvelope = {
|
|
2
|
+
body: string;
|
|
3
|
+
signature: string;
|
|
4
|
+
};
|
|
5
|
+
export type RotationStatement = {
|
|
6
|
+
type: "key-rotation";
|
|
7
|
+
appId: string;
|
|
8
|
+
sequence: number;
|
|
9
|
+
newKey: string;
|
|
10
|
+
validFrom: string;
|
|
11
|
+
};
|
|
12
|
+
export type RotationFailure = "bad_envelope" | "bad_signature" | "bad_body" | "wrong_type" | "wrong_app" | "bad_sequence" | "replay" | "too_far" | "bad_key";
|
|
13
|
+
export type RotationResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
statement: RotationStatement;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: RotationFailure;
|
|
19
|
+
};
|
|
20
|
+
export declare const MAX_BODY_BYTES = 1024;
|
|
21
|
+
export declare const MAX_PENDING_DAYS = 90;
|
|
22
|
+
/** The body string. Key order is fixed; the signer keeps these exact bytes. */
|
|
23
|
+
export declare function buildStatementBody(s: {
|
|
24
|
+
appId: string;
|
|
25
|
+
sequence: number;
|
|
26
|
+
newKey: string;
|
|
27
|
+
validFrom: string;
|
|
28
|
+
}): string;
|
|
29
|
+
/** Sign the exact UTF-8 bytes of `body` with the root seed. */
|
|
30
|
+
export declare function signStatement(s: {
|
|
31
|
+
appId: string;
|
|
32
|
+
sequence: number;
|
|
33
|
+
newKey: string;
|
|
34
|
+
validFrom: string;
|
|
35
|
+
}, rootSeedB64: string): RotationEnvelope;
|
|
36
|
+
/**
|
|
37
|
+
* Check one envelope in the order of design section 8, then section 3.
|
|
38
|
+
* `effectiveSequence` is max(stored sequence, build minimum). `now` is the runtime clock.
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyStatement(env: unknown, rootPublicKeyB64: string, ctx: {
|
|
41
|
+
appId: string;
|
|
42
|
+
effectiveSequence: number;
|
|
43
|
+
now: Date;
|
|
44
|
+
}): RotationResult;
|
|
45
|
+
export declare class RotateError extends Error {
|
|
46
|
+
}
|
|
47
|
+
type Cfg = {
|
|
48
|
+
appId: string;
|
|
49
|
+
controlPlane: string;
|
|
50
|
+
};
|
|
51
|
+
export declare const ROTATE_HELP = "Usage:\n shipvo keys rotate sign --root-seed-file <file> --new-key <public key> --sequence <n> [--valid-from <ISO time>] [--json]\n shipvo keys rotate upload <envelope.json> [--json]\n\nsign works offline. It reads the ROOT seed from a file (never the release seed) and prints the signed envelope.\n--valid-from defaults to now (an emergency rotation). It may be at most 90 days ahead.\nPick a sequence above every statement you signed before. To override a planned statement, use a higher one.\nupload needs a signed-in owner session (shipvo login). Tokens are refused by the control plane.\n";
|
|
52
|
+
/** Runs `shipvo keys rotate ...`. Throws RotateError for user mistakes; the caller prints it and exits 1. */
|
|
53
|
+
export declare function rotate(argv: string[], cfg: Cfg, deps?: {
|
|
54
|
+
fetchFn?: typeof fetch;
|
|
55
|
+
now?: () => Date;
|
|
56
|
+
}): Promise<void>;
|
|
57
|
+
export {};
|
package/dist/rotation.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Root-signed key rotation statements (docs/design/key-rotation.md, sections 3, 6 and 8).
|
|
2
|
+
// `signStatement` and `verifyStatement` follow the wire format that the Android and iOS runtimes will check.
|
|
3
|
+
import * as ed from "@noble/ed25519";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { b64, keyFromSeed, unb64 } from "./index.js";
|
|
6
|
+
import { args } from "./config.js";
|
|
7
|
+
import { savedCreds } from "./login.js";
|
|
8
|
+
export const MAX_BODY_BYTES = 1024;
|
|
9
|
+
export const MAX_PENDING_DAYS = 90;
|
|
10
|
+
const enc = new TextEncoder();
|
|
11
|
+
/** The body string. Key order is fixed; the signer keeps these exact bytes. */
|
|
12
|
+
export function buildStatementBody(s) {
|
|
13
|
+
if (!Number.isSafeInteger(s.sequence) || s.sequence < 1)
|
|
14
|
+
throw new Error("sequence must be an integer, at least 1 and at most 9007199254740991");
|
|
15
|
+
if (unb64(s.newKey).length !== 32)
|
|
16
|
+
throw new Error("the new release key must be a 32-byte Ed25519 public key (base64)");
|
|
17
|
+
const t = Date.parse(s.validFrom);
|
|
18
|
+
if (Number.isNaN(t))
|
|
19
|
+
throw new Error("validFrom must be an ISO 8601 UTC time");
|
|
20
|
+
return JSON.stringify({ type: "key-rotation", appId: s.appId, sequence: s.sequence, newKey: s.newKey, validFrom: new Date(t).toISOString() });
|
|
21
|
+
}
|
|
22
|
+
/** Sign the exact UTF-8 bytes of `body` with the root seed. */
|
|
23
|
+
export function signStatement(s, rootSeedB64) {
|
|
24
|
+
const body = buildStatementBody(s);
|
|
25
|
+
const { seed } = keyFromSeed(rootSeedB64);
|
|
26
|
+
return { body, signature: b64(ed.sign(enc.encode(body), seed)) };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Check one envelope in the order of design section 8, then section 3.
|
|
30
|
+
* `effectiveSequence` is max(stored sequence, build minimum). `now` is the runtime clock.
|
|
31
|
+
*/
|
|
32
|
+
export function verifyStatement(env, rootPublicKeyB64, ctx) {
|
|
33
|
+
const e = env;
|
|
34
|
+
if (!e || typeof e.body !== "string" || typeof e.signature !== "string" || enc.encode(e.body).length > MAX_BODY_BYTES)
|
|
35
|
+
return { ok: false, reason: "bad_envelope" };
|
|
36
|
+
let good = false;
|
|
37
|
+
try {
|
|
38
|
+
good = ed.verify(unb64(e.signature), enc.encode(e.body), unb64(rootPublicKeyB64));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
good = false;
|
|
42
|
+
}
|
|
43
|
+
if (!good)
|
|
44
|
+
return { ok: false, reason: "bad_signature" };
|
|
45
|
+
let s;
|
|
46
|
+
try {
|
|
47
|
+
s = JSON.parse(e.body);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { ok: false, reason: "bad_body" };
|
|
51
|
+
}
|
|
52
|
+
if (!s || typeof s !== "object" || Array.isArray(s))
|
|
53
|
+
return { ok: false, reason: "bad_body" };
|
|
54
|
+
if (s.type !== "key-rotation")
|
|
55
|
+
return { ok: false, reason: "wrong_type" };
|
|
56
|
+
if (s.appId !== ctx.appId)
|
|
57
|
+
return { ok: false, reason: "wrong_app" };
|
|
58
|
+
if (!Number.isSafeInteger(s.sequence) || s.sequence < 1)
|
|
59
|
+
return { ok: false, reason: "bad_sequence" };
|
|
60
|
+
if (s.sequence <= ctx.effectiveSequence)
|
|
61
|
+
return { ok: false, reason: "replay" };
|
|
62
|
+
const from = typeof s.validFrom === "string" ? Date.parse(s.validFrom) : NaN;
|
|
63
|
+
if (Number.isNaN(from))
|
|
64
|
+
return { ok: false, reason: "bad_body" };
|
|
65
|
+
if (from > ctx.now.getTime() + MAX_PENDING_DAYS * 86_400_000)
|
|
66
|
+
return { ok: false, reason: "too_far" };
|
|
67
|
+
if (typeof s.newKey !== "string" || unb64(s.newKey).length !== 32)
|
|
68
|
+
return { ok: false, reason: "bad_key" };
|
|
69
|
+
return { ok: true, statement: s };
|
|
70
|
+
}
|
|
71
|
+
export class RotateError extends Error {
|
|
72
|
+
}
|
|
73
|
+
export const ROTATE_HELP = `Usage:
|
|
74
|
+
shipvo keys rotate sign --root-seed-file <file> --new-key <public key> --sequence <n> [--valid-from <ISO time>] [--json]
|
|
75
|
+
shipvo keys rotate upload <envelope.json> [--json]
|
|
76
|
+
|
|
77
|
+
sign works offline. It reads the ROOT seed from a file (never the release seed) and prints the signed envelope.
|
|
78
|
+
--valid-from defaults to now (an emergency rotation). It may be at most ${MAX_PENDING_DAYS} days ahead.
|
|
79
|
+
Pick a sequence above every statement you signed before. To override a planned statement, use a higher one.
|
|
80
|
+
upload needs a signed-in owner session (shipvo login). Tokens are refused by the control plane.
|
|
81
|
+
`;
|
|
82
|
+
/** Runs `shipvo keys rotate ...`. Throws RotateError for user mistakes; the caller prints it and exits 1. */
|
|
83
|
+
export async function rotate(argv, cfg, deps = {}) {
|
|
84
|
+
const [sub, ...rest] = argv, a = args(rest), json = a.json === "true";
|
|
85
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
86
|
+
if (sub === "sign") {
|
|
87
|
+
const file = a["root-seed-file"], newKey = a["new-key"], seq = a.sequence;
|
|
88
|
+
if (!file || file === "true" || !newKey || newKey === "true" || !seq || seq === "true")
|
|
89
|
+
throw new RotateError(ROTATE_HELP);
|
|
90
|
+
let rootSeed;
|
|
91
|
+
try {
|
|
92
|
+
rootSeed = readFileSync(file, "utf8").trim();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new RotateError(`Cannot read the root seed file ${file}.`);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
keyFromSeed(rootSeed);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new RotateError("The root seed is not valid: it must be a 32-byte value in base64.");
|
|
102
|
+
}
|
|
103
|
+
if (!/^\d+$/.test(seq) || !Number.isSafeInteger(Number(seq)) || Number(seq) < 1)
|
|
104
|
+
throw new RotateError("--sequence must be a whole number from 1 to 9007199254740991.");
|
|
105
|
+
const validFrom = a["valid-from"] && a["valid-from"] !== "true" ? a["valid-from"] : now.toISOString();
|
|
106
|
+
const t = Date.parse(validFrom);
|
|
107
|
+
if (Number.isNaN(t))
|
|
108
|
+
throw new RotateError("--valid-from must be an ISO 8601 UTC time, such as 2026-11-06T00:00:00Z.");
|
|
109
|
+
if (t > now.getTime() + MAX_PENDING_DAYS * 86_400_000)
|
|
110
|
+
throw new RotateError(`--valid-from is more than ${MAX_PENDING_DAYS} days ahead. Phones would reject it.`);
|
|
111
|
+
let env;
|
|
112
|
+
try {
|
|
113
|
+
env = signStatement({ appId: cfg.appId, sequence: Number(seq), newKey, validFrom }, rootSeed);
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
throw new RotateError(e instanceof Error ? e.message : String(e));
|
|
117
|
+
}
|
|
118
|
+
console.log(JSON.stringify(env, null, json ? 0 : 2));
|
|
119
|
+
if (!json)
|
|
120
|
+
console.error(`Signed rotation statement ${seq} for "${cfg.appId}". Confirm ${seq} is above every statement you signed before; this offline tool cannot check. Then run: shipvo keys rotate upload <file>`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (sub === "upload") {
|
|
124
|
+
const file = rest.find((x) => !x.startsWith("--"));
|
|
125
|
+
if (!file)
|
|
126
|
+
throw new RotateError(ROTATE_HELP);
|
|
127
|
+
let env;
|
|
128
|
+
try {
|
|
129
|
+
env = JSON.parse(readFileSync(file === "-" ? 0 : file, "utf8"));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new RotateError(`${file} is not a readable JSON envelope.`);
|
|
133
|
+
}
|
|
134
|
+
if (typeof env?.body !== "string" || typeof env?.signature !== "string")
|
|
135
|
+
throw new RotateError("The envelope needs a string \"body\" and a string \"signature\".");
|
|
136
|
+
const creds = savedCreds();
|
|
137
|
+
if (!creds?.key)
|
|
138
|
+
throw new RotateError("Not signed in. Run `shipvo login` as the app owner. Tokens cannot upload rotation statements.");
|
|
139
|
+
const norm = (u) => u.replace(/\/$/, "");
|
|
140
|
+
if (norm(creds.control ?? "") !== norm(cfg.controlPlane))
|
|
141
|
+
throw new RotateError(`shipvo.json controlPlane is ${cfg.controlPlane}, but you signed in at ${creds.control || "another host"}. Not sending your session key there. Fix controlPlane or run \`shipvo login\` for that host.`);
|
|
142
|
+
const url = `${cfg.controlPlane.replace(/\/$/, "")}/rotation/${encodeURIComponent(cfg.appId)}`;
|
|
143
|
+
let r;
|
|
144
|
+
try {
|
|
145
|
+
r = await (deps.fetchFn ?? fetch)(url, { method: "POST", headers: { authorization: `Bearer ${creds.key}`, "content-type": "application/json" }, body: JSON.stringify({ body: env.body, signature: env.signature }) });
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
throw new RotateError(`Could not reach the control plane at ${cfg.controlPlane} (${e instanceof Error ? e.message : String(e)}).`);
|
|
149
|
+
}
|
|
150
|
+
const out = await r.json().catch(() => ({}));
|
|
151
|
+
if (!r.ok)
|
|
152
|
+
throw new RotateError(`The control plane refused the statement (HTTP ${r.status}): ${out.message ?? out.reason ?? out.error ?? "no reason given"}`);
|
|
153
|
+
console.log(json ? JSON.stringify({ ok: true, ...out }) : `Uploaded. Phones switch when they next fetch it.`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
throw new RotateError(ROTATE_HELP);
|
|
157
|
+
}
|