← back to Kickbacks Ai Upstream

test/manifestSigning.test.ts

71 lines

import { describe, it, expect } from "vitest";
import { generateKeyPairSync, sign } from "node:crypto";
import { manifestSignedString } from "../scripts/deploy.mjs";
import { _verifyManifestSignature } from "../src/update/client";

// The producer (deploy.mjs::manifestSignedString) and the consumer
// (client.ts::_verifyManifestSignature) must agree on the signed-payload format
// byte-for-byte, or signature verification silently breaks once a pubkey-
// embedded client requires it ("Step C"). These tests sign with the REAL
// producer string and verify with the REAL consumer so a divergence fails CI.
describe("manifest signing contract (deploy.mjs producer <-> client.ts consumer)", () => {
  const { publicKey, privateKey } = generateKeyPairSync("ed25519");
  const pubPem = publicKey.export({ type: "spki", format: "pem" }) as string;
  const SHA = "a".repeat(64);
  const URL = "https://storage.googleapis.com/kickbacks-vsix/kickbacks.vsix";

  // Sign exactly what deploy.mjs would sign for a given manifest shape.
  const signFor = (m: { version: string; sha256: string; url: string; rollback_to?: string }) =>
    sign(null, Buffer.from(
      manifestSignedString(m.version, m.sha256, m.url, m.rollback_to ?? "")), privateKey)
      .toString("base64");

  it("forward manifest (rollback_to empty) verifies", () => {
    const m = { version: "0.3.200", sha256: SHA, url: URL };
    expect(_verifyManifestSignature({ ...m, signature: signFor(m) }, pubPem)).toBe(true);
  });

  it("signed rollback (rollback_to set) verifies", () => {
    const m = { version: "0.3.150", sha256: SHA, url: URL, rollback_to: "0.3.200" };
    expect(_verifyManifestSignature({ ...m, signature: signFor(m) }, pubPem)).toBe(true);
  });

  it("missing signature -> false", () => {
    expect(_verifyManifestSignature({ version: "0.3.200", sha256: SHA, url: URL }, pubPem))
      .toBe(false);
  });

  it("tampered sha256 -> false", () => {
    const m = { version: "0.3.200", sha256: SHA, url: URL };
    expect(_verifyManifestSignature(
      { ...m, signature: signFor(m), sha256: "b".repeat(64) }, pubPem)).toBe(false);
  });

  it("tampered url (attacker-host swap) -> false", () => {
    const m = { version: "0.3.200", sha256: SHA, url: URL };
    expect(_verifyManifestSignature(
      { ...m, signature: signFor(m), url: "https://evil.example.com/x.vsix" }, pubPem))
      .toBe(false);
  });

  it("grafted rollback_to onto a forward signature -> false (downgrade-graft defense)", () => {
    const m = { version: "0.3.200", sha256: SHA, url: URL };
    const forwardSig = signFor(m); // signed with rollback_to=""
    expect(_verifyManifestSignature(
      { ...m, signature: forwardSig, rollback_to: "0.3.100" }, pubPem)).toBe(false);
  });

  it("signature from a different key -> false", () => {
    const other = generateKeyPairSync("ed25519");
    const otherPem = other.publicKey.export({ type: "spki", format: "pem" }) as string;
    const m = { version: "0.3.200", sha256: SHA, url: URL };
    expect(_verifyManifestSignature({ ...m, signature: signFor(m) }, otherPem)).toBe(false);
  });

  it("malformed base64 signature -> false (never throws)", () => {
    expect(_verifyManifestSignature(
      { version: "0.3.200", sha256: SHA, url: URL, signature: "!!!not-base64!!!" }, pubPem))
      .toBe(false);
  });
});