Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 7 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
18 changes: 18 additions & 0 deletions src/aws/ec2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
7 changes: 7 additions & 0 deletions src/aws/ec2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}),
);

Expand Down
27 changes: 27 additions & 0 deletions src/aws/userdata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
53 changes: 52 additions & 1 deletion src/aws/userdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<binary>.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
Expand Down
Loading