Vectrum
Sign In
53 lines · 2.4 KB · TypeScript
import "server-only";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { DATA_ROOT } from "./paths";

/*
 * Signed upload URLs for release files, so an agent can stream a file (`curl -T <file> <url>`)
 * without holding an API token, and without the file passing through its context. A URL is good for
 * one file name of one release, for its signer, for UPLOAD_URL_MINUTES. Uploading still needs the
 * signer to have push access then, so removing a collaborator also ends their URLs.
 */

export const UPLOAD_URL_MINUTES = 30;

type Payload = { repoId: string; releaseId: string; name: string; userId: string; expiresAt: number };

const g = globalThis as unknown as { __releaseUploadKey?: Buffer };

/** Generated once and kept in the data directory, so URLs survive a restart. */
function key() {
  if (g.__releaseUploadKey) return g.__releaseUploadKey;
  const file = path.join(DATA_ROOT, "release-upload-key");
  if (!existsSync(file)) {
    mkdirSync(DATA_ROOT, { recursive: true });
    try {
      writeFileSync(file, randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" });
    } catch (e) {
      // Another request created it first.
      if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
    }
  }
  return (g.__releaseUploadKey = Buffer.from(readFileSync(file, "utf8").trim(), "hex"));
}

const sign = (data: string) => createHmac("sha256", key()).update(data).digest("base64url");

/** The value of an upload URL's `upload` parameter. */
export function signUpload(payload: Omit<Payload, "expiresAt">) {
  const data = Buffer.from(JSON.stringify({ ...payload, expiresAt: Date.now() + UPLOAD_URL_MINUTES * 60e3 })).toString("base64url");
  return `${data}.${sign(data)}`;
}

/** The upload a signed value allows, "expired", or null if it isn't one this server signed. */
export function verifyUpload(value: string): Payload | "expired" | null {
  const [data, signature, extra] = value.split(".");
  if (!data || !signature || extra !== undefined) return null;
  const expected = Buffer.from(sign(data));
  const actual = Buffer.from(signature);
  if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
  const payload = JSON.parse(Buffer.from(data, "base64url").toString("utf8")) as Payload;
  return payload.expiresAt < Date.now() ? "expired" : payload;
}