diff --git a/CHANGELOG.md b/CHANGELOG.md index c22f2cc..c2d2639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Pre-1.0, breaking changes bump the MINOR version. ## [Unreleased] ### Added +- **Optional spored signature verification** (#26) — an `EC2Provider` + `sporedSigningPublicKey` (PEM) makes the bootstrap verify the downloaded + `spored`'s detached signature (`openssl`, fail-closed) against a launcher-held + key before install, proving authenticity — not just the SHA256 checksum + (integrity). Ports the Go bootstrap's `SPORED_SIG_VERIFY` path. Default stays + checksum-only, matching the Go tool when no key is compiled in. - **Lifecycle-hook tags** (#25) — emit the `spawn:*` tags for daemon-enforced hooks so an instance spawn-ts launches is honored by a real spored (spawn-ts, a browser launcher, can't run them itself): `pre-stop` (+timeout), diff --git a/docs/architecture.md b/docs/architecture.md index c66e735..53d1eb9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,13 @@ tool; the GUI and terminal are two consumers of that library. - `ec2.ts` — `EC2Provider` over `@aws-sdk/client-ec2` v3; targets real AWS or a substrate emulator by endpoint. - `userdata.ts` — the instance bootstrap that installs `spored`, so instances - self-terminate even with the browser closed. + self-terminate even with the browser closed. It downloads the arch-appropriate + `spored` from the regional S3 bucket and **always verifies a SHA256 checksum** + (guards corruption). Optionally, when an `EC2Provider` is given a + `sporedSigningPublicKey`, it also **verifies a publisher signature** (`openssl` + against a key carried by the launcher, not the binary's bucket — fail-closed), + proving authenticity. Checksum-only is the default, matching the Go tool when + no signing key is compiled in. ### `src/cli` diff --git a/src/aws/ec2.test.ts b/src/aws/ec2.test.ts index 0872c98..832fc2c 100644 --- a/src/aws/ec2.test.ts +++ b/src/aws/ec2.test.ts @@ -190,6 +190,24 @@ describe("EC2Provider.launch", () => { await provider().launch(baseSpec, T0); expect(sent.some((c) => c instanceof DescribeImagesCommand)).toBe(false); }); + + it("threads the signing key into the bootstrap user-data when configured", async () => { + handler = () => ({ Instances: [{ InstanceId: "i-1", State: { Name: "pending" } }] }); + const pem = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHkeymaterial==\n-----END PUBLIC KEY-----"; + await provider({ sporedSigningPublicKey: pem }).launch(baseSpec, T0); + const b64 = lastOf(RunInstancesCommand).input.UserData!; + const script = Buffer.from(b64, "base64").toString("utf8"); + expect(script).toContain("spored-signing-key.pem"); + expect(script).toContain("MFkwEwYHkeymaterial=="); + expect(script).toContain("openssl dgst -sha256 -verify"); + }); + + it("omits signature verification when no key is configured", async () => { + handler = () => ({ Instances: [{ InstanceId: "i-1", State: { Name: "pending" } }] }); + await provider().launch(baseSpec, T0); + const script = Buffer.from(lastOf(RunInstancesCommand).input.UserData!, "base64").toString("utf8"); + expect(script).not.toContain("spored-signing-key.pem"); + }); }); describe("archForInstanceType", () => { diff --git a/src/aws/ec2.ts b/src/aws/ec2.ts index 97e85f8..0c1c2e0 100644 --- a/src/aws/ec2.ts +++ b/src/aws/ec2.ts @@ -46,6 +46,12 @@ export interface EC2ProviderOptions { iamInstanceProfile?: string; /** Login username for bootstrap (default ec2-user). */ username?: string; + /** + * PEM spore.host signing public key. When set, launched instances verify the + * spored binary's signature before running it (fail-closed). Absent = the + * bootstrap relies on the SHA256 checksum only. See userdata.ts. + */ + sporedSigningPublicKey?: string; } export class EC2Provider implements Provider { @@ -79,6 +85,7 @@ export class EC2Provider implements Provider { publicKey: this.opts.publicKey, command: spec.onComplete ? undefined : undefined, // workload wiring is a later feature sessionTimeoutMs: spec.sessionTimeoutMs, + sporedSigningPublicKey: this.opts.sporedSigningPublicKey, }), ); diff --git a/src/aws/userdata.test.ts b/src/aws/userdata.test.ts index cefe47c..474685e 100644 --- a/src/aws/userdata.test.ts +++ b/src/aws/userdata.test.ts @@ -79,6 +79,33 @@ describe("buildLinuxBootstrap", () => { expect(s).not.toContain("releases/latest/download"); expect(s).not.toContain("github.com/spore-host/spawn/releases"); }); + + it("omits signature verification by default (checksum only)", () => { + const s = buildLinuxBootstrap({ username: "ec2-user" }); + expect(s).not.toContain("spored-signing-key.pem"); + expect(s).not.toContain("openssl dgst"); + expect(s).toContain("sha256sum"); // checksum still present + }); + + it("adds a fail-closed signature-verify block when a signing key is supplied", () => { + const pem = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHtestkey==\n-----END PUBLIC KEY-----"; + const s = buildLinuxBootstrap({ username: "ec2-user", sporedSigningPublicKey: pem }); + expect(s).toContain("/etc/spawn/spored-signing-key.pem"); + expect(s).toContain("MFkwEwYHtestkey=="); + expect(s).toContain('SIG_URL="${CHECKSUM_URL%.sha256}.sig"'); + expect(s).toContain("openssl dgst -sha256 -verify"); + // Fail-closed: refuses on a missing sig and on a bad sig. + expect(s).toContain("refusing to run an unsigned binary"); + expect(s).toContain("signature verification FAILED"); + // Runs before the atomic install (verify then mv). + expect(s.indexOf("openssl dgst")).toBeLessThan(s.indexOf("/usr/local/bin/spored")); + }); + + it("treats a blank signing key as disabled", () => { + expect(buildLinuxBootstrap({ username: "ec2-user", sporedSigningPublicKey: " " })).not.toContain( + "spored-signing-key.pem", + ); + }); }); describe("encodeUserData", () => { diff --git a/src/aws/userdata.ts b/src/aws/userdata.ts index e341745..f7fa77a 100644 --- a/src/aws/userdata.ts +++ b/src/aws/userdata.ts @@ -33,6 +33,16 @@ export interface BootstrapOptions { * does NOT stop/terminate the instance (that's the idle-instance lifecycle). */ sessionTimeoutMs?: number; + /** + * PEM-encoded spore.host signing PUBLIC key. When set, the bootstrap verifies + * the downloaded spored's detached signature against this key (fail-closed) + * before installing — proving authenticity, not just integrity. The key is + * carried by the launcher (trusted), NOT served from the binary's S3 bucket, + * so a bucket compromise can't forge it (spore-host#440). Absent = checksum + * only (guards corruption), matching the Go tool's default when no key is + * compiled in. + */ + sporedSigningPublicKey?: string; } /** @@ -64,6 +74,11 @@ chmod 600 /etc/spawn/command // shells. Seconds are computed here so the script needs no duration parser. const sessionBlock = buildSessionTimeoutBlock(opts.sessionTimeoutMs ?? 0); + // Optional publisher-signature verification of the spored binary. Empty when + // no signing key is supplied (checksum-only, the default). Runs after the + // SHA256 check and before the atomic install; fail-closed on any mismatch. + const sigVerifyBlock = buildSigVerifyBlock(opts.sporedSigningPublicKey); + return `#!/bin/bash set -e @@ -122,7 +137,7 @@ if curl -f -s -o /tmp/spored.sha256 "$CHECKSUM_URL" 2>/dev/null; then rm -f "$SPORED_TMP"; exit 1 fi fi - +${sigVerifyBlock} # Atomic install — rename works even if an old spored is executing (#27). chmod +x "$SPORED_TMP" mv -f "$SPORED_TMP" /usr/local/bin/spored @@ -158,6 +173,42 @@ systemctl start spored `; } +/** + * Bootstrap fragment that verifies the spored binary's detached signature + * against a supplied signing PUBLIC key before install. Empty when no key is + * given (checksum-only default). Ports the Go bootstrap's SPORED_SIG_VERIFY + * path (pkg/launcher/bootstrap.go): download `.sig` next to the + * checksum URL, base64-decode to DER, `openssl dgst -sha256 -verify`, + * fail-closed on missing/invalid signature. + */ +function buildSigVerifyBlock(publicKeyPem?: string): string { + if (!publicKeyPem || !publicKeyPem.trim()) return ""; + return ` +# Publisher-signature verification (spore-host#440). The checksum above only +# proves the download wasn't corrupted (it's served from the same bucket as the +# binary); this proves authenticity against a key carried by the launcher, not +# the bucket. Fail-closed. +mkdir -p /etc/spawn +cat > /etc/spawn/spored-signing-key.pem <<'EOFSPOREDPUBKEY' +${publicKeyPem.trim()} +EOFSPOREDPUBKEY +SIG_URL="\${CHECKSUM_URL%.sha256}.sig" +if ! curl -f -s -o /tmp/spored.sig "$SIG_URL"; then + echo "spored signature not found at $SIG_URL — refusing to run an unsigned binary" >&2 + rm -f "$SPORED_TMP"; exit 1 +fi +# The .sig is base64-encoded DER (ECDSA_SHA_256); decode to raw DER for openssl. +base64 -d /tmp/spored.sig > /tmp/spored.sig.der 2>/dev/null || cp /tmp/spored.sig /tmp/spored.sig.der +if openssl dgst -sha256 -verify /etc/spawn/spored-signing-key.pem -signature /tmp/spored.sig.der "$SPORED_TMP" >/dev/null 2>&1; then + echo "spored signature verified (spore.host)" +else + echo "spored signature verification FAILED — refusing to run spored" >&2 + rm -f "$SPORED_TMP" /tmp/spored.sig /tmp/spored.sig.der; exit 1 +fi +rm -f /tmp/spored.sig /tmp/spored.sig.der +`; +} + /** * Bootstrap fragment for idle-SSH-shell auto-logout. Empty when disabled * (timeoutMs <= 0). Sets sshd ClientAlive (interval = 1/6 of the timeout, min