pgserve 2.3.0 → 2.5.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/bin/pgserve-wrapper.cjs +9 -4
- package/bin/postgres-server.js +170 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +3 -2
- package/scripts/audit-redaction-lint.js +349 -0
- package/scripts/test-npx.sh +32 -10
- package/src/audit/audit.js +134 -0
- package/src/cli-install.cjs +340 -100
- package/src/commands/uninstall.js +241 -0
- package/src/commands/verify.js +360 -0
- package/src/cosign/cache-token.js +328 -0
- package/src/cosign/schema.js +97 -0
- package/src/cosign/trust-list.js +81 -0
- package/src/cosign/verify-binary.js +277 -0
- package/src/index.js +11 -44
- package/src/lib/admin-json.js +202 -0
- package/src/lib/pm2-args.js +119 -0
- package/src/lib/runtime-json.js +181 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/upgrade/index.js +5 -0
- package/src/upgrade/steps/cosign-meta-migration.js +123 -0
- package/src/admin-client.js +0 -223
- package/src/audit.js +0 -168
- package/src/cluster.js +0 -654
- package/src/control-db.js +0 -330
- package/src/daemon-control.js +0 -468
- package/src/daemon-shared.js +0 -18
- package/src/daemon-tcp.js +0 -297
- package/src/daemon.js +0 -709
- package/src/dashboard.js +0 -217
- package/src/fingerprint.js +0 -479
- package/src/gc.js +0 -351
- package/src/pg-wire.js +0 -869
- package/src/protocol.js +0 -389
- package/src/restore.js +0 -574
- package/src/router.js +0 -546
- package/src/sdk.js +0 -137
- package/src/stats-collector.js +0 -453
- package/src/stats-dashboard.js +0 -401
- package/src/sync.js +0 -335
- package/src/tenancy.js +0 -75
- package/src/tokens.js +0 -102
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `autopg uninstall` — Tier A teardown of the rootless pm2 supervisor.
|
|
3
|
+
*
|
|
4
|
+
* Group 1 of the canonical-pgserve-pm2-supervision wish. Idempotent.
|
|
5
|
+
*
|
|
6
|
+
* Removes:
|
|
7
|
+
* - pm2 entry `autopg-server` (the postmaster, registered by `autopg install`)
|
|
8
|
+
* - pm2 entry `autopg-ui` (the console SPA, registered by `autopg install`)
|
|
9
|
+
* - the supervisor record in `~/.autopg/admin.json` (the four supervisor
|
|
10
|
+
* fields managed by `src/lib/admin-json.js` — preserves the scrypt
|
|
11
|
+
* Basic-Auth scheme so a re-install can keep the same admin password).
|
|
12
|
+
*
|
|
13
|
+
* Preserves:
|
|
14
|
+
* - the data directory under `~/.autopg/data/`
|
|
15
|
+
* - `~/.autopg/config.json`
|
|
16
|
+
* - `admin.json` auth fields (scheme/salt/hash/createdAt/rotatedAt/...)
|
|
17
|
+
*
|
|
18
|
+
* Writes one JSONL audit-log entry to `<configDir>/audit.log`.
|
|
19
|
+
*
|
|
20
|
+
* Idempotent contract: running uninstall twice in a row is a no-op on the
|
|
21
|
+
* second call. After uninstall, a subsequent `autopg install` succeeds
|
|
22
|
+
* without a Tier-B-refusal false positive — `assertSupervisor` treats a
|
|
23
|
+
* missing supervisor field as "host is free to install".
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
ADMIN_FILE_MODE,
|
|
33
|
+
getAdminFilePath,
|
|
34
|
+
readAdminJson,
|
|
35
|
+
} from '../lib/admin-json.js';
|
|
36
|
+
|
|
37
|
+
export const TIER_A_PM2_PROCESSES = Object.freeze(['autopg-server', 'autopg-ui']);
|
|
38
|
+
export const SUPERVISOR_FIELDS = Object.freeze([
|
|
39
|
+
'supervisor',
|
|
40
|
+
'socketDir',
|
|
41
|
+
'port',
|
|
42
|
+
'installedAt',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function getConfigDir() {
|
|
46
|
+
return (
|
|
47
|
+
process.env.AUTOPG_CONFIG_DIR
|
|
48
|
+
|| process.env.PGSERVE_CONFIG_DIR
|
|
49
|
+
|| path.join(os.homedir(), '.autopg')
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pm2IsAvailable() {
|
|
54
|
+
try {
|
|
55
|
+
execFileSync('pm2', ['--version'], {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: 3000,
|
|
58
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
59
|
+
});
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pm2GetProcess(name) {
|
|
67
|
+
try {
|
|
68
|
+
const out = execFileSync('pm2', ['jlist'], {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
72
|
+
});
|
|
73
|
+
const list = JSON.parse(out);
|
|
74
|
+
return list.find((p) => p && p.name === name) || null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Always-attempt pm2 delete. `pm2 delete <missing>` exits non-zero but the
|
|
82
|
+
* call is idempotent server-side, so any non-zero exit is treated as
|
|
83
|
+
* "already absent" rather than a hard failure. We snapshot pm2 jlist
|
|
84
|
+
* BEFORE the delete to report whether the entry actually existed.
|
|
85
|
+
*/
|
|
86
|
+
function tearDownPm2(name) {
|
|
87
|
+
if (!pm2IsAvailable()) {
|
|
88
|
+
return { name, removed: false, status: 'pm2-missing' };
|
|
89
|
+
}
|
|
90
|
+
const before = pm2GetProcess(name);
|
|
91
|
+
const res = spawnSync('pm2', ['delete', name], {
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
93
|
+
timeout: 10_000,
|
|
94
|
+
});
|
|
95
|
+
if (res.status === 0) {
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
removed: !!before,
|
|
99
|
+
status: before ? 'removed' : 'already-absent',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
removed: false,
|
|
105
|
+
status: 'already-absent',
|
|
106
|
+
exitCode: res.status,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Atomically clear the supervisor fields from admin.json. Preserves all
|
|
112
|
+
* other fields (notably the scrypt Basic-Auth scheme written by
|
|
113
|
+
* `cli-install.cjs`'s `writeAdminFile`). Removes the file entirely if
|
|
114
|
+
* clearing leaves an empty object.
|
|
115
|
+
*
|
|
116
|
+
* Returns { changed, file, hadSupervisor }.
|
|
117
|
+
*/
|
|
118
|
+
function clearSupervisorRecord(configDir) {
|
|
119
|
+
const file = getAdminFilePath(configDir);
|
|
120
|
+
const existing = readAdminJson({ configDir });
|
|
121
|
+
if (!existing) {
|
|
122
|
+
return { changed: false, file, hadSupervisor: false };
|
|
123
|
+
}
|
|
124
|
+
const hadSupervisor = SUPERVISOR_FIELDS.some((k) => k in existing);
|
|
125
|
+
if (!hadSupervisor) {
|
|
126
|
+
return { changed: false, file, hadSupervisor: false };
|
|
127
|
+
}
|
|
128
|
+
const cleared = { ...existing };
|
|
129
|
+
for (const field of SUPERVISOR_FIELDS) {
|
|
130
|
+
delete cleared[field];
|
|
131
|
+
}
|
|
132
|
+
if (Object.keys(cleared).length === 0) {
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(file);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err && err.code !== 'ENOENT') throw err;
|
|
137
|
+
}
|
|
138
|
+
return { changed: true, file, hadSupervisor: true };
|
|
139
|
+
}
|
|
140
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
141
|
+
const json = `${JSON.stringify(cleared, null, 2)}\n`;
|
|
142
|
+
fs.writeFileSync(tmp, json, { mode: ADMIN_FILE_MODE });
|
|
143
|
+
fs.renameSync(tmp, file);
|
|
144
|
+
fs.chmodSync(file, ADMIN_FILE_MODE);
|
|
145
|
+
return { changed: true, file, hadSupervisor: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function appendAuditLog(configDir, payload) {
|
|
149
|
+
if (!fs.existsSync(configDir)) {
|
|
150
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
151
|
+
}
|
|
152
|
+
const file = path.join(configDir, 'audit.log');
|
|
153
|
+
const record = {
|
|
154
|
+
ts: new Date().toISOString(),
|
|
155
|
+
event: 'autopg_uninstall',
|
|
156
|
+
...payload,
|
|
157
|
+
};
|
|
158
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function emit(level, msg, silent) {
|
|
162
|
+
if (silent) return;
|
|
163
|
+
const stream = level === 'err' ? process.stderr : process.stdout;
|
|
164
|
+
stream.write(`autopg: ${msg}\n`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run the uninstall flow. Returns a numeric exit code.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} [opts]
|
|
171
|
+
* @param {string} [opts.configDir] — override the autopg config dir (used
|
|
172
|
+
* by tests; production code should leave this undefined and let env vars
|
|
173
|
+
* resolve).
|
|
174
|
+
* @param {boolean} [opts.silent] — suppress stdout/stderr writes.
|
|
175
|
+
*/
|
|
176
|
+
export function runUninstall(opts = {}) {
|
|
177
|
+
const configDir = opts.configDir || getConfigDir();
|
|
178
|
+
const silent = opts.silent === true;
|
|
179
|
+
|
|
180
|
+
const pm2Available = pm2IsAvailable();
|
|
181
|
+
if (!pm2Available) {
|
|
182
|
+
emit(
|
|
183
|
+
'err',
|
|
184
|
+
'pm2 not found in PATH; skipping pm2 teardown (admin.json supervisor record will still be cleared).',
|
|
185
|
+
silent,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const pm2Results = TIER_A_PM2_PROCESSES.map((name) => tearDownPm2(name));
|
|
190
|
+
|
|
191
|
+
let supervisorClear;
|
|
192
|
+
try {
|
|
193
|
+
supervisorClear = clearSupervisorRecord(configDir);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
emit('err', `failed to clear supervisor record in admin.json: ${err.message}`, silent);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
appendAuditLog(configDir, {
|
|
201
|
+
pm2Available,
|
|
202
|
+
pm2: pm2Results,
|
|
203
|
+
supervisorRecord: {
|
|
204
|
+
changed: supervisorClear.changed,
|
|
205
|
+
hadSupervisor: supervisorClear.hadSupervisor,
|
|
206
|
+
file: supervisorClear.file,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
// Audit must never break uninstall.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const removed = pm2Results.filter((r) => r.removed).map((r) => r.name);
|
|
214
|
+
const absent = pm2Results.filter((r) => r.status === 'already-absent').map((r) => r.name);
|
|
215
|
+
|
|
216
|
+
if (removed.length === 0 && !supervisorClear.changed) {
|
|
217
|
+
emit(
|
|
218
|
+
'out',
|
|
219
|
+
`not registered under pm2 (${TIER_A_PM2_PROCESSES.join(', ')}); nothing to uninstall`,
|
|
220
|
+
silent,
|
|
221
|
+
);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (removed.length > 0) {
|
|
226
|
+
emit(
|
|
227
|
+
'out',
|
|
228
|
+
`uninstalled pm2 entries: ${removed.join(', ')} (data dir preserved at ${path.join(configDir, 'data')})`,
|
|
229
|
+
silent,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (absent.length > 0 && removed.length > 0) {
|
|
233
|
+
emit('out', `(already absent: ${absent.join(', ')})`, silent);
|
|
234
|
+
}
|
|
235
|
+
if (supervisorClear.changed) {
|
|
236
|
+
emit('out', `cleared supervisor record from ${supervisorClear.file}`, silent);
|
|
237
|
+
} else if (removed.length > 0) {
|
|
238
|
+
emit('out', `no supervisor record to clear in ${supervisorClear.file}`, silent);
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pgserve verify <binary-path>` — cosign-keyless-OIDC verification.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
|
|
5
|
+
*
|
|
6
|
+
* Flow:
|
|
7
|
+
* 1. Resolve target binary, compute realpath + sha256 + size + mtime.
|
|
8
|
+
* 2. Look up the HMAC-signed cache at `$XDG_STATE_HOME/pgserve/verified/
|
|
9
|
+
* <fingerprint>.token`. If valid (HMAC matches, sliding expiry not
|
|
10
|
+
* lapsed, binary attestation matches mtime/size) → short-circuit.
|
|
11
|
+
* 3. Otherwise call `verifyBinary()` (cosign verify-blob against the
|
|
12
|
+
* hardcoded trust list per `src/cosign/trust-list.js`).
|
|
13
|
+
* 4. On success: persist the cache token (mode 0600). On failure: emit
|
|
14
|
+
* a diagnostic and exit non-zero.
|
|
15
|
+
*
|
|
16
|
+
* Flags:
|
|
17
|
+
* --json — emit machine-readable result on stdout
|
|
18
|
+
* --skip-sigstore — bypass cosign and consult the operator's
|
|
19
|
+
* offline trust file. Refuses unless the file
|
|
20
|
+
* records at least one offline-cosign-key entry
|
|
21
|
+
* (managed by G3's `pgserve trust add`).
|
|
22
|
+
* --bundle <path> — override the sigstore bundle sidecar path
|
|
23
|
+
* --cosign-bin <path> — override the cosign executable
|
|
24
|
+
* --allow-fetch — let cosign be fetched if missing on PATH
|
|
25
|
+
* --no-cache — never read or write the verified-cache token
|
|
26
|
+
*
|
|
27
|
+
* Exit codes:
|
|
28
|
+
* 0 — verified (fresh or cache hit)
|
|
29
|
+
* 2 — verification failed (cosign rejected, tampered binary, ...)
|
|
30
|
+
* 3 — invocation problem (--skip-sigstore without pretrusted key,
|
|
31
|
+
* missing binary, missing bundle, no cosign on PATH, ...)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
buildTokenPayload,
|
|
40
|
+
computeBinaryAttestation,
|
|
41
|
+
deleteCacheToken,
|
|
42
|
+
getStateDir,
|
|
43
|
+
readCacheToken,
|
|
44
|
+
touchCacheToken,
|
|
45
|
+
writeCacheToken,
|
|
46
|
+
} from '../cosign/cache-token.js';
|
|
47
|
+
import { sha256File, verifyBinary } from '../cosign/verify-binary.js';
|
|
48
|
+
|
|
49
|
+
const EXIT_OK = 0;
|
|
50
|
+
const EXIT_VERIFY_FAILED = 2;
|
|
51
|
+
const EXIT_INVOCATION = 3;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute the cache fingerprint for a binary. We use the realpath +
|
|
55
|
+
* sha256 first 32 chars so two distinct binaries get distinct cache
|
|
56
|
+
* entries even if they share a directory layout, while keeping the
|
|
57
|
+
* filename short enough to be readable in `ls`.
|
|
58
|
+
*/
|
|
59
|
+
export function computeFingerprint(binaryRealpath, sha256) {
|
|
60
|
+
return `${path.basename(binaryRealpath).replace(/[^A-Za-z0-9._-]/g, '_')}.${sha256.slice(0, 16)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getTrustFilePath() {
|
|
64
|
+
if (process.env.PGSERVE_TRUST_FILE) return process.env.PGSERVE_TRUST_FILE;
|
|
65
|
+
const home = process.env.HOME || os.homedir();
|
|
66
|
+
return path.join(home, '.pgserve', 'trust', 'identities.json');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load the operator-managed offline trust file. G3's `pgserve trust add
|
|
71
|
+
* --offline-cosign-key` writes to this path; in G4 we only consume it.
|
|
72
|
+
*
|
|
73
|
+
* Expected shape:
|
|
74
|
+
* {
|
|
75
|
+
* offlineKeys: [
|
|
76
|
+
* { id: '<short-id>', publisher: '<package>', keyFingerprint: '...',
|
|
77
|
+
* addedAt: '<ISO>' },
|
|
78
|
+
* ...
|
|
79
|
+
* ]
|
|
80
|
+
* }
|
|
81
|
+
*/
|
|
82
|
+
function readOfflineTrust() {
|
|
83
|
+
const file = getTrustFilePath();
|
|
84
|
+
if (!fs.existsSync(file)) return { ok: false, reason: 'trust-file-missing', file };
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return { ok: false, reason: 'trust-file-unreadable', detail: err.message, file };
|
|
90
|
+
}
|
|
91
|
+
let doc;
|
|
92
|
+
try {
|
|
93
|
+
doc = JSON.parse(raw);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, reason: 'trust-file-malformed', detail: err.message, file };
|
|
96
|
+
}
|
|
97
|
+
const keys = Array.isArray(doc?.offlineKeys) ? doc.offlineKeys : null;
|
|
98
|
+
if (!keys || keys.length === 0) {
|
|
99
|
+
return { ok: false, reason: 'no-offline-keys', file };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, keys, file };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseArgs(args) {
|
|
105
|
+
const opts = {
|
|
106
|
+
binaryPath: null,
|
|
107
|
+
json: false,
|
|
108
|
+
skipSigstore: false,
|
|
109
|
+
bundlePath: null,
|
|
110
|
+
cosignBin: null,
|
|
111
|
+
allowFetch: false,
|
|
112
|
+
noCache: false,
|
|
113
|
+
};
|
|
114
|
+
for (let i = 0; i < args.length; i++) {
|
|
115
|
+
const a = args[i];
|
|
116
|
+
if (a === '--json') opts.json = true;
|
|
117
|
+
else if (a === '--skip-sigstore') opts.skipSigstore = true;
|
|
118
|
+
else if (a === '--allow-fetch') opts.allowFetch = true;
|
|
119
|
+
else if (a === '--no-cache') opts.noCache = true;
|
|
120
|
+
else if (a === '--bundle') opts.bundlePath = args[++i];
|
|
121
|
+
else if (a === '--cosign-bin') opts.cosignBin = args[++i];
|
|
122
|
+
else if (a === '--help' || a === '-h') {
|
|
123
|
+
printHelp(process.stdout);
|
|
124
|
+
return { exit: EXIT_OK };
|
|
125
|
+
} else if (a.startsWith('-')) {
|
|
126
|
+
process.stderr.write(`pgserve verify: unknown option ${JSON.stringify(a)}\n`);
|
|
127
|
+
return { exit: EXIT_INVOCATION };
|
|
128
|
+
} else if (opts.binaryPath === null) {
|
|
129
|
+
opts.binaryPath = a;
|
|
130
|
+
} else {
|
|
131
|
+
process.stderr.write(`pgserve verify: unexpected positional argument ${JSON.stringify(a)}\n`);
|
|
132
|
+
return { exit: EXIT_INVOCATION };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!opts.binaryPath) {
|
|
136
|
+
printHelp(process.stderr);
|
|
137
|
+
return { exit: EXIT_INVOCATION };
|
|
138
|
+
}
|
|
139
|
+
return { opts };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function printHelp(stream) {
|
|
143
|
+
stream.write(`pgserve verify <binary-path> [options]
|
|
144
|
+
|
|
145
|
+
Verify a binary against the cosign keyless OIDC trust list. On success,
|
|
146
|
+
persists an HMAC-signed cache token so subsequent invocations short-circuit
|
|
147
|
+
the cosign call until the binary changes (mtime/size) or the sliding
|
|
148
|
+
expiry lapses (1h idle / 7d max).
|
|
149
|
+
|
|
150
|
+
Options:
|
|
151
|
+
--json Emit a machine-readable JSON result on stdout
|
|
152
|
+
--skip-sigstore Bypass cosign — requires \`pgserve trust add\` (G3)
|
|
153
|
+
--bundle <path> Override the sigstore bundle sidecar path
|
|
154
|
+
(default: <binary>.bundle)
|
|
155
|
+
--cosign-bin <path> Override the cosign executable
|
|
156
|
+
--allow-fetch Allow downloading cosign if missing
|
|
157
|
+
--no-cache Never read or write the verified-cache token
|
|
158
|
+
--help, -h Show this help
|
|
159
|
+
|
|
160
|
+
Exit codes:
|
|
161
|
+
0 Verified (fresh or cache hit)
|
|
162
|
+
2 Verification failed
|
|
163
|
+
3 Invocation problem (missing binary/bundle/cosign/pretrusted key)
|
|
164
|
+
`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function emit({ json }, payload) {
|
|
168
|
+
if (json) {
|
|
169
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (payload.ok) {
|
|
173
|
+
const tag = payload.cached ? 'cached' : 'verified';
|
|
174
|
+
process.stdout.write(`pgserve verify: ${tag} ${payload.binary} as ${payload.identity} (${payload.tier})\n`);
|
|
175
|
+
if (payload.cached === false) {
|
|
176
|
+
process.stdout.write(`pgserve verify: cache token written → ${payload.cacheFile}\n`);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
process.stderr.write(`pgserve verify: FAILED — ${payload.reason}${payload.detail ? `: ${payload.detail}` : ''}\n`);
|
|
181
|
+
if (payload.identityChain && payload.identityChain.length > 0) {
|
|
182
|
+
process.stderr.write(`pgserve verify: trust roots tried: ${JSON.stringify(payload.identityChain)}\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run the verify command. `argv` is the bare argument list AFTER the
|
|
188
|
+
* `verify` token. Returns an integer exit code.
|
|
189
|
+
*/
|
|
190
|
+
export function runVerify(argv) {
|
|
191
|
+
const parsed = parseArgs(argv);
|
|
192
|
+
if (parsed.exit !== undefined) return parsed.exit;
|
|
193
|
+
const opts = parsed.opts;
|
|
194
|
+
|
|
195
|
+
const binaryPath = path.resolve(opts.binaryPath);
|
|
196
|
+
if (!fs.existsSync(binaryPath)) {
|
|
197
|
+
emit(opts, { ok: false, reason: 'binary-missing', detail: binaryPath });
|
|
198
|
+
return EXIT_INVOCATION;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let attestation;
|
|
202
|
+
try {
|
|
203
|
+
attestation = computeBinaryAttestation(binaryPath);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
emit(opts, { ok: false, reason: 'binary-attestation-failed', detail: err.message });
|
|
206
|
+
return EXIT_INVOCATION;
|
|
207
|
+
}
|
|
208
|
+
const sha256 = sha256File(binaryPath);
|
|
209
|
+
const fingerprint = computeFingerprint(attestation.realpath, sha256);
|
|
210
|
+
|
|
211
|
+
// ── Cache lookup ─────────────────────────────────────────────────────
|
|
212
|
+
if (!opts.noCache) {
|
|
213
|
+
const cache = readCacheToken(fingerprint, { binaryAttestation: attestation });
|
|
214
|
+
if (cache.ok) {
|
|
215
|
+
// PR #79 P1 security fix: honor the requested tier strictly. Without
|
|
216
|
+
// this gate, a token written under `--skip-sigstore` (tier:self_signed)
|
|
217
|
+
// would be accepted on a subsequent run WITHOUT `--skip-sigstore`,
|
|
218
|
+
// letting the operator bypass cosign verification entirely. The fix:
|
|
219
|
+
// - default invocation (no --skip-sigstore) requires tier:cosign_signed
|
|
220
|
+
// - --skip-sigstore invocation requires tier:self_signed
|
|
221
|
+
// Mismatched-tier cache hits are treated as cache misses (fall through
|
|
222
|
+
// to re-verify under the requested tier).
|
|
223
|
+
const cachedTier = cache.payload.tier;
|
|
224
|
+
const expectedTier = opts.skipSigstore ? 'self_signed' : 'cosign_signed';
|
|
225
|
+
if (cachedTier === expectedTier) {
|
|
226
|
+
// Tier matches — bump lastUsedAt and return.
|
|
227
|
+
touchCacheToken(cache.payload, {});
|
|
228
|
+
emit(opts, {
|
|
229
|
+
ok: true,
|
|
230
|
+
cached: true,
|
|
231
|
+
binary: binaryPath,
|
|
232
|
+
identity: cache.payload.identity,
|
|
233
|
+
tier: cachedTier,
|
|
234
|
+
sha256: cache.payload.sha256 || sha256,
|
|
235
|
+
cacheFile: cache.file,
|
|
236
|
+
});
|
|
237
|
+
return EXIT_OK;
|
|
238
|
+
}
|
|
239
|
+
// Tier mismatch — fall through. Do NOT delete the cache token: the
|
|
240
|
+
// existing token is valid for its own tier; we just need a fresh
|
|
241
|
+
// verification under the currently-requested tier.
|
|
242
|
+
}
|
|
243
|
+
// Stale binary attestation invalidates the cache so the new fingerprint
|
|
244
|
+
// wins. We delete defensively when the binary changed under us.
|
|
245
|
+
if (cache.reason === 'binary-changed') {
|
|
246
|
+
deleteCacheToken(fingerprint, {});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── --skip-sigstore path ─────────────────────────────────────────────
|
|
251
|
+
if (opts.skipSigstore) {
|
|
252
|
+
const trust = readOfflineTrust();
|
|
253
|
+
if (!trust.ok) {
|
|
254
|
+
emit(opts, {
|
|
255
|
+
ok: false,
|
|
256
|
+
reason: 'skip-sigstore-without-pretrusted-key',
|
|
257
|
+
detail:
|
|
258
|
+
`--skip-sigstore requires an offline trust entry. None found (${trust.reason}). `
|
|
259
|
+
+ 'Operators must run `pgserve trust add --offline-cosign-key '
|
|
260
|
+
+ '<key-file> --identity <id>` once Group 3 of the singleton wish ships. '
|
|
261
|
+
+ `Trust file path: ${trust.file}`,
|
|
262
|
+
});
|
|
263
|
+
return EXIT_INVOCATION;
|
|
264
|
+
}
|
|
265
|
+
// Operator vouched for the binary via an offline-cosign-key entry; we
|
|
266
|
+
// record it as `self_signed` tier (NOT cosign_signed — this is a less
|
|
267
|
+
// strong attestation than a Sigstore OIDC chain).
|
|
268
|
+
const identity = trust.keys[0].id;
|
|
269
|
+
const payload = buildTokenPayload({
|
|
270
|
+
fingerprint,
|
|
271
|
+
binary: attestation,
|
|
272
|
+
identity,
|
|
273
|
+
tier: 'self_signed',
|
|
274
|
+
sha256,
|
|
275
|
+
});
|
|
276
|
+
let cacheFile = null;
|
|
277
|
+
if (!opts.noCache) {
|
|
278
|
+
try {
|
|
279
|
+
cacheFile = writeCacheToken(payload, {});
|
|
280
|
+
} catch (err) {
|
|
281
|
+
emit(opts, { ok: false, reason: 'cache-write-failed', detail: err.message });
|
|
282
|
+
return EXIT_VERIFY_FAILED;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
emit(opts, {
|
|
286
|
+
ok: true,
|
|
287
|
+
cached: false,
|
|
288
|
+
binary: binaryPath,
|
|
289
|
+
identity,
|
|
290
|
+
tier: 'self_signed',
|
|
291
|
+
sha256,
|
|
292
|
+
cacheFile,
|
|
293
|
+
skipSigstore: true,
|
|
294
|
+
});
|
|
295
|
+
return EXIT_OK;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Cosign path ──────────────────────────────────────────────────────
|
|
299
|
+
const result = verifyBinary(binaryPath, {
|
|
300
|
+
cosignBin: opts.cosignBin || process.env.PGSERVE_COSIGN_BIN || undefined,
|
|
301
|
+
bundlePath: opts.bundlePath || undefined,
|
|
302
|
+
allowFetch: opts.allowFetch === true,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
emit(opts, {
|
|
307
|
+
ok: false,
|
|
308
|
+
reason: result.reason,
|
|
309
|
+
detail: result.detail,
|
|
310
|
+
identityChain: result.identityChain,
|
|
311
|
+
});
|
|
312
|
+
if (result.reason === 'binary-missing'
|
|
313
|
+
|| result.reason === 'binary-unreadable'
|
|
314
|
+
|| result.reason === 'binary-not-a-file'
|
|
315
|
+
|| result.reason === 'bundle-missing'
|
|
316
|
+
|| result.reason === 'cosign-missing'
|
|
317
|
+
|| result.reason === 'empty-trust-list'
|
|
318
|
+
|| result.reason === 'invalid-args') {
|
|
319
|
+
return EXIT_INVOCATION;
|
|
320
|
+
}
|
|
321
|
+
return EXIT_VERIFY_FAILED;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let cacheFile = null;
|
|
325
|
+
if (!opts.noCache) {
|
|
326
|
+
try {
|
|
327
|
+
const payload = buildTokenPayload({
|
|
328
|
+
fingerprint,
|
|
329
|
+
binary: attestation,
|
|
330
|
+
identity: result.identity,
|
|
331
|
+
tier: result.tier,
|
|
332
|
+
sha256: result.sha256,
|
|
333
|
+
});
|
|
334
|
+
cacheFile = writeCacheToken(payload, {});
|
|
335
|
+
} catch (err) {
|
|
336
|
+
emit(opts, { ok: false, reason: 'cache-write-failed', detail: err.message });
|
|
337
|
+
return EXIT_VERIFY_FAILED;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
emit(opts, {
|
|
341
|
+
ok: true,
|
|
342
|
+
cached: false,
|
|
343
|
+
binary: binaryPath,
|
|
344
|
+
identity: result.identity,
|
|
345
|
+
publisher: result.publisher,
|
|
346
|
+
tier: result.tier,
|
|
347
|
+
sha256: result.sha256,
|
|
348
|
+
cacheFile,
|
|
349
|
+
bundle: result.bundle,
|
|
350
|
+
cosignBin: result.cosignBin,
|
|
351
|
+
});
|
|
352
|
+
return EXIT_OK;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Convenience export so tests can introspect paths without re-implementing.
|
|
356
|
+
export const _internals = {
|
|
357
|
+
computeFingerprint,
|
|
358
|
+
getStateDir,
|
|
359
|
+
getTrustFilePath,
|
|
360
|
+
};
|