pgserve 2.4.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 +4 -0
- package/bin/postgres-server.js +28 -0
- package/package.json +2 -1
- package/scripts/audit-redaction-lint.js +349 -0
- package/src/audit/audit.js +134 -0
- package/src/cli-install.cjs +195 -25
- 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/lib/runtime-json.js +181 -0
- package/src/upgrade/index.js +5 -0
- package/src/upgrade/steps/cosign-meta-migration.js +123 -0
package/src/cli-install.cjs
CHANGED
|
@@ -34,10 +34,15 @@ const path = require('node:path');
|
|
|
34
34
|
// promise once at module load and await it from async install paths.
|
|
35
35
|
const _adminJsonModuleP = import('./lib/admin-json.js');
|
|
36
36
|
const _socketDirModuleP = import('./lib/socket-dir.js');
|
|
37
|
+
const _runtimeJsonModuleP = import('./lib/runtime-json.js');
|
|
37
38
|
|
|
38
39
|
async function loadCohortModules() {
|
|
39
|
-
const [adminJson, socketDirMod] = await Promise.all([
|
|
40
|
-
|
|
40
|
+
const [adminJson, socketDirMod, runtimeJson] = await Promise.all([
|
|
41
|
+
_adminJsonModuleP,
|
|
42
|
+
_socketDirModuleP,
|
|
43
|
+
_runtimeJsonModuleP,
|
|
44
|
+
]);
|
|
45
|
+
return { adminJson, socketDirMod, runtimeJson };
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
// pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 1.
|
|
@@ -165,6 +170,131 @@ function readConfig() {
|
|
|
165
170
|
}
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
// cutover G19: discovery layer used by `autopg port / url / status`.
|
|
174
|
+
//
|
|
175
|
+
// Order of precedence (most-authoritative first):
|
|
176
|
+
// 1. `<socketDir>/runtime.json` — written by the live postmaster at greet
|
|
177
|
+
// time, removed on graceful shutdown. Carries the *current* port + pid
|
|
178
|
+
// for an actually-running daemon.
|
|
179
|
+
// 2. `~/.autopg/admin.json` — supervisor record written at install time.
|
|
180
|
+
// Survives postmaster restarts; doesn't reflect runtime state.
|
|
181
|
+
// 3. `~/.autopg/config.json` — legacy pre-G19 install record. Final
|
|
182
|
+
// fallback so older installs that haven't been re-installed under v2.4
|
|
183
|
+
// still discover cleanly.
|
|
184
|
+
//
|
|
185
|
+
// All readers swallow errors — discovery must never throw on a missing or
|
|
186
|
+
// truncated file. Synchronous on purpose: `dispatch()` for status/url/port
|
|
187
|
+
// is sync and the wrapper handles only `Promise OR number` return types.
|
|
188
|
+
function readRuntimeJsonSync(socketDir) {
|
|
189
|
+
if (typeof socketDir !== 'string' || socketDir.length === 0) return null;
|
|
190
|
+
const file = path.join(socketDir, 'runtime.json');
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(raw);
|
|
199
|
+
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function readAdminJsonSync() {
|
|
206
|
+
const file = path.join(getConfigDir(), ADMIN_FILE_NAME);
|
|
207
|
+
let raw;
|
|
208
|
+
try {
|
|
209
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const parsed = JSON.parse(raw);
|
|
215
|
+
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function resolveCanonicalSocketDir() {
|
|
222
|
+
// Mirror src/lib/socket-dir.js#resolveSocketDir — pure function, no fs
|
|
223
|
+
// touch. Inlined here so the sync discovery layer doesn't need a top-
|
|
224
|
+
// level await on the ESM module.
|
|
225
|
+
const xdg = process.env.XDG_RUNTIME_DIR;
|
|
226
|
+
const base = xdg && xdg.length > 0 ? xdg : '/tmp';
|
|
227
|
+
return path.join(base, 'pgserve');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isLivePid(pid) {
|
|
231
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
232
|
+
try {
|
|
233
|
+
process.kill(pid, 0);
|
|
234
|
+
return true;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return err && err.code === 'EPERM';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Compose a discovery view from runtime.json (preferred), admin.json
|
|
242
|
+
* (fallback), and config.json (legacy fallback). Returns:
|
|
243
|
+
* {
|
|
244
|
+
* runtime: { socketDir, port, pid, autopgPid, schemaVersion } | null,
|
|
245
|
+
* admin: { supervisor, socketDir, port, installedAt, ... } | null,
|
|
246
|
+
* config: { port, dataDir, registeredAt } | null,
|
|
247
|
+
* // composed view — best effort merge for callers that just want
|
|
248
|
+
* // "where do I connect right now?":
|
|
249
|
+
* socketDir: <string|null>,
|
|
250
|
+
* port: <number|null>,
|
|
251
|
+
* liveAutopg: <boolean> // true when runtime.json names a live pid
|
|
252
|
+
* }
|
|
253
|
+
*/
|
|
254
|
+
function readDiscovery() {
|
|
255
|
+
const config = readConfig();
|
|
256
|
+
const admin = readAdminJsonSync();
|
|
257
|
+
// Prefer the socket dir the supervisor recorded at install time — that's
|
|
258
|
+
// the path operators configured. Only fall back to the canonical resolver
|
|
259
|
+
// when the install record is missing (fresh-host case).
|
|
260
|
+
const socketDir = (admin && typeof admin.socketDir === 'string' && admin.socketDir.length > 0)
|
|
261
|
+
? admin.socketDir
|
|
262
|
+
: resolveCanonicalSocketDir();
|
|
263
|
+
const runtime = readRuntimeJsonSync(socketDir);
|
|
264
|
+
|
|
265
|
+
// PR #80 P2 fix: previous logic treated ANY parsed runtime.json as
|
|
266
|
+
// authoritative — a malformed-but-JSON file (no port, no socketDir) would
|
|
267
|
+
// hide later admin.json / config fallbacks because composedPort stayed
|
|
268
|
+
// null while the precedence chain stopped early. Validate that runtime
|
|
269
|
+
// actually carries a usable port + socketDir before treating it as live.
|
|
270
|
+
// Mirrors the admin / config branches' Number.isInteger guard.
|
|
271
|
+
let composedSocketDir = null;
|
|
272
|
+
let composedPort = null;
|
|
273
|
+
const runtimeUsable = runtime
|
|
274
|
+
&& Number.isInteger(runtime.port)
|
|
275
|
+
&& typeof runtime.socketDir === 'string'
|
|
276
|
+
&& runtime.socketDir.length > 0;
|
|
277
|
+
if (runtimeUsable) {
|
|
278
|
+
composedSocketDir = runtime.socketDir;
|
|
279
|
+
composedPort = runtime.port;
|
|
280
|
+
} else if (admin && Number.isInteger(admin.port)) {
|
|
281
|
+
composedSocketDir = admin.socketDir ?? socketDir;
|
|
282
|
+
composedPort = admin.port;
|
|
283
|
+
} else if (config && Number.isInteger(config.port)) {
|
|
284
|
+
composedPort = config.port;
|
|
285
|
+
composedSocketDir = socketDir;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
runtime,
|
|
290
|
+
admin,
|
|
291
|
+
config,
|
|
292
|
+
socketDir: composedSocketDir,
|
|
293
|
+
port: composedPort,
|
|
294
|
+
liveAutopg: !!(runtime && isLivePid(runtime.autopgPid)),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
168
298
|
function writeConfig(config) {
|
|
169
299
|
const dir = getConfigDir();
|
|
170
300
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
@@ -704,15 +834,20 @@ function writeSupervisorRecord(adminJson, { supervisor, socketDir, port }) {
|
|
|
704
834
|
/**
|
|
705
835
|
* `pgserve status [--json]`
|
|
706
836
|
*
|
|
707
|
-
* Reports both pm2 state and on-disk
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
837
|
+
* Reports both pm2 state and on-disk discovery (runtime.json → admin.json
|
|
838
|
+
* → config.json fallback chain). Exits 0 with status info regardless of
|
|
839
|
+
* running/stopped — operators script around the JSON output. Non-zero
|
|
840
|
+
* only when nothing was ever installed (no admin.json AND no config.json).
|
|
841
|
+
*
|
|
842
|
+
* Cutover G19: surfaces `runtime` (live socket discovery) and `socketDir`
|
|
843
|
+
* top-level so consumers can pick UDS vs TCP without parsing pm2 jlist.
|
|
711
844
|
*/
|
|
712
845
|
function cmdStatus(args) {
|
|
713
846
|
const json = args.includes('--json');
|
|
714
|
-
const
|
|
715
|
-
|
|
847
|
+
const discovery = readDiscovery();
|
|
848
|
+
const { config, admin, runtime } = discovery;
|
|
849
|
+
|
|
850
|
+
if (!config && !admin) {
|
|
716
851
|
if (json) {
|
|
717
852
|
process.stdout.write(`${JSON.stringify({ installed: false })}\n`);
|
|
718
853
|
} else {
|
|
@@ -720,24 +855,41 @@ function cmdStatus(args) {
|
|
|
720
855
|
}
|
|
721
856
|
return 1;
|
|
722
857
|
}
|
|
858
|
+
|
|
723
859
|
const proc = pm2GetProcess(PM2_PROCESS_NAME);
|
|
724
860
|
const status = proc?.pm2_env?.status ?? 'stopped';
|
|
725
861
|
const pid = proc?.pid ?? null;
|
|
726
862
|
const uptimeMs = proc?.pm2_env?.pm_uptime ? Date.now() - proc.pm2_env.pm_uptime : null;
|
|
727
863
|
const restarts = proc?.pm2_env?.restart_time ?? 0;
|
|
728
864
|
|
|
865
|
+
const port = discovery.port;
|
|
866
|
+
const socketDir = discovery.socketDir;
|
|
867
|
+
const dataDir = config?.dataDir ?? null;
|
|
868
|
+
|
|
729
869
|
const payload = {
|
|
730
870
|
installed: true,
|
|
731
871
|
name: PM2_PROCESS_NAME,
|
|
732
872
|
status,
|
|
733
873
|
pid,
|
|
734
|
-
port
|
|
735
|
-
|
|
874
|
+
port,
|
|
875
|
+
socketDir,
|
|
876
|
+
dataDir,
|
|
736
877
|
logsDir: getLogsDir(),
|
|
737
|
-
url: `postgres://localhost:${
|
|
878
|
+
url: port ? `postgres://localhost:${port}/postgres` : null,
|
|
738
879
|
uptimeMs,
|
|
739
880
|
restarts,
|
|
740
|
-
registeredAt: config
|
|
881
|
+
registeredAt: config?.registeredAt ?? null,
|
|
882
|
+
supervisor: admin?.supervisor ?? null,
|
|
883
|
+
runtime: runtime
|
|
884
|
+
? {
|
|
885
|
+
socketDir: runtime.socketDir,
|
|
886
|
+
port: runtime.port,
|
|
887
|
+
pid: runtime.pid,
|
|
888
|
+
autopgPid: runtime.autopgPid,
|
|
889
|
+
schemaVersion: runtime.schemaVersion,
|
|
890
|
+
live: discovery.liveAutopg,
|
|
891
|
+
}
|
|
892
|
+
: null,
|
|
741
893
|
};
|
|
742
894
|
|
|
743
895
|
if (json) {
|
|
@@ -746,42 +898,53 @@ function cmdStatus(args) {
|
|
|
746
898
|
}
|
|
747
899
|
process.stdout.write(`name ${payload.name}\n`);
|
|
748
900
|
process.stdout.write(`status ${payload.status}${payload.pid ? ` (pid ${payload.pid})` : ''}\n`);
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
901
|
+
if (payload.supervisor) {
|
|
902
|
+
process.stdout.write(`supervisor ${payload.supervisor}\n`);
|
|
903
|
+
}
|
|
904
|
+
if (payload.port != null) process.stdout.write(`port ${payload.port}\n`);
|
|
905
|
+
if (payload.url) process.stdout.write(`url ${payload.url}\n`);
|
|
906
|
+
if (payload.socketDir) process.stdout.write(`socketDir ${payload.socketDir}\n`);
|
|
907
|
+
if (payload.dataDir) process.stdout.write(`dataDir ${payload.dataDir}\n`);
|
|
752
908
|
process.stdout.write(`logsDir ${payload.logsDir}\n`);
|
|
909
|
+
if (payload.runtime) {
|
|
910
|
+
process.stdout.write(`runtime pid=${payload.runtime.pid} autopgPid=${payload.runtime.autopgPid} live=${payload.runtime.live}\n`);
|
|
911
|
+
} else {
|
|
912
|
+
process.stdout.write(`runtime (no runtime.json — postmaster down or never started)\n`);
|
|
913
|
+
}
|
|
753
914
|
if (payload.uptimeMs != null) {
|
|
754
915
|
const sec = Math.floor(payload.uptimeMs / 1000);
|
|
755
916
|
process.stdout.write(`uptime ${sec}s\n`);
|
|
756
917
|
}
|
|
757
918
|
process.stdout.write(`restarts ${payload.restarts}\n`);
|
|
758
|
-
process.stdout.write(`registered ${payload.registeredAt}\n`);
|
|
919
|
+
if (payload.registeredAt) process.stdout.write(`registered ${payload.registeredAt}\n`);
|
|
759
920
|
return 0;
|
|
760
921
|
}
|
|
761
922
|
|
|
762
923
|
/**
|
|
763
924
|
* `pgserve url`
|
|
764
925
|
*
|
|
765
|
-
* Discovery API. Prints the canonical connection string. Downstream
|
|
926
|
+
* Discovery API. Prints the canonical TCP connection string. Downstream
|
|
766
927
|
* installers (genie install, omni install) call this to learn where to
|
|
767
|
-
* connect, instead of hardcoding a port.
|
|
928
|
+
* connect, instead of hardcoding a port. The TCP form is stable across
|
|
929
|
+
* Tier A / Tier B / fingerprint-disabled hosts; UDS callers should
|
|
930
|
+
* resolve `<socketDir>/.s.PGSQL.<port>` from `autopg status --json`.
|
|
768
931
|
*/
|
|
769
932
|
function cmdUrl() {
|
|
770
|
-
const
|
|
771
|
-
if (
|
|
933
|
+
const discovery = readDiscovery();
|
|
934
|
+
if (discovery.port == null) {
|
|
772
935
|
fail('not installed (run: pgserve install)');
|
|
773
936
|
}
|
|
774
|
-
process.stdout.write(`postgres://localhost:${
|
|
937
|
+
process.stdout.write(`postgres://localhost:${discovery.port}/postgres\n`);
|
|
775
938
|
return 0;
|
|
776
939
|
}
|
|
777
940
|
|
|
778
|
-
/** `pgserve port` — print the canonical port. */
|
|
941
|
+
/** `pgserve port` — print the canonical port from runtime.json → admin.json → config.json. */
|
|
779
942
|
function cmdPort() {
|
|
780
|
-
const
|
|
781
|
-
if (
|
|
943
|
+
const discovery = readDiscovery();
|
|
944
|
+
if (discovery.port == null) {
|
|
782
945
|
fail('not installed (run: pgserve install)');
|
|
783
946
|
}
|
|
784
|
-
process.stdout.write(`${
|
|
947
|
+
process.stdout.write(`${discovery.port}\n`);
|
|
785
948
|
return 0;
|
|
786
949
|
}
|
|
787
950
|
|
|
@@ -913,6 +1076,13 @@ function dispatch(subcommand, args, ctx) {
|
|
|
913
1076
|
}
|
|
914
1077
|
case 'auth':
|
|
915
1078
|
return cmdAuthDispatch(args);
|
|
1079
|
+
case 'verify': {
|
|
1080
|
+
// pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
|
|
1081
|
+
// `pgserve verify` is a pure-node command (cosign shellout + HMAC
|
|
1082
|
+
// cache token write); routes through the same async-import pattern
|
|
1083
|
+
// as `uninstall` so the ESM module isn't eagerly loaded.
|
|
1084
|
+
return import('./commands/verify.js').then((mod) => mod.runVerify(args));
|
|
1085
|
+
}
|
|
916
1086
|
default:
|
|
917
1087
|
throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
|
|
918
1088
|
}
|
|
@@ -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
|
+
};
|