wardensec 0.0.0-stage → 0.1.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1 @@
1
+ Copyright 2026 The Warden Authors. All rights reserved. Use is permitted only under the terms at https://wardensec.ai/legal/terms and a valid Warden licence file.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # wardensec
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Installs Warden, the self-hosted AWS and Kubernetes security software, from a terminal where Node is installed.
4
+
5
+ ```
6
+ npx wardensec@alpha
7
+ ```
8
+
9
+ or `npm i -g wardensec@alpha && wardensec`.
10
+
11
+ This package carries one file that matters: `install.sh`, the same installer https://wardensec.ai/install.sh serves,
12
+ byte for byte. The command writes it to a private temporary directory and runs it with `sh`, passing your arguments on
13
+ (`--now`, `--root-sha256=<hex>`). The installer checks every release against its built-in root and ends in the same
14
+ launcher and the same one approval in your console as the `curl` line. The package adds no verifier and no root of its
15
+ own, opens no connection of its own, and runs no code when it is installed.
16
+
17
+ Check the bytes before you run them:
18
+
19
+ ```
20
+ npm pack wardensec@alpha
21
+ tar -xOf wardensec-*.tgz package/install.sh | sha256sum
22
+ ```
23
+
24
+ The result must equal an `installer sha256` line of the public trust record,
25
+ https://raw.githubusercontent.com/agent-labs-dev/warden-trust/main/ROOTS.txt. `npx wardensec@alpha --help` prints the
26
+ same hash and runs nothing.
27
+
28
+ It needs a POSIX shell: macOS, Linux, or WSL on Windows. Elsewhere it stops with one line and the `curl` command.
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // The npm entry to the Warden install. It carries the one released install.sh, byte for byte, and runs it with sh.
5
+ // It holds no verifier and no root of its own: install.sh checks every release against its built-in root, and the
6
+ // bytes it runs are the ones the public trust record names. This file only carries them. It opens no connection;
7
+ // the installer makes every fetch.
8
+
9
+ const fs = require("node:fs");
10
+ const os = require("node:os");
11
+ const path = require("node:path");
12
+ const crypto = require("node:crypto");
13
+ const childProcess = require("node:child_process");
14
+
15
+ // Filled in when the package is packed (tools/release/npm-pack.ts).
16
+ const BUILT_IN = { version: "0.1.0-alpha.1", installerSha256: "41db9d8809bbd9dab61f84753b3a6605115d6db9bd9a79f26a9e5c7e5629e9b1" };
17
+
18
+ const CURL_LINE = "curl -fsSL https://wardensec.ai/install | sh";
19
+ const TRUST_RECORD = "https://raw.githubusercontent.com/agent-labs-dev/warden-trust/main/ROOTS.txt";
20
+ const NO_SHELL = `Warden installs from a POSIX shell (macOS, Linux, or WSL on Windows). There, run: ${CURL_LINE}\n`;
21
+
22
+ function defaults() {
23
+ return {
24
+ argv: process.argv.slice(2),
25
+ platform: process.platform,
26
+ sh: "/bin/sh",
27
+ pkgDir: path.resolve(__dirname, ".."),
28
+ tmp: os.tmpdir(),
29
+ uid: typeof process.getuid === "function" ? process.getuid() : -1,
30
+ version: BUILT_IN.version,
31
+ installerSha256: BUILT_IN.installerSha256,
32
+ environ: process.env,
33
+ out: (s) => process.stdout.write(s),
34
+ err: (s) => process.stderr.write(s),
35
+ };
36
+ }
37
+
38
+ function helpText(e) {
39
+ return [
40
+ `wardensec ${e.version}: runs the Warden installer this package carries.`,
41
+ `install.sh sha256 ${e.installerSha256}; compare it with the installer lines of ${TRUST_RECORD}`,
42
+ "Any other arguments go to the installer: --now, --root-sha256=<hex>.",
43
+ "",
44
+ ].join("\n");
45
+ }
46
+
47
+ // `npx` puts every ./node_modules/.bin from here up the tree in front of PATH and adds npm_* and NODE_* variables.
48
+ // install.sh calls ssh-keygen, sha256sum, awk and curl through PATH, so a project folder must not choose them: the
49
+ // installer gets the PATH the user's shell had, and no variable that npm or node added.
50
+ function cleanEnv(environ) {
51
+ const out = {};
52
+ for (const [k, v] of Object.entries(environ)) {
53
+ if (/^(npm_|NODE_)/i.test(k) || k === "NODE" || k === "INIT_CWD") continue;
54
+ out[k] = v;
55
+ }
56
+ const key = Object.keys(out).find((k) => k.toUpperCase() === "PATH");
57
+ if (key !== undefined && typeof out[key] === "string") {
58
+ out[key] = out[key]
59
+ .split(path.delimiter)
60
+ .filter((d) => !/(^|[\\/])node_modules[\\/]\.bin[\\/]*$/.test(d))
61
+ .join(path.delimiter);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function hasShell(sh) {
67
+ try {
68
+ fs.accessSync(sh, fs.constants.X_OK);
69
+ return fs.statSync(sh).isFile();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function privateDir(e) {
76
+ const dir = fs.mkdtempSync(path.join(e.tmp, "wardensec-"));
77
+ try {
78
+ fs.chmodSync(dir, 0o700);
79
+ const st = fs.lstatSync(dir);
80
+ if (!st.isDirectory() || st.uid !== e.uid || (st.mode & 0o777) !== 0o700) throw new Error("not private");
81
+ } catch (err) {
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ throw err;
84
+ }
85
+ return dir;
86
+ }
87
+
88
+ /** Runs the package. Returns { code } or { signal }; the caller exits with it. */
89
+ function main(env) {
90
+ const e = { ...defaults(), ...env };
91
+ if (e.argv.length === 1 && (e.argv[0] === "--help" || e.argv[0] === "--version")) {
92
+ e.out(helpText(e));
93
+ return { code: 0 };
94
+ }
95
+ if (e.platform === "win32" || !hasShell(e.sh)) {
96
+ e.err(NO_SHELL);
97
+ return { code: 1 };
98
+ }
99
+ let bytes;
100
+ try {
101
+ bytes = fs.readFileSync(path.join(e.pkgDir, "install.sh"));
102
+ } catch {
103
+ bytes = Buffer.alloc(0);
104
+ }
105
+ if (crypto.createHash("sha256").update(bytes).digest("hex") !== e.installerSha256) {
106
+ e.err(
107
+ `wardensec: the install.sh in this package does not match the sha256 it names; nothing ran. Reinstall it, or run: ${CURL_LINE}\n`,
108
+ );
109
+ return { code: 1 };
110
+ }
111
+ let dir;
112
+ try {
113
+ dir = privateDir(e);
114
+ } catch {
115
+ e.err("wardensec: could not make a private temporary directory; nothing ran.\n");
116
+ return { code: 1 };
117
+ }
118
+ const ignore = () => {
119
+ // Wait for the installer; the signal reaches it directly.
120
+ };
121
+ const signals = ["SIGINT", "SIGTERM"];
122
+ try {
123
+ const file = path.join(dir, "install.sh");
124
+ fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
125
+ // The terminal sends Ctrl-C to the installer too; this process waits for it and reports how it ended.
126
+ for (const s of signals) process.on(s, ignore);
127
+ const r = childProcess.spawnSync(e.sh, [file, ...e.argv], { stdio: "inherit", env: cleanEnv(e.environ) });
128
+ if (r.error) {
129
+ e.err(`wardensec: could not start sh: ${r.error.message}\n`);
130
+ return { code: 1 };
131
+ }
132
+ if (r.signal) return { signal: r.signal };
133
+ return { code: r.status === null ? 1 : r.status };
134
+ } finally {
135
+ for (const s of signals) process.removeListener(s, ignore);
136
+ fs.rmSync(dir, { recursive: true, force: true });
137
+ }
138
+ }
139
+
140
+ /** Ends this process the way the installer ended: its exit code, or its signal re-raised. */
141
+ function finish(r, p) {
142
+ if (r.signal) {
143
+ p.kill(p.pid, r.signal);
144
+ // A signal this process ignores (SIGPIPE) leaves it running: report what a shell reports, 128 plus the number.
145
+ p.exitCode = 128 + (os.constants.signals[r.signal] || 0);
146
+ return;
147
+ }
148
+ p.exitCode = r.code === undefined ? 1 : r.code;
149
+ }
150
+
151
+ if (require.main === module) finish(main(), process);
152
+
153
+ module.exports = { main, helpText, cleanEnv, finish };
package/install.sh ADDED
@@ -0,0 +1,710 @@
1
+ #!/bin/sh
2
+ # Warden installer, served at https://wardensec.ai/install (distribution design 3.7).
3
+ #
4
+ # It fetches only from https://wardensec.ai/releases/, checks every record against the built-in
5
+ # root 1 with the shell verifier below, and installs one file, bin/warden, with its trust files
6
+ # under ${XDG_DATA_HOME:-$HOME/.local/share}/warden, plus at most one symlink in ~/.local/bin.
7
+ # No sudo, no package manager, no shell start-up file, no eval, nothing in the background, and
8
+ # nothing about this machine is sent anywhere. No byte runs before its hash matched a signed record.
9
+ #
10
+ # v1.0: the release log has no witnesses yet. This script requires the release signatures only and
11
+ # refuses a root that asks for witness cosignatures. It prints the root fingerprints so you can
12
+ # compare them with the ones published outside this website (https://wardensec.ai/docs/verify-install
13
+ # names where). Given --root-sha256=<hex>, it compares root 1 before it fetches anything. Every check
14
+ # here runs inside this script, so none of them can catch a tampered copy of it: compare this file's
15
+ # sha256 with the installer lines of the public trust record before you run it. It never starts setup.
16
+ #
17
+ # The whole body runs from main(), called on the last line, so a cut download runs nothing.
18
+
19
+ set -eu
20
+
21
+ # >>> sshsig_ok from launcher/sshsig-ok.sh sha256=73144cc52a1ff058ed24e2d460e3dbf914f4ce980055ec39706e617f9f2e334b (generated; edit the source, not this copy)
22
+ # shellcheck shell=sh
23
+ # sshsig_ok: the shell's one verdict on an SSHSIG signature (distribution 3.7 step 2, 12.3 L5).
24
+ #
25
+ # This file is the one source. tools/launcher.ts embeds it, byte for byte, in the launcher and the one-line
26
+ # installer; never edit an embedded copy. POSIX sh: it runs under dash and bash --posix, and needs only ssh-keygen,
27
+ # base64 (-d or -D), od, tr, wc, awk and mktemp.
28
+ #
29
+ # ssh-keygen -Y verify checks the cryptography, the namespace and the principal, but enforces neither user presence,
30
+ # user verification nor the signature counter, and tolerates armour and fields the TypeScript verifier
31
+ # (src/release/sshsig.ts) refuses. So this function also reads the signature itself: strict armour, the exact blob
32
+ # structure for the named key and namespace (version 1, empty reserved field, sha512, nothing trailing), and then the
33
+ # five bytes the authenticator signed after the signature: flags must be exactly UP|UV (0x05) and the counter above 0
34
+ # and above the stored one. src/release/sshsig.differential.test.ts holds this verdict equal to the TypeScript one.
35
+ #
36
+ # sshsig_ok NAMESPACE KID ALLOWED_SIGNERS SIG MESSAGE [STORED_COUNTER]
37
+ #
38
+ # ALLOWED_SIGNERS holds one line per key, `<kid> namespaces="<namespace>" <type> <base64 key>`, built by the caller
39
+ # from verified roots; KID names the one line whose key must have made SIG over the bytes of MESSAGE.
40
+ # STORED_COUNTER (decimal, default 0) is the counter already seen for KID; an index signature must exceed it.
41
+ # 0 the signature counts. SSHSIG_COUNTER holds its counter (empty for an ssh-ed25519 key, which has none), and
42
+ # SSHSIG_CLASS the key's class from its type alone: `software` for ssh-ed25519 (a password
43
+ # manager's SSH agent, a file, a witness), `security_key` for sk-ssh-ed25519@openssh.com. Empty on any refusal.
44
+ # 1 it does not. SSHSIG_REFUSED holds one closed code: TOOL ARMOUR OVERSIZE STRUCTURE KEY VERIFY FLAGS COUNTER
45
+ # COUNTER_STALE.
46
+ # 2 the caller's arguments are wrong (SSHSIG_REFUSED=ARGS): bad namespace or kid shape, no single matching
47
+ # allowed line, a stored counter that is not plain decimal, or a stored counter for a key that has none.
48
+ # It writes nothing to stdout or stderr and never evaluates what it reads.
49
+
50
+ SSHSIG_COUNTER=
51
+ SSHSIG_CLASS=
52
+ SSHSIG_REFUSED=
53
+ _wd_b64_flag=
54
+
55
+ # Chooses the decoder once per shell: `base64 -d` (GNU, BusyBox, recent macOS) or `base64 -D` (older macOS).
56
+ _wd_b64_init() {
57
+ [ -n "$_wd_b64_flag" ] && return 0
58
+ if [ "$(printf 'QQ==' | base64 -d 2>/dev/null)" = A ]; then
59
+ _wd_b64_flag=-d
60
+ elif [ "$(printf 'QQ==' | base64 -D 2>/dev/null)" = A ]; then
61
+ _wd_b64_flag=-D
62
+ else
63
+ return 1
64
+ fi
65
+ }
66
+
67
+ _wd_b64d() {
68
+ base64 "$_wd_b64_flag" 2>/dev/null
69
+ }
70
+
71
+ _wd_hex() {
72
+ od -An -tx1 -v | tr -d ' \t\n'
73
+ }
74
+
75
+ sshsig_ok() {
76
+ SSHSIG_COUNTER=
77
+ SSHSIG_CLASS=
78
+ SSHSIG_REFUSED=ARGS
79
+ [ "$#" -eq 5 ] || [ "$#" -eq 6 ] || return 2
80
+ _wd_ns=$1
81
+ _wd_kid=$2
82
+ _wd_as=$3
83
+ _wd_sig=$4
84
+ _wd_msg=$5
85
+ _wd_min=${6-0}
86
+ case $_wd_ns in
87
+ '' | *[!a-z0-9-]*) return 2 ;;
88
+ esac
89
+ case $_wd_kid in
90
+ wd[rw]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
91
+ *) return 2 ;;
92
+ esac
93
+ case $_wd_min in
94
+ '' | *[!0-9]* | 0?*) return 2 ;;
95
+ esac
96
+ [ "${#_wd_min}" -le 10 ] && [ "$_wd_min" -le 4294967295 ] || return 2
97
+ [ -f "$_wd_as" ] && [ -r "$_wd_as" ] && [ -f "$_wd_msg" ] && [ -r "$_wd_msg" ] || return 2
98
+
99
+ # The one allowed line for the kid, with exactly this namespace option.
100
+ _wd_line=$(LC_ALL=C awk -v k="$_wd_kid" -v o="namespaces=\"$_wd_ns\"" '
101
+ $1 == k { n++; if (NF == 4 && $2 == o) t = $3 " " $4; else t = "" }
102
+ END { if (n == 1 && t != "") print t }' "$_wd_as") || return 2
103
+ [ -n "$_wd_line" ] || return 2
104
+ _wd_type=${_wd_line%% *}
105
+ _wd_kb64=${_wd_line#* }
106
+ case $_wd_type in
107
+ sk-ssh-ed25519@openssh.com) _wd_sk=1 ;;
108
+ ssh-ed25519)
109
+ _wd_sk=0
110
+ [ "$_wd_min" = 0 ] || return 2
111
+ ;;
112
+ *) return 2 ;;
113
+ esac
114
+ case $_wd_kb64 in
115
+ '' | *[!A-Za-z0-9+/=]*) return 2 ;;
116
+ esac
117
+ _wd_b64_init || {
118
+ SSHSIG_REFUSED=TOOL
119
+ return 1
120
+ }
121
+ _wd_khex=$(printf '%s' "$_wd_kb64" | _wd_b64d | _wd_hex) || return 2
122
+ _wd_thex=$(printf '%08x' "${#_wd_type}")$(printf '%s' "$_wd_type" | _wd_hex) || return 2
123
+ case $_wd_khex in
124
+ "$_wd_thex"00000020*) ;;
125
+ *) return 2 ;;
126
+ esac
127
+ if [ "$_wd_sk" = 1 ]; then
128
+ # string(type) string(32-byte key) string(application)
129
+ [ "${#_wd_khex}" -ge 140 ] || return 2
130
+ _wd_al=$(printf '%s' "$_wd_khex" | cut -c 133-140) || return 2
131
+ [ "${#_wd_khex}" -eq $((140 + 2 * 0x$_wd_al)) ] || return 2
132
+ else
133
+ [ "${#_wd_khex}" -eq 102 ] || return 2
134
+ fi
135
+
136
+ # Strict armour: printable ASCII and LF only, exact header and footer, body lines of 1 to 76 base64 characters,
137
+ # canonical base64, at most 4096 bytes of text and 1024 bytes of blob.
138
+ [ -f "$_wd_sig" ] && [ -r "$_wd_sig" ] || {
139
+ SSHSIG_REFUSED=ARMOUR
140
+ return 1
141
+ }
142
+ _wd_size=$(wc -c <"$_wd_sig" | tr -d ' \t') || return 1
143
+ [ "$_wd_size" -le 4096 ] || {
144
+ SSHSIG_REFUSED=OVERSIZE
145
+ return 1
146
+ }
147
+ SSHSIG_REFUSED=ARMOUR
148
+ _wd_bad=$(LC_ALL=C tr -d '\n\040-\176' <"$_wd_sig" | wc -c | tr -d ' \t') || return 1
149
+ [ "$_wd_bad" = 0 ] || return 1
150
+ _wd_b64=$(LC_ALL=C awk '
151
+ { l[NR] = $0 }
152
+ END {
153
+ if (NR < 3 || l[1] != "-----BEGIN SSH SIGNATURE-----" || l[NR] != "-----END SSH SIGNATURE-----") exit 1
154
+ s = ""
155
+ for (i = 2; i < NR; i++) {
156
+ if (length(l[i]) > 76 || l[i] !~ /^[A-Za-z0-9+\/=]+$/) exit 1
157
+ s = s l[i]
158
+ }
159
+ if (s !~ /^[A-Za-z0-9+\/]+(=|==)?$/ || length(s) % 4 != 0) exit 1
160
+ print s
161
+ }' "$_wd_sig") || return 1
162
+ [ -n "$_wd_b64" ] || return 1
163
+ case $_wd_b64 in
164
+ *==) _wd_pad=2 ;;
165
+ *=) _wd_pad=1 ;;
166
+ *) _wd_pad=0 ;;
167
+ esac
168
+ [ $((${#_wd_b64} / 4 * 3 - _wd_pad)) -le 1024 ] || {
169
+ SSHSIG_REFUSED=OVERSIZE
170
+ return 1
171
+ }
172
+ _wd_re=$(printf '%s' "$_wd_b64" | _wd_b64d | base64 | tr -d '\r\n') || return 1
173
+ [ "$_wd_re" = "$_wd_b64" ] || return 1
174
+ _wd_shex=$(printf '%s' "$_wd_b64" | _wd_b64d | _wd_hex) || return 1
175
+
176
+ # The exact structure: "SSHSIG", version 1, the named key, the namespace, an empty reserved field, sha512, then a
177
+ # signature blob of the key's type holding a 64-byte signature and, for an sk key, the flags and counter.
178
+ _wd_head=53534853494700000001
179
+ case $_wd_shex in
180
+ "$_wd_head"*) ;;
181
+ *)
182
+ SSHSIG_REFUSED=STRUCTURE
183
+ return 1
184
+ ;;
185
+ esac
186
+ _wd_want=$_wd_head$(printf '%08x' $((${#_wd_khex} / 2)))$_wd_khex
187
+ case $_wd_shex in
188
+ "$_wd_want"*) ;;
189
+ *)
190
+ SSHSIG_REFUSED=KEY
191
+ return 1
192
+ ;;
193
+ esac
194
+ if [ "$_wd_sk" = 1 ]; then
195
+ _wd_slen=103
196
+ _wd_tail=10
197
+ else
198
+ _wd_slen=83
199
+ _wd_tail=0
200
+ fi
201
+ _wd_nshex=$(printf '%s' "$_wd_ns" | _wd_hex) || return 1
202
+ _wd_want=$_wd_want$(printf '%08x' "${#_wd_ns}")$_wd_nshex
203
+ _wd_want=${_wd_want}00000000
204
+ _wd_want=${_wd_want}00000006736861353132
205
+ _wd_want=$_wd_want$(printf '%08x' "$_wd_slen")$_wd_thex
206
+ _wd_want=${_wd_want}00000040
207
+ SSHSIG_REFUSED=STRUCTURE
208
+ case $_wd_shex in
209
+ "$_wd_want"*) ;;
210
+ *) return 1 ;;
211
+ esac
212
+ [ "${#_wd_shex}" -eq $((${#_wd_want} + 128 + _wd_tail)) ] || return 1
213
+
214
+ SSHSIG_REFUSED=VERIFY
215
+ ssh-keygen -Y verify -n "$_wd_ns" -I "$_wd_kid" -f "$_wd_as" -s "$_wd_sig" <"$_wd_msg" >/dev/null 2>&1 || return 1
216
+
217
+ if [ "$_wd_sk" = 1 ]; then
218
+ # The last five bytes, covered by the signature ssh-keygen has just checked: flags, then the counter (big-endian).
219
+ _wd_fc=${_wd_shex#"${_wd_shex%??????????}"}
220
+ _wd_flags=${_wd_fc%????????}
221
+ _wd_ctr=$((0x${_wd_fc#??}))
222
+ SSHSIG_REFUSED=FLAGS
223
+ [ "$_wd_flags" = 05 ] || return 1
224
+ SSHSIG_REFUSED=COUNTER
225
+ [ "$_wd_ctr" -gt 0 ] || return 1
226
+ SSHSIG_REFUSED=COUNTER_STALE
227
+ [ "$_wd_ctr" -gt "$_wd_min" ] || return 1
228
+ SSHSIG_COUNTER=$_wd_ctr
229
+ SSHSIG_CLASS=security_key
230
+ else
231
+ SSHSIG_CLASS=software
232
+ fi
233
+ SSHSIG_REFUSED=
234
+ return 0
235
+ }
236
+
237
+ # The self-test (3.7 step 2): four built-in vectors over one built-in sk-ssh-ed25519 key. Flags 0x05 with counter 7
238
+ # must count; flags 0x01, 0x04 and 0x00 must each be refused by the flag reader after ssh-keygen accepted them. It
239
+ # runs in a subshell, in a private mktemp directory removed on exit, and touches nothing else.
240
+ sshsig_selftest() (
241
+ _wd_d=$(mktemp -d 2>/dev/null) || exit 1
242
+ [ -n "$_wd_d" ] && [ -d "$_wd_d" ] || exit 1
243
+ trap 'rm -rf "$_wd_d"' EXIT
244
+ trap 'exit 1' HUP INT TERM
245
+ _wd_k=wdr-4943def14ec1d7f0
246
+ _wd_n=wardensec-release-v1
247
+ printf '%s\n' 'wardensec sshsig fixture' >"$_wd_d/m"
248
+ printf '%s\n' 'wdr-4943def14ec1d7f0 namespaces="wardensec-release-v1" sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAICia/v4KKmAwny1nGulB8OALFvkREDhrsovegylyCr/TAAAAFXNzaDp3YXJkZW5zZWMtcmVsZWFzZQ==' >"$_wd_d/a"
249
+ cat >"$_wd_d/05" <<'EOF'
250
+ -----BEGIN SSH SIGNATURE-----
251
+ U1NIU0lHAAAAAQAAAFsAAAAac2stc3NoLWVkMjU1MTlAb3BlbnNzaC5jb20AAAAgKJr+/g
252
+ oqYDCfLWca6UHw4AsW+REQOGuyi96DKXIKv9MAAAAVc3NoOndhcmRlbnNlYy1yZWxlYXNl
253
+ AAAAFHdhcmRlbnNlYy1yZWxlYXNlLXYxAAAAAAAAAAZzaGE1MTIAAABnAAAAGnNrLXNzaC
254
+ 1lZDI1NTE5QG9wZW5zc2guY29tAAAAQFYGQMLta9uAYU6ML3UfQn0c11b2h8NROW3c5uQb
255
+ Uo+vK7lr0FJN5FHPsM2546fb9VdU7AtL+M59u5y6rUCCfw8FAAAABw==
256
+ -----END SSH SIGNATURE-----
257
+ EOF
258
+ cat >"$_wd_d/01" <<'EOF'
259
+ -----BEGIN SSH SIGNATURE-----
260
+ U1NIU0lHAAAAAQAAAFsAAAAac2stc3NoLWVkMjU1MTlAb3BlbnNzaC5jb20AAAAgKJr+/g
261
+ oqYDCfLWca6UHw4AsW+REQOGuyi96DKXIKv9MAAAAVc3NoOndhcmRlbnNlYy1yZWxlYXNl
262
+ AAAAFHdhcmRlbnNlYy1yZWxlYXNlLXYxAAAAAAAAAAZzaGE1MTIAAABnAAAAGnNrLXNzaC
263
+ 1lZDI1NTE5QG9wZW5zc2guY29tAAAAQKYYoMWy25Ek/TRiZXZUtHEL2ACbqgLdakDZ2fjc
264
+ HX8hpsJH0QuDNmCaXkU1xjcP+5FFL9flyruPuVo8yASySw0BAAAABw==
265
+ -----END SSH SIGNATURE-----
266
+ EOF
267
+ cat >"$_wd_d/04" <<'EOF'
268
+ -----BEGIN SSH SIGNATURE-----
269
+ U1NIU0lHAAAAAQAAAFsAAAAac2stc3NoLWVkMjU1MTlAb3BlbnNzaC5jb20AAAAgKJr+/g
270
+ oqYDCfLWca6UHw4AsW+REQOGuyi96DKXIKv9MAAAAVc3NoOndhcmRlbnNlYy1yZWxlYXNl
271
+ AAAAFHdhcmRlbnNlYy1yZWxlYXNlLXYxAAAAAAAAAAZzaGE1MTIAAABnAAAAGnNrLXNzaC
272
+ 1lZDI1NTE5QG9wZW5zc2guY29tAAAAQLC5HvpXVdqOhDGufOQY88kLch8uMRI9QpX2sXL9
273
+ 1n+vjo3Q33kwBMAzQHwxOPzTYNW0zqfgWjZQNfT8Ajstrg8EAAAABw==
274
+ -----END SSH SIGNATURE-----
275
+ EOF
276
+ cat >"$_wd_d/00" <<'EOF'
277
+ -----BEGIN SSH SIGNATURE-----
278
+ U1NIU0lHAAAAAQAAAFsAAAAac2stc3NoLWVkMjU1MTlAb3BlbnNzaC5jb20AAAAgKJr+/g
279
+ oqYDCfLWca6UHw4AsW+REQOGuyi96DKXIKv9MAAAAVc3NoOndhcmRlbnNlYy1yZWxlYXNl
280
+ AAAAFHdhcmRlbnNlYy1yZWxlYXNlLXYxAAAAAAAAAAZzaGE1MTIAAABnAAAAGnNrLXNzaC
281
+ 1lZDI1NTE5QG9wZW5zc2guY29tAAAAQF4YP7F3B4WfhVoXkr7hjxzmjmcJb3RJ2bsCHV9t
282
+ RURUTTXWOcZJSiVHVf5bWaQdKYONT4/Bqttasw1XZtepUAwAAAAABw==
283
+ -----END SSH SIGNATURE-----
284
+ EOF
285
+ sshsig_ok "$_wd_n" "$_wd_k" "$_wd_d/a" "$_wd_d/05" "$_wd_d/m" || exit 1
286
+ [ "$SSHSIG_COUNTER" = 7 ] && [ "$SSHSIG_CLASS" = security_key ] || exit 1
287
+ for _wd_v in 01 04 00; do
288
+ if sshsig_ok "$_wd_n" "$_wd_k" "$_wd_d/a" "$_wd_d/$_wd_v" "$_wd_d/m"; then exit 1; fi
289
+ [ "$SSHSIG_REFUSED" = FLAGS ] || exit 1
290
+ done
291
+ exit 0
292
+ )
293
+
294
+ # The self-test, with the one sentence a person sees when this machine cannot verify the way Warden needs.
295
+ sshsig_require_selftest() {
296
+ if sshsig_selftest; then
297
+ return 0
298
+ fi
299
+ printf '%s\n' "This machine's OpenSSH cannot verify security-key signatures the way Warden needs (8.2 or later is needed). Nothing was installed." >&2
300
+ return 1
301
+ }
302
+ # <<< sshsig_ok
303
+
304
+ # The software-key self-test (amendment 02 B4.5), beside the four sk vectors above: one ssh-ed25519 signature made by
305
+ # ssh-keygen must count, with no counter, and the same signature over other bytes must not.
306
+ in_selftest_soft() (
307
+ in_d=$(mktemp -d 2>/dev/null) && [ -n "$in_d" ] && [ -d "$in_d" ] || exit 1
308
+ trap 'rm -rf "$in_d"' EXIT
309
+ trap 'exit 1' HUP INT TERM
310
+ in_k=wdr-c956dff44e52ea56
311
+ printf '%s\n' 'wdr-c956dff44e52ea56 namespaces="wardensec-release-v1" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBEfZCqnq84xy0t2BUTKqofV0oE/Fk2TyEYTkeNB7dtm' >"$in_d/a"
312
+ printf '%s\n' 'wardensec sshsig fixture' >"$in_d/m"
313
+ cat >"$in_d/s" <<'EOF'
314
+ -----BEGIN SSH SIGNATURE-----
315
+ U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgER9kKqerzjHLS3YFRMqqh9XSgT
316
+ 8WTZPIRhOR40Ht22YAAAAUd2FyZGVuc2VjLXJlbGVhc2UtdjEAAAAAAAAABnNoYTUxMgAA
317
+ AFMAAAALc3NoLWVkMjU1MTkAAABAZKPSaQr/vz1A6RySaYLItCG/erBJGh4HTukig3Kd3J
318
+ i2K7suY9VntNz0sVc8egbHzqGfvOJyy731t7JdY/TuBg==
319
+ -----END SSH SIGNATURE-----
320
+ EOF
321
+ sshsig_ok wardensec-release-v1 "$in_k" "$in_d/a" "$in_d/s" "$in_d/m" && [ -z "$SSHSIG_COUNTER" ] || exit 1
322
+ printf '%s\n' 'wardensec sshsig fixturf' >"$in_d/m"
323
+ if sshsig_ok wardensec-release-v1 "$in_k" "$in_d/a" "$in_d/s" "$in_d/m"; then exit 1; fi
324
+ [ "$SSHSIG_REFUSED" = VERIFY ]
325
+ )
326
+
327
+ # Root 1, built in. Every later root, index and release must chain to it.
328
+ in_root1() {
329
+ cat <<'WD_ROOT_1'
330
+ wardensec-root 1
331
+ serial 1
332
+ issued 2026-10-31T20:04:01Z
333
+ threshold root 2
334
+ threshold release 2
335
+ threshold witness 0
336
+ distinct-custody
337
+ key wdr-7a1d75603ba41504 root ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJsDCHa2CqJAUYF2WaPSNHCj6UgNSN3oyrZHlYlfuI9
338
+ key wdr-dc7dffa7195fda90 root ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHY7aN44mL2Ubiw3L7sXXNQq/l5/9z03b5KZ4fZLlJsM
339
+ key wdr-617fb4b50c6681a6 release ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHgicMETDmigeeAMQHwWqaSIiPPraQLnf6Elq5bKoJZf
340
+ key wdr-3184059dcd400670 release ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH4/Hy5Gq9hkHeSPDkntlkqFdXVtOIK0NWFtwCakwtTq
341
+ custody wdr-7a1d75603ba41504 vault-root
342
+ custody wdr-dc7dffa7195fda90 kms-root
343
+ custody wdr-617fb4b50c6681a6 vault-a
344
+ custody wdr-3184059dcd400670 kms-1
345
+ end
346
+ WD_ROOT_1
347
+ }
348
+
349
+ in_say() { printf '%s\n' "$@"; }
350
+
351
+ # Every refusal is a closed code and a fixed sentence; nothing fetched is ever printed.
352
+ in_die() { printf '%s\n' "Warden install stopped ($1): $2" "Nothing was installed." >&2; exit 1; }
353
+
354
+ in_sha() {
355
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum <"$1"; else shasum -a 256 <"$1"; fi | awk '{ print $1 }'
356
+ }
357
+
358
+ in_hex() { printf '%s\n' "$1" | LC_ALL=C grep -Eqx '[0-9a-f]{64}'; }
359
+
360
+ in_int() { case $1 in '' | *[!0-9]* | 0*) return 1 ;; esac && [ "${#1}" -le 9 ]; }
361
+
362
+ in_iso() { case $1 in [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z) ;; *) return 1 ;; esac; }
363
+
364
+ # A release name as the index may give it: v<major>.<minor>.<patch>, then only lowercase letters, digits, dots, dashes.
365
+ in_relname() { case $1 in v[0-9]*.[0-9]*.[0-9]*) case $1 in *[!0-9a-z.-]*) return 1 ;; esac ;; *) return 1 ;; esac; }
366
+
367
+ in_bad() { in_die "$1" "the $2 is not well formed."; }
368
+
369
+ # GET one release file into the work directory: one host, HTTPS only, no redirect, no query. in_code keeps the HTTP
370
+ # status the site answered (000 when no answer came); it is compared, never printed.
371
+ in_get() {
372
+ rm -f "$in_w/$2"
373
+ in_code=$(curl --proto '=https' --tlsv1.2 --max-redirs 0 --max-filesize 400000000 -fsS -A warden-installer \
374
+ -w '%{http_code}' -o "$in_w/$2" "https://wardensec.ai/releases/$1" 2>/dev/null)
375
+ }
376
+
377
+ # A text record: ASCII, LF only, single spaces, no blank line, at most 64 KiB, and `end` last.
378
+ in_record() {
379
+ [ -f "$1" ] && in_sz=$(wc -c <"$1" | tr -d ' ') && [ "$in_sz" -gt 0 ] && [ "$in_sz" -le 65536 ] || return 1
380
+ [ "$(LC_ALL=C tr -d '\n -~' <"$1" | wc -c | tr -d ' ')" = 0 ] || return 1
381
+ [ "$(tail -c 1 "$1" | od -An -tx1 | tr -d ' \n')" = 0a ] || return 1
382
+ LC_ALL=C awk '$0 == "" || /^ / || / $/ || / / { bad = 1 } { last = $0 } END { exit (bad || last != "end") }' "$1"
383
+ }
384
+
385
+ # The value of the one two-field line `KEY value`; fails when there is none or more than one.
386
+ in_val() { LC_ALL=C awk -v k="$2" '$1 == k { n++; v = $2; f = NF } END { if (n != 1 || f != 2) exit 1; print v }' "$1"; }
387
+
388
+ in_thr() { LC_ALL=C awk -v r="$2" '$1 == "threshold" && $2 == r { print $3 }' "$1"; }
389
+
390
+ # A root file's shape (amendment 02 B4.3): prints ok, test (its `test` line: never trusted), below (a root or release
391
+ # key under the class its `require` line asks), witness (it asks for cosignatures this installer cannot check yet) or
392
+ # bad. Lines come in the product's order. A key's class is its type: ssh-ed25519 is a software key (0),
393
+ # sk-ssh-ed25519@openssh.com a security key (1); no attestation is verified here, so none is ever class 2. The
394
+ # advisory feed's keys (amendment 05 3.4) are accepted and never counted: zero to four `key <wdc-kid> curator` lines
395
+ # after the witnesses, zero or two to four `spki <wdf-kid> feed <base64 DER>` lines after them, a `custody` line for
396
+ # each feed key (at least two labels among them) and optionally each curator, in key order, and the `distrust` and
397
+ # `retire <wdf-kid> feed-seq <n>` forms. The product's parity vectors (tests/fixtures/root-grammar-vectors.json)
398
+ # hold this grammar to its parser.
399
+ in_root_check() {
400
+ if ! in_record "$1" || [ "$(head -n 1 "$1")" != "wardensec-root 1" ] || [ "$(in_val "$1" serial)" != "$2" ]; then
401
+ echo bad && return 0
402
+ fi
403
+ LC_ALL=C awk '
404
+ BEGIN {
405
+ n = split("wardensec-root test serial issued threshold require distinct-custody delay key spki attest custody log distrust retire reset end", o, " ")
406
+ for (i = 1; i <= n; i++) rk[o[i]] = i
407
+ split("threshold key spki attest custody distrust retire", m, " "); for (i in m) multi[m[i]] = 1
408
+ split("root release witness", tr, " "); ro["root"] = 1; ro["release"] = 2; ro["witness"] = 3; ro["curator"] = 4
409
+ }
410
+ !($1 in rk) || rk[$1] < last || (rk[$1] == last && !($1 in multi)) { bad = 1 }
411
+ { last = rk[$1] }
412
+ $1 == "test" || $1 == "distinct-custody" || $1 == "end" { if (NF != 1) bad = 1; f[$1] = 1 }
413
+ $1 == "issued" { iss = 1; if (NF != 2 || $2 !~ /^[0-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z$/) bad = 1 }
414
+ $1 == "threshold" { if (NF != 3 || $2 != tr[++nt] || $3 !~ /^(0|[1-9][0-9]?[0-9]?)$/) bad = 1; t[$2] = $3 + 0 }
415
+ $1 == "require" { need = $2 == "security-key" ? 1 : $2 == "attested-security-key" ? 2 : 0; if (NF != 2 || !need) bad = 1 }
416
+ $1 == "delay" { if (NF != 2 || $2 !~ /^[1-9][0-9]*$/ || length($2) > 3 || $2 + 0 > 168) bad = 1 }
417
+ $1 == "key" {
418
+ if (NF != 5 || ($2 in seen) || ($5 in blob) || length($2) != 20 || !($3 in ro) || ro[$3] < lr) bad = 1
419
+ seen[$2] = 1; blob[$5] = 1; c[$3]++; lr = ro[$3]
420
+ if ($3 == "witness") { if ($2 !~ /^wdw-[0-9a-f]+$/ || $4 != "ssh-ed25519") bad = 1 }
421
+ else if ($3 == "root" || $3 == "release") {
422
+ if ($2 !~ /^wdr-[0-9a-f]+$/ || ($4 != "ssh-ed25519" && $4 != "sk-ssh-ed25519@openssh.com")) bad = 1
423
+ cls[$2] = $4 == "ssh-ed25519" ? 0 : 1; idx[$2] = ++ns; ci[$2] = ++nc; rl[$2] = $3; if (cls[$2] < 1) low = 1
424
+ } else if ($3 == "curator") {
425
+ if ($2 !~ /^wdc-[0-9a-f]+$/ || ($4 != "ssh-ed25519" && $4 != "sk-ssh-ed25519@openssh.com")) bad = 1
426
+ ci[$2] = ++nc
427
+ } else bad = 1
428
+ }
429
+ $1 == "spki" {
430
+ if (NF != 4 || $3 != "feed" || $2 !~ /^wdf-[0-9a-f]+$/ || length($2) != 20 || ($2 in seen) || ($4 in blob) || $4 !~ /^[A-Za-z0-9+\/]+=*$/) bad = 1
431
+ seen[$2] = 1; blob[$4] = 1; fd[$2] = 1; nf++; ci[$2] = ++nc
432
+ }
433
+ $1 == "attest" { if (NF != 3 || !($2 in cls) || cls[$2] != 1 || idx[$2] <= la || $3 !~ /^sha256:[0-9a-f]+$/ || length($3) != 71) bad = 1; la = idx[$2] }
434
+ $1 == "custody" {
435
+ if (NF != 3 || !($2 in ci) || ci[$2] <= lc || $3 !~ /^[a-z0-9][a-z0-9.-]*$/ || length($3) > 32) bad = 1
436
+ lc = ci[$2]
437
+ if ($2 in cls) { cu[$2] = $3; if (!((rl[$2], $3) in dc)) { dc[rl[$2], $3] = 1; nd[rl[$2]]++ } }
438
+ else if ($2 in fd) { fcu[$2] = 1; if (!($3 in fl)) { fl[$3] = 1; nfl++ } }
439
+ }
440
+ $1 == "distrust" {
441
+ if (NF != 3 || $2 !~ /^wd[rwcf]-[0-9a-f]+$/ || length($2) != 20 || ($2 in dis)) bad = 1
442
+ if ($3 !~ /^[0-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z$/) bad = 1
443
+ dis[$2] = 1
444
+ }
445
+ $1 == "retire" && $2 ~ /^wdf-/ {
446
+ if ($2 !~ /^wdf-[0-9a-f]+$/ || length($2) != 20 || ($2 in seen) || ($2 in rt) || NF != 4 || $3 != "feed-seq" || $4 !~ /^[1-9][0-9]*$/ || length($4) > 15) bad = 1
447
+ rt[$2] = 1; next
448
+ }
449
+ $1 == "retire" {
450
+ if ($2 !~ /^wdr-[0-9a-f]+$/ || length($2) != 20 || ($2 in seen) || ($2 in rt)) bad = 1
451
+ if (!(NF == 3 && $3 ~ /^(0|[1-9][0-9]*)$/ && length($3) <= 10 && $3 + 0 <= 4294967295) && !(NF == 6 && $3 == "serial" && $4 ~ /^[1-9][0-9]*$/ && $5 == "seq" && $6 ~ /^[1-9][0-9]*$/)) bad = 1
452
+ rt[$2] = 1
453
+ }
454
+ END {
455
+ if (nt != 3 || !iss) bad = 1
456
+ if (c["curator"] > 4 || nf == 1 || nf > 4 || (nf > 0 && nfl < 2)) bad = 1
457
+ for (k in fd) if (!(k in fcu)) bad = 1
458
+ for (i = 1; i <= 2; i++) { r = tr[i]; if (t[r] < (r == "release" && c[r] == 1 ? 1 : 2) || t[r] > c[r]) bad = 1 }
459
+ if (t["witness"] > c["witness"] || (!f["test"] && c["witness"] > 0 && t["witness"] < 1)) bad = 1
460
+ if (f["distinct-custody"]) { for (k in cls) if (!(k in cu)) bad = 1; if (nd["root"] < t["root"] || nd["release"] < t["release"]) bad = 1 }
461
+ print (bad ? "bad" : need > 1 || (need && low) ? "below" : f["test"] ? "test" : t["witness"] > 0 ? "witness" : "ok")
462
+ }' "$1"
463
+ }
464
+
465
+ # Counts the distinct ROLE keys of ROOTFILE whose signature on MSG counts (sshsig_ok), dropping keys the newest root
466
+ # distrusts and retired keys outside their retire line: a security key above its last counter, a software key on an
467
+ # index serial or release seq above its bounds (AT is serial:<n>, seq:<n> or -, for a root). Sets in_got and writes
468
+ # `<kid> <counter or -> <custody or -> <software|security>` lines to $in_w/got. Args: NAMESPACE ROLE ROOTFILE SIGPATH
469
+ # MSG NEWEST AT, STATE, whose `counter` lines each security key's counter must exceed (an index on a re-install), and
470
+ # RETIRES, the root whose retire lines apply (NEWEST if empty): on a root, the previous one, as a root's own retire
471
+ # lines bind from the next root on (verifyRootChain).
472
+ in_count() {
473
+ awk -v r="$2" -v ns="$1" '$1 == "key" && $3 == r { print $2 " namespaces=\"" ns "\" " $4 " " $5 }' "$3" >"$in_w/as"
474
+ in_got=0
475
+ : >"$in_w/got"
476
+ for in_k in $(awk '{ print $1 }' "$in_w/as"); do
477
+ in_ret=$(awk -v k="$in_k" -v at="$7" 'BEGIN { split(at, a, ":") }
478
+ f == 1 && $1 == "distrust" && $2 == k { x = 1 }
479
+ f == 2 && $1 == "retire" && $2 == k { if (NF == 3) print $3; else if (!(a[1] == "serial" && a[2] + 0 <= $4 + 0) && !(a[1] == "seq" && a[2] + 0 <= $6 + 0)) x = 1 }
480
+ END { exit x }' f=1 "$6" f=2 "${9:-$6}") || continue
481
+ in_cl=$(awk -v k="$in_k" '$1 == k { print ($3 == "ssh-ed25519" ? "software" : "security") }' "$in_w/as")
482
+ in_old=
483
+ [ "$in_cl" = software ] || in_old=$(awk -v k="$in_k" '$1 == "counter" && $2 == k { print $3 }' "${8:-/dev/null}" 2>/dev/null) || in_old=
484
+ in_get "$4.$in_k.sig" sig && sshsig_ok "$1" "$in_k" "$in_w/as" "$in_w/sig" "$5" "${in_old:-0}" || continue
485
+ [ -z "$in_ret" ] || [ "$SSHSIG_COUNTER" -le "$in_ret" ] 2>/dev/null || continue
486
+ in_got=$((in_got + 1))
487
+ printf '%s %s %s %s\n' "$in_k" "${SSHSIG_COUNTER:--}" \
488
+ "$(awk -v k="$in_k" '$1 == "custody" && $2 == k { c = $3 } END { print (c == "" ? "-" : c) }' "$3")" "$in_cl" >>"$in_w/got"
489
+ done
490
+ }
491
+
492
+ # Whether the signatures in_count counted meet ROOTFILE's threshold for ROLE, or stop with CODE and SENTENCE; under
493
+ # `distinct-custody` they must also come from that many distinct custodies (B-4: one stolen laptop is not enough).
494
+ in_need() {
495
+ in_t=$(in_thr "$1" "$2")
496
+ [ "$in_got" -ge "$in_t" ] || in_die "$3" "$4"
497
+ grep -qx distinct-custody "$1" || return 0
498
+ [ "$(awk '!s[$3]++ { n++ } END { print n + 0 }' "$in_w/got")" -ge "$in_t" ] ||
499
+ in_die ROOT_CUSTODY_DUPLICATE "the counted signatures come from too few distinct custodies."
500
+ }
501
+
502
+ # Root 1 to root N: each newer root needs `threshold root` of the previous root's root keys and
503
+ # of its own, and carries every distrust and retire line forward.
504
+ in_chain() {
505
+ in_n=1
506
+ while [ "$in_n" -lt "$1" ]; do
507
+ in_m=$((in_n + 1)) && in_r=$in_w/root-$in_m.txt
508
+ in_get "root/$in_m.txt" "root-$in_m.txt" || in_die ROOT_FETCH "root $in_m could not be fetched."
509
+ case $(in_root_check "$in_r" "$in_m") in
510
+ ok) ;;
511
+ witness) in_die WITNESS_REQUIRED "root $in_m asks for witness cosignatures, which this installer cannot check." ;;
512
+ test) in_die ROOT_TEST "root $in_m is a test root." ;;
513
+ below) in_die ROOT_KEY_CLASS_BELOW_REQUIRED "root $in_m holds a key below the class it requires." ;;
514
+ *) in_bad ROOT_GRAMMAR "root $in_m" ;;
515
+ esac
516
+ awk 'NR == FNR { if ($1 == "distrust" || $1 == "retire") need[$0] = 1; next } { delete need[$0] }
517
+ END { for (l in need) exit 1 }' "$in_w/root-$in_n.txt" "$in_r" || in_die ROOT_DISTRUST_DROPPED "root $in_m drops a distrust or retire line."
518
+ in_count wardensec-root-v1 root "$in_w/root-$in_n.txt" "root/$in_m.txt" "$in_r" "$in_r" - "" "$in_w/root-$in_n.txt"
519
+ in_need "$in_w/root-$in_n.txt" root ROOT_UNSIGNED_BY_PREVIOUS "root $in_m lacks the previous root's signatures."
520
+ in_count wardensec-root-v1 root "$in_r" "root/$in_m.txt" "$in_r" "$in_r" - "" "$in_w/root-$in_n.txt"
521
+ in_need "$in_r" root ROOT_UNSIGNED_BY_ITSELF "root $in_m lacks its own signatures."
522
+ in_n=$in_m
523
+ done
524
+ }
525
+
526
+ # The newest stable release that is neither revoked nor withdrawn, at or above `minimum` and, unless --now, 72 hours
527
+ # old by its index date. Prints `<name> <seq> <sha256> <skipped>`; or `none <skipped> <name> <time>` when every such
528
+ # release is younger, naming the newest and when it is 72 hours old; or `none 0` when there is none; or `held` while
529
+ # the index is inside the root's publication delay (B-4: DELAY hours after ISSUED, which --now does not shorten).
530
+ in_pick() {
531
+ LC_ALL=C awk -v now="$(date -u +%s)" -v age="$1" -v min="$2" -v iss="$3" -v dl="$4" '
532
+ function ep(s, y, m) {
533
+ y = substr(s, 1, 4) + 0; m = substr(s, 6, 2) + 0
534
+ if (m <= 2) { y--; m += 12 }
535
+ return (365 * y + int(y / 4) - int(y / 100) + int(y / 400) + int((153 * (m - 3) + 2) / 5) + substr(s, 9, 2) - 719469) * 86400 + substr(s, 12, 2) * 3600 + substr(s, 15, 2) * 60 + substr(s, 18, 2)
536
+ }
537
+ function iso(t, d, x, z, e, doe, yoe, doy, mp, mm) {
538
+ d = int(t / 86400); x = t - d * 86400; z = d + 719468; e = int(z / 146097); doe = z - e * 146097; yoe = int((doe - int(doe / 1460) + int(doe / 36524) - int(doe / 146096)) / 365)
539
+ doy = doe - (365 * yoe + int(yoe / 4) - int(yoe / 100)); mp = int((5 * doy + 2) / 153); mm = mp < 10 ? mp + 3 : mp - 9
540
+ return sprintf("%04d-%02d-%02dT%02d:%02d:%02dZ", yoe + e * 400 + (mm <= 2), mm, doy - int((153 * mp + 2) / 5) + 1, int(x / 3600), int((x % 3600) / 60), x % 60)
541
+ }
542
+ $1 == "revoke" { gone[$2] = 1 }
543
+ $1 == "withdraw" { withdrawn[$2] = 1 }
544
+ $1 == "release" && NF == 6 { line[++n] = $0 }
545
+ END {
546
+ if (ep(iss) + dl * 3600 > now + 0) { print "held"; exit }
547
+ for (i = n; i > 0; i--) {
548
+ split(line[i], f, " ")
549
+ if (f[6] != "stable" || (f[2] in gone) || (f[3] in withdrawn) || f[3] + 0 < min + 0) continue
550
+ if (age && ep(f[5]) + 259200 > now + 0) { if (!young++) y = f[2] " " iso(ep(f[5]) + 259200); continue }
551
+ print f[2], f[3], f[4], young + 0
552
+ exit
553
+ }
554
+ print "none", young + 0, y
555
+ }' "$in_w/index.txt"
556
+ }
557
+
558
+ # Writes FILE into the data directory as NAME, through tmp/ and one rename.
559
+ in_put() { cp -p "$1" "$in_data/tmp/f" && mv -f "$in_data/tmp/f" "$in_data/$2"; }
560
+
561
+ main() {
562
+ in_age=1 in_pin=
563
+ for in_a in "$@"; do
564
+ case $in_a in --now) in_age= ;; --root-sha256=*) in_pin=${in_a#*=} ;; *) in_die USAGE "the options are --now and --root-sha256=<hex>." ;; esac
565
+ done
566
+ [ "$(id -u)" != 0 ] || in_die ROOT_USER "run this as yourself, not as root."
567
+ in_data=${XDG_DATA_HOME:-$HOME/.local/share}/warden
568
+ [ ! -L "$in_data" ] || in_die DATA_SYMLINK "the data directory $in_data is a symlink."
569
+ umask 077
570
+ sshsig_require_selftest || exit 1
571
+ in_selftest_soft || in_die SSHSIG_SELFTEST "this machine's OpenSSH cannot verify software-key signatures the way Warden needs."
572
+ in_w=$(mktemp -d) && [ -d "$in_w" ] || in_die TMP "no private temporary directory."
573
+ trap 'rm -rf "$in_w"' EXIT
574
+ trap 'exit 1' HUP INT TERM
575
+
576
+ in_root1 >"$in_w/root-1.txt"
577
+ [ -z "$in_pin" ] || [ "$(in_sha "$in_w/root-1.txt")" = "$in_pin" ] || in_die ROOT_PIN "this script's root 1 is not the one you pinned with --root-sha256."
578
+ case $(in_root_check "$in_w/root-1.txt" 1) in
579
+ ok) ;;
580
+ test)
581
+ in_say "Warden is in early access and releases are rolling out. This installer found no release to verify, so it stopped before changing anything." \
582
+ "Read the install guide: https://wardensec.ai/docs/install" "Nothing was installed." >&2
583
+ exit 1
584
+ ;;
585
+ *) in_bad ROOT_BUILT_IN "built-in root" ;;
586
+ esac
587
+
588
+ in_i=$in_w/index.txt
589
+ # The site answers 404 for the index only until the first release is published: that is not a network fault.
590
+ in_get index.txt index.txt || { [ "$in_code" != 404 ] || in_die NO_RELEASE_YET "Warden has not published a release yet. The first release is being signed; once it is, it becomes installable 72 hours later. Run the same command then."; in_die INDEX_FETCH "the release index could not be fetched."; }
591
+ in_record "$in_i" && [ "$(head -n 1 "$in_i")" = "wardensec-index 1" ] || in_bad INDEX_GRAMMAR "release index"
592
+ in_serial=$(in_val "$in_i" serial) || in_serial=
593
+ in_rootn=$(in_val "$in_i" root) || in_rootn=
594
+ in_exp=$(in_val "$in_i" expires) || in_exp=
595
+ in_inst=$(in_val "$in_i" installer) || in_inst=
596
+ in_min=$(in_val "$in_i" minimum) || in_min=
597
+ in_iss=$(in_val "$in_i" issued) || in_iss=
598
+ in_int "$in_serial" && in_int "$in_rootn" && [ "$in_rootn" -le 999 ] && in_hex "$in_inst" && in_iso "$in_exp" && in_iso "$in_iss" &&
599
+ { [ "$in_min" = 0 ] || in_int "$in_min"; } || in_bad INDEX_GRAMMAR "release index"
600
+
601
+ in_chain "$in_rootn"
602
+ in_top=$in_w/root-$in_rootn.txt
603
+ in_st=$in_data/trust/state && [ -f "$in_st" ] || in_st=/dev/null
604
+ in_count wardensec-index-v1 release "$in_top" index.txt "$in_i" "$in_top" "serial:$in_serial" "$in_st"
605
+ in_need "$in_top" release INDEX_UNSIGNED "the release index lacks its signatures."
606
+ mv "$in_w/got" "$in_w/index-got"
607
+ # The seen state (B4.4), the counter of a key that has none: a key seen on this serial or a later one counts only
608
+ # on the bytes it signed then. Other keys' well-formed lines are carried.
609
+ LC_ALL=C awk -v s="$in_serial" -v h="$(in_sha "$in_i")" 'NR == FNR { k[$1] = 1; next }
610
+ $1 == "seen" && NF == 4 && $2 ~ /^wdr-[0-9a-f]+$/ && length($2) == 20 && $3 ~ /^[1-9][0-9]*$/ && length($3) < 10 &&
611
+ $4 ~ /^[0-9a-f]+$/ && length($4) == 64 && !d[$2]++ { if (!($2 in k)) print; else if ($3 + 0 >= s + 0 && $4 != h) exit 3 }
612
+ END { for (x in k) print "seen", x, s, h }' "$in_w/index-got" "$in_st" >"$in_w/seen" ||
613
+ in_die SIGNATURE_SERIAL_REGRESSED "a release key signed other bytes at an index serial it has already signed."
614
+ in_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
615
+ awk -v a="$in_exp" -v b="$in_now" 'BEGIN { exit !(a > b) }' || in_die INDEX_EXPIRED "the release index has expired."
616
+ case $0 in sh | */sh | dash | */dash | bash | */bash | -*) in_file= ;; *) in_file=$0 ;; esac
617
+ if [ -n "$in_file" ] && [ -f "$in_file" ]; then
618
+ [ "$(in_sha "$in_file")" = "$in_inst" ] || in_die INSTALLER_MISMATCH "this script is not the installer the index names."
619
+ else
620
+ in_say "This script was piped to sh, so it could not check its own hash against the index."
621
+ fi
622
+
623
+ in_dl=$(in_val "$in_top" delay) || in_dl=0
624
+ in_pk=$(in_pick "$in_age" "$in_min" "$in_iss" "$in_dl")
625
+ [ "$in_pk" != held ] || in_die INDEX_NOT_YET_ADOPTABLE "the release index was issued less than $in_dl hours ago; its root holds every release that long."
626
+ # shellcheck disable=SC2086
627
+ set -f && set -- $in_pk && set +f
628
+ if [ "$1" = none ]; then
629
+ [ "$#" -eq 4 ] || in_die NO_RELEASE "no stable release can be installed yet."
630
+ in_relname "$3" && in_iso "$4" || in_bad INDEX_GRAMMAR "release index"
631
+ if [ -n "$in_file" ]; then in_how="add --now"; else in_how="end the line with sh -s -- --now in place of sh"; fi
632
+ in_die NO_RELEASE "no stable release is old enough to install yet: this installer waits 72 hours after a release is signed before it installs one, so a bad release can be caught and withdrawn before anyone runs it, and the newest, $3, becomes usable at $4. Run the same command then, or $in_how to install it today (--now turns off only that age floor)."
633
+ fi
634
+ [ "$#" -eq 4 ] || in_die NO_RELEASE "no stable release can be installed yet."
635
+ in_rel=$1 in_seq=$2 in_rsha=$3
636
+ in_relname "$in_rel" && in_int "$in_seq" && in_hex "$in_rsha" || in_bad INDEX_GRAMMAR "release index"
637
+ [ "$4" = 0 ] || in_say "Skipped $4 newer stable release(s) signed less than 72 hours ago."
638
+ [ -n "$in_age" ] || in_say "--now: the 72-hour age floor is off. A release signed in the last three days" \
639
+ "has had no time to be noticed if it is bad."
640
+
641
+ in_f=$in_w/release.txt
642
+ in_get "$in_rel/release.txt" release.txt || in_die RELEASE_FETCH "the release record could not be fetched."
643
+ [ "$(in_sha "$in_f")" = "$in_rsha" ] || in_die RELEASE_HASH "the release record does not match the index."
644
+ in_record "$in_f" && [ "$(head -n 1 "$in_f")" = "wardensec-release 2" ] && [ "$(in_val "$in_f" release)" = "$in_rel" ] &&
645
+ [ "$(in_val "$in_f" seq)" = "$in_seq" ] && [ "$(in_val "$in_f" channel)" = stable ] || in_bad RELEASE_GRAMMAR "release record"
646
+ in_rr=$(in_val "$in_f" root) || in_rr=
647
+ in_int "$in_rr" && [ "$in_rr" -le "$in_rootn" ] || in_bad RELEASE_GRAMMAR "release record"
648
+ in_count wardensec-release-v1 release "$in_w/root-$in_rr.txt" "$in_rel/release.txt" "$in_f" "$in_top" "seq:$in_seq"
649
+ in_need "$in_w/root-$in_rr.txt" release RELEASE_UNSIGNED "the release record lacks its signatures."
650
+ in_fl=$(awk '$1 == "file" && $2 == "warden" { n++; h = $3; s = $4; f = NF } END { if (n == 1 && f == 4) print h, s }' "$in_f")
651
+ in_fsha=${in_fl%% *} && in_fsha=${in_fsha#sha256:} && in_fsize=${in_fl#* }
652
+ case $in_fl in sha256:*) ;; *) in_fsha= ;; esac
653
+ in_hex "$in_fsha" && in_int "$in_fsize" || in_bad RELEASE_GRAMMAR "release record"
654
+ in_get "sha256/$in_fsha" warden || in_die FILE_FETCH "the warden file could not be fetched."
655
+ [ "$(wc -c <"$in_w/warden" | tr -d ' ')" = "$in_fsize" ] && [ "$(in_sha "$in_w/warden")" = "$in_fsha" ] ||
656
+ in_die FILE_HASH "the warden file does not match the signed release."
657
+
658
+ # Install: every file is written under tmp/ and renamed into place.
659
+ {
660
+ printf 'root-serial %s\nindex-serial %s\nindex-sha256 %s\n' "$in_rootn" "$in_serial" "$(in_sha "$in_i")"
661
+ printf 'seen-seq %s\n' "$(awk '$1 == "release" && $3 + 0 > m { m = $3 + 0 } END { print m + 0 }' "$in_i")"
662
+ awk '$2 != "-" { print "counter", $1, $2 }' "$in_w/index-got"
663
+ cat "$in_w/seen"
664
+ printf 'first-seen %s %s\n' "$in_serial" "$in_now"
665
+ } >"$in_w/state"
666
+ chmod 0755 "$in_w/warden"
667
+ mkdir -p "$in_data/bin" "$in_data/trust" "$in_data/tmp"
668
+ in_n=0
669
+ while [ "$in_n" -lt "$in_rootn" ]; do
670
+ in_n=$((in_n + 1)) && in_put "$in_w/root-$in_n.txt" "trust/root-$in_n.txt"
671
+ done
672
+ in_put "$in_i" trust/index.txt && in_put "$in_w/state" trust/state && in_put "$in_w/warden" bin/warden
673
+ rmdir "$in_data/tmp" 2>/dev/null || true
674
+ in_bin=$in_data/bin/warden in_lb=$HOME/.local/bin in_link=
675
+
676
+ case ":$PATH:" in
677
+ *":$in_lb:"*)
678
+ if [ -d "$in_lb" ] && { [ -L "$in_lb/warden" ] || [ ! -e "$in_lb/warden" ]; }; then
679
+ rm -f "$in_lb/warden" && ln -s "$in_bin" "$in_lb/warden" && in_link=$in_lb/warden
680
+ fi
681
+ ;;
682
+ esac
683
+
684
+ in_say "" "Installed Warden $in_rel at $in_bin" ""
685
+ # The class of every key behind this release (B4.2): posture, printed, never a refusal.
686
+ awk -v t="$(in_thr "$in_w/root-$in_rr.txt" release)" '{ printf "release signed by %s, %s key%s, %d of %s needed\n", $1, $4,
687
+ ($3 == "-" ? "" : ", custody " $3), NR, t }' "$in_w/got"
688
+ awk -v n="$in_rootn" 'function p(x) { return c[x] ? c[x] " " x (c[x] > 1 ? "s" : "") : "" }
689
+ $1 == "key" && $3 == "root" { c[$4 == "ssh-ed25519" ? "software key" : "security key"]++ }
690
+ $1 == "threshold" && $2 == "root" { t = $3 } $1 == "distinct-custody" { d = " from distinct custodies" } $1 == "require" { q = 1 }
691
+ END { a = p("software key"); b = p("security key"); printf "root %s: %s%s%s, %s needed%s; this root %s security keys\n", n, a,
692
+ (a != "" && b != "" ? " and " : ""), b, t, d, (q ? "requires" : "does not require") }' "$in_top"
693
+ in_say "Root keys: $(awk '$1 == "key" && $3 == "root" { printf "%s ", $2 }' "$in_top")"
694
+ for in_n in $(awk -v n="$in_rootn" 'BEGIN { for (i = 1; i <= n; i++) print i }'); do
695
+ in_say " sha256 $(in_sha "$in_data/trust/root-$in_n.txt") trust/root-$in_n.txt"
696
+ done
697
+ in_say "These came from this script, so they are only as good as the installer sha256 you compared with the public" \
698
+ "trust record before you ran it; https://wardensec.ai/docs/verify-install says how. To print them again:" \
699
+ " shasum -a 256 $in_data/trust/root-*.txt" ""
700
+ [ -n "$in_link" ] || in_say "To run it by name, add this line to your shell's start-up file:" \
701
+ " export PATH=\"$in_data/bin:\$PATH\""
702
+ in_cv=$(command -v warden 2>/dev/null || true)
703
+ if [ -n "$in_cv" ] && [ "$in_cv" != "$in_bin" ] && [ "$in_cv" != "$in_link" ]; then
704
+ in_say "Warning: another warden comes first on your PATH and shadows this one: $in_cv"
705
+ fi
706
+ [ -z "$in_pin" ] || in_say "Root 1 matches the sha256 you pinned."
707
+ in_say "Next: $in_bin setup"
708
+ }
709
+
710
+ main "$@"
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "wardensec",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Warden AI installer: runs the signed install.sh that https://wardensec.ai/install.sh serves, byte for byte",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "homepage": "https://wardensec.ai/install",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/agent-labs-dev/warden.git",
10
+ "directory": "launcher/npm"
11
+ },
12
+ "bin": {
13
+ "wardensec": "bin/wardensec.cjs"
14
+ },
15
+ "files": [
16
+ "bin/wardensec.cjs",
17
+ "install.sh",
18
+ "LICENSE",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }