← back to Kickbacks Ai Upstream

src/update/client.ts

314 lines

import { createHash, verify } from "node:crypto";
import { dlog } from "../log";
import { errMsg } from "../util/errMsg";
import { timeoutFetch } from "../util/http";

type Fetch = typeof fetch;
type Installer = (vsix: ArrayBuffer) => Promise<void>;
/** Persistence hooks so a given manifest version is install-attempted AT
 *  MOST ONCE, ever. Without this, if installExtension+restartExtensionHost
 *  fails to actually swap the running version, isNewer stays true forever →
 *  download→install→restart→repeat = the endless restart loop. With it, a
 *  non-converging version is tried once then left alone (safe-degraded).
 *
 *  wave-2A-F05: distinguish "permanent install attempt" from "transient
 *  pre-install failure" (sha mismatch, body-read abort). The latter should
 *  be retried after a short cooldown rather than burning the full
 *  install-attempt slot (which would block recovery for 30 min). */
interface AttemptGuard {
  // The optional sha256 second arg is the manifest's pinned VSIX digest.
  // Wave-2P-F01: keying ONLY on `version` lets a manifest flap between two
  // artifacts that carry the same semver but different bytes (observed in
  // prod: 0.3.54 with two distinct BUILD_TS-stamped builds re-installing
  // each other every ~90s). Passing `sha256` lets the extension-side
  // implementation key on (version, sha) so the second flap is suppressed
  // until the cooldown expires. Optional + ignored-when-omitted to keep
  // every existing call site / test guard binary-compatible.
  attempted(version: string, sha256?: string): boolean;
  markAttempted(version: string, sha256?: string): void;
  /** Optional: SUCCESS record (trey-nag-loop 2026-06-11). `attempted` is
   *  cooldown-bounded so a FAILED install can retry — but that same cooldown
   *  re-ran SUCCESSFUL installs too: a user who dismissed the reload toast
   *  got the identical artifact re-downloaded, re-installed and re-toasted
   *  every ~31 min until they reloaded (observed: 20+ cycles / 10 h on one
   *  machine). An artifact that installed without throwing is recorded here
   *  and never re-attempted; only the wiring (selfUpdate.ts) clears the
   *  record, at activation, when the install visibly failed to converge. */
  installed?(version: string, sha256?: string): boolean;
  markInstalled?(version: string, sha256?: string): void;
  /** Optional: short cooldown for transient pre-install failures. When
   *  omitted, transient failures are NOT retried-throttled and the next
   *  90s poll re-tries immediately (pre-2A-F05 behavior). */
  transientFailed?(version: string, sha256?: string): boolean;
  markTransientFailed?(version: string, sha256?: string): void;
  /** Optional: wave-2K-F03 LKG VSIX recording. Called after a successful
   *  install so an activate.fatal bootstrap can restore the last-known-good
   *  bytes. Bytes may be persisted to a tmp file path; only the path needs
   *  to fit in globalState. */
  recordLkg?(version: string, vsix: Buffer): void;
}

export function isNewer(a: string, b: string): boolean {
  const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
  for (let i = 0; i < 3; i++) {
    if ((pa[i] || 0) > (pb[i] || 0)) return true;
    if ((pa[i] || 0) < (pb[i] || 0)) return false;
  }
  return false;
}

// wave-2A-F01 — VSIX size sanity + signature scaffolding.
//
// A self-hosted manifest (no marketplace fallback) where the only integrity
// check is sha256 vs the manifest itself is integrity-vs-the-manifest, not
// authenticity. A backend compromise that controls the manifest also
// controls the sha → a malicious VSIX ships silently. Two layers of fix:
//
// 1. UNCONDITIONAL minimum-size sanity. Rejects empty/garbage downloads
//    (CDN partial response, ad-blocker stub, etc) before they reach the
//    installer. 10 KiB is far below any real VSIX (89 KB at audit time).
//
// 2. FLAG-GATED Ed25519 signature verification over the manifest blob.
//    When VIBE_ADS_REQUIRE_MANIFEST_SIG=1 and a public key is compiled in
//    via VIBE_ADS_MANIFEST_PUBKEY_PEM (esbuild --define), the manifest
//    MUST carry a base64 `signature` field that verifies against
//    `${version}\n${sha256}\n${url}\n${rollback_to}` under the embedded
//    public key (rollback_to is "" for a normal forward manifest).
//    The flag is OFF by default so existing deployments keep working
//    until a key story is set up (see docs/runbooks/extension-signing.md
//    when it lands as part of the wave-2A-F01 operational follow-up).
const MIN_VSIX_BYTES = 10 * 1024;

declare const __MANIFEST_PUBKEY_PEM__: string | undefined;

function _embeddedPubkeyPem(): string | null {
  try {
    // esbuild --define replaces __MANIFEST_PUBKEY_PEM__ at build time.
    // When undefined the typeof guard short-circuits without ReferenceError.
    if (typeof __MANIFEST_PUBKEY_PEM__ === "string" && __MANIFEST_PUBKEY_PEM__.length > 0) {
      return __MANIFEST_PUBKEY_PEM__;
    }
  } catch { /* swallow; treated as unset */ }
  return null;
}

export function _verifyManifestSignature(
  m: { version: string; sha256: string; url: string; signature?: string;
       rollback_to?: string },
  pubkeyPem: string,
): boolean {
  if (!m.signature) return false;
  try {
    // Locked signed-payload format: `version\nsha256\nurl\nrollback_to`.
    // rollback_to is "" for a normal forward manifest (deploy.mjs signs it
    // empty); including it in the signed bytes prevents a compromised manifest
    // from grafting an unsigned `rollback_to` to force a downgrade to an old
    // (genuinely signed but known-vuln) version.
    return verify(
      null,
      Buffer.from(`${m.version}\n${m.sha256}\n${m.url}\n${m.rollback_to ?? ""}`),
      pubkeyPem,
      Buffer.from(m.signature, "base64"),
    );
  } catch { return false; }
}

// wave-2A-F01 layer 3 — VSIX download-origin pin. The sha256 above proves the
// downloaded bytes match the manifest, but a compromised manifest controls
// BOTH `url` AND `sha256`, so on its own the pin can be redirected to an
// attacker-controlled host (supply-chain RCE on every install). Restrict the
// download origin to the published `kickbacks-vsix` GCS bucket in production,
// while still allowing a self-hosted DEV manifest to serve the VSIX from its
// OWN origin (same host as `base`) or loopback — if an attacker can MITM that
// they already control the manifest + sha, so it grants no new capability. An
// out-of-allowlist url is rejected BEFORE the fetch (safe-degraded: the
// self-update no-ops rather than fetching/installing an untrusted artifact).
export function _vsixUrlAllowed(rawUrl: string, base: string): boolean {
  let u: URL;
  try { u = new URL(rawUrl); } catch { return false; }
  const host = u.hostname.toLowerCase();
  // Dev / self-host escape hatches.
  if (host === "localhost" || host === "127.0.0.1" || host === "::1") return true;
  try {
    if (host === new URL(base).hostname.toLowerCase()) return true;
  } catch { /* base isn't a URL — fall through to the prod allowlist */ }
  // Production: HTTPS to the published kickbacks-vsix GCS bucket only.
  if (u.protocol !== "https:") return false;
  if (host === "kickbacks-vsix.storage.googleapis.com") return true;
  if (host === "kickbacks-vsix.storage.cloud.google.com") return true;
  if ((host === "storage.googleapis.com" || host === "storage.cloud.google.com")
      && u.pathname.startsWith("/kickbacks-vsix/")) return true;
  return false;
}

/** Polls /v1/ext/manifest; sha256-verifies the VSIX before install. Returns
 *  true iff an install happened. Never throws. */
export class UpdateClient {
  // wave-2A-M4: surface when self-update has been silently failing for a
  // while. Pre-fix, a peer left on a stale build had zero signal — every
  // failure path returns false with no log. Counter resets on success; at
  // each 3-poll multiple we emit a dlog so an operator running with
  // debugMode: true can see "self-update is wedged" without instrumenting
  // anything else.
  private consecutiveFails = 0;
  /** Optional user-facing "new version detected" notifier. Fires AFTER
   *  the version + sig + sha gates pass and BEFORE the install starts —
   *  so the user sees "Kickbacks: v0.3.85 available, installing…" while
   *  the install runs, not after the extension host has restarted on
   *  top of them. Never throws (best-effort). */
  private onUpdateAvailable?: (info: { version: string;
                                       current: string;
                                       rollback: boolean }) => void;
  constructor(private base: string, private current: string,
              private f: Fetch = timeoutFetch(120000), private install: Installer = async () => {},
              private guard?: AttemptGuard,
              onUpdateAvailable?: (info: { version: string;
                                            current: string;
                                            rollback: boolean }) => void) {
    this.onUpdateAvailable = onUpdateAvailable;
  }

  private noteFail(reason: string, extra: Record<string, unknown> = {}): false {
    this.consecutiveFails++;
    if (this.consecutiveFails > 0 && this.consecutiveFails % 3 === 0) {
      dlog("ext", "selfupdate.failed",
        { reason, base: this.base, current: this.current,
          consecutiveFails: this.consecutiveFails, ...extra });
    }
    return false;
  }

  // audit-2026-06-09 #31: single-flight guard. checkOnce is driven by a
  // fire-and-forget 90s setInterval (selfUpdate.ts); the attempt fence is
  // only written AFTER the download completes (markAttempted below), so a
  // VSIX download slower than the poll period let the next tick re-enter,
  // double-download and double-install the same artifact. An overlapping
  // poll now returns false immediately; cooldown/attempt-guard semantics
  // for SEQUENTIAL polls are unchanged.
  private inFlight = false;

  async checkOnce(): Promise<boolean> {
    if (this.inFlight) return false;
    this.inFlight = true;
    try { return await this.checkOnceInner(); }
    finally { this.inFlight = false; }
  }

  private async checkOnceInner(): Promise<boolean> {
    try {
      const m = await (await this.f(`${this.base}/v1/ext/manifest`)).json() as
        { version: string; sha256: string; url: string; signature?: string;
          rollback_to?: string };
      // wave-2K-F02: rollback contract. When the server-published manifest
      // sets `rollback_to: <currently-running-version>`, this client treats
      // the manifest version as installable even when it's <= current --
      // overriding isNewer() so a true downgrade is possible. Sha pin still
      // applies; the attempt-guard still applies.
      const isRollbackForUs = !!m.rollback_to
        && m.rollback_to === this.current
        && m.version !== this.current;
      if (!isRollbackForUs && !isNewer(m.version, this.current)) return false;
      // Already-installed artifact (success record): the running version
      // only changes on window reload, so "manifest newer than current"
      // stays true indefinitely after a successful install. Without this
      // gate the attempted-cooldown below re-installs + re-toasts the SAME
      // artifact every time it expires (the Trey nag loop). Checked before
      // the cooldown slots so success suppression is not time-bounded.
      if (this.guard?.installed?.(m.version, m.sha256)) return false;
      // One install attempt per (version, sha), ever — the restart-loop
      // guard. Wave-2P-F01: previously keyed on version only, which let a
      // manifest carrying the same semver with a different VSIX flap-install
      // every poll. Threading m.sha256 to every guard call site means the
      // *artifact* is what the cooldown actually fences; manifests that
      // genuinely re-serve the same bytes still hit the existing slot.
      if (this.guard && this.guard.attempted(m.version, m.sha256)) return false;
      // wave-2A-F05 — short transient cooldown for pre-install failures
      // (sha mismatch, body-read abort). The 90s poll means a flapping CDN
      // would otherwise burn bandwidth + CPU every 90s indefinitely.
      if (this.guard?.transientFailed?.(m.version, m.sha256)) return false;
      // wave-2A-F01: flag-gated manifest signature verification.
      const pubkey = _embeddedPubkeyPem();
      const requireSig = !!pubkey
        || process.env.KICKBACKS_REQUIRE_MANIFEST_SIG === "1"
        || process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG === "1";
      if (requireSig) {
        if (!pubkey) {                          // flag on but no embedded key
          this.guard?.markTransientFailed?.(m.version, m.sha256);
          return this.noteFail("missing-pubkey", { version: m.version });
        }
        if (!_verifyManifestSignature(m, pubkey)) {
          this.guard?.markTransientFailed?.(m.version, m.sha256);
          return this.noteFail("sig-verify", { version: m.version });
        }
      }
      // Supply-chain pin: never fetch a VSIX from an origin outside the
      // published bucket (or a dev self-host). Transient so a later corrected
      // manifest still recovers, but the download itself is refused here.
      if (!_vsixUrlAllowed(m.url, this.base)) {
        this.guard?.markTransientFailed?.(m.version, m.sha256);
        return this.noteFail("vsix-url-blocked", { version: m.version, url: m.url });
      }
      let ab: ArrayBuffer;
      try {
        ab = await (await this.f(m.url)).arrayBuffer();
      } catch {
        // Body-read abort: transient. wave-2A-M4: this is the canonical
        // "manifest URL unreachable" case — a peer pointing at a manifest
        // that has localhost:6080 will hit this every poll and silently
        // stay stale forever pre-fix.
        this.guard?.markTransientFailed?.(m.version, m.sha256);
        return this.noteFail("vsix-fetch", { version: m.version, url: m.url });
      }
      // wave-2A-F01: minimum-size sanity, unconditional. Transient — a
      // partial/CDN-stub response should be retried after backoff, not
      // burn the permanent install slot.
      if (ab.byteLength < MIN_VSIX_BYTES) {
        this.guard?.markTransientFailed?.(m.version, m.sha256);
        return this.noteFail("vsix-too-small",
          { version: m.version, bytes: ab.byteLength });
      }
      const got = createHash("sha256").update(Buffer.from(ab)).digest("hex");
      if (got !== m.sha256) {
        // tamper / corruption -> transient (a flapping CDN or in-flight
        // corruption should recover; a persistent attacker still burns
        // only the cooldown rate, not the network).
        this.guard?.markTransientFailed?.(m.version, m.sha256);
        return this.noteFail("sha-mismatch", { version: m.version });
      }
      // Notify the user a new version is about to install (toast). Fires
      // BEFORE markAttempted/install so the user sees the notice in the
      // few seconds the install + restart take. Best-effort: a thrown
      // notifier never blocks the install path.
      try {
        this.onUpdateAvailable?.(
          { version: m.version, current: this.current, rollback: isRollbackForUs });
      } catch { /* notifier is best-effort */ }
      // Mark BEFORE installing/restarting: install() triggers an ext-host
      // restart that can kill this context before a post-install write
      // lands, so a non-converging restart must be fenced here. The guard
      // is cooldown-bounded (see extension.ts) so a TRANSIENT install
      // failure still retries on a later poll instead of bricking forever.
      if (this.guard) this.guard.markAttempted(m.version, m.sha256);
      await this.install(ab);
      // install() resolved without throwing -> record success so this
      // artifact is never re-attempted (the attempted slot above only
      // rate-limits FAILED installs, which skip this line by throwing).
      try { this.guard?.markInstalled?.(m.version, m.sha256); }
      catch { /* best-effort */ }
      // wave-2K-F03: stash the freshly-installed VSIX bytes as last-known-
      // good so an `activate.fatal` (wave-2A-F03 dlog) bootstrap can roll
      // back to it without operator intervention. Persisted by the
      // extension's wired guard (extension.ts); ignored if the guard
      // doesn't implement the hook (older callers).
      try { this.guard?.recordLkg?.(m.version, Buffer.from(ab)); } catch { /* obs only */ }
      this.consecutiveFails = 0;       // wave-2A-M4: success resets counter
      return true;
    } catch (e) {
      // Outer catch: manifest fetch threw (most common case for the M4
      // silent-degradation finding — peer can't reach the manifest URL).
      return this.noteFail("manifest-fetch",
        { msg: errMsg(e) });
    }
  }
}