Brings t3code/fix-security-report-issues onto the PocketBase and Actions code: the SSH server runs receive-pack through spawnService (so pushes get the pre-receive hook) while still starting workflows, session cookies and image names use the configured origin, and the runner API hands runners PUBLIC_URL instead of trusting X-Forwarded-* headers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 19 changed files with 491 additions and 66 deletions.
| @@ -28,7 +28,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or | ||
| 28 | 28 | |
| 29 | 29 | ## Getting started |
| 30 | 30 | |
| 31 | - Requires Node.js 22+ and `git` on the `PATH`. | |
| 31 | + Requires Node.js 22+ (24.7+ for post-quantum SSH key exchange) and `git` on the `PATH`. | |
| 32 | 32 | |
| 33 | 33 | ```bash |
| 34 | 34 | bun install # also downloads the PocketBase server into bin/ |
| @@ -51,7 +51,7 @@ The image runs the web UI on port 3000 and the SSH server on port 2222, with all | ||
| 51 | 51 | docker compose exec -it vectrum node scripts/create-user.mjs <username> <email> [name] |
| 52 | 52 | ``` |
| 53 | 53 | |
| 54 | - It asks for the password (or reads `VECTRUM_PASSWORD`). Outside Docker, run `node scripts/create-user.mjs` the same way while Vectrum runs. Clone URLs show `SSH_PORT`, so publish SSH on the same port on the host, and set `SSH_HOST` to your domain. Put a TLS reverse proxy in front of port 3000 if the server is reachable from the internet. | |
| 54 | + It asks for the password (or reads `VECTRUM_PASSWORD`). Outside Docker, run `node scripts/create-user.mjs` the same way while Vectrum runs. Clone URLs show `SSH_PORT`, so publish SSH on the same port on the host, and set `SSH_HOST` to your domain. Put a TLS reverse proxy in front of port 3000 if the server is reachable from the internet, and set `PUBLIC_URL` to its address. | |
| 55 | 55 | |
| 56 | 56 | ### Pushing over SSH |
| 57 | 57 | |
| @@ -125,6 +125,8 @@ Runners poll the server over HTTPS (`/api/runner/*`), so they need no inbound po | ||
| 125 | 125 | |
| 126 | 126 | | Variable | Default | Purpose | |
| 127 | 127 | | ----------------- | ----------- | --------------------------------------------------------- | |
| 128 | + | `PUBLIC_URL` | request host | Canonical address, e.g. `https://vectrum.cc`. Used for clone URLs; `https` also makes session cookies `Secure`. Set it in production | | |
| 129 | + | `SECURE_COOKIES` | — | Set to `1` to mark session cookies `Secure` without `PUBLIC_URL` | | |
| 128 | 130 | | `DATA_ROOT` | `./data` | Where repositories, the database and the SSH host key live | |
| 129 | 131 | | `PB_PORT` | `8090` | Port of the PocketBase server Vectrum runs | |
| 130 | 132 | | `PB_URL` | — | Use this PocketBase server instead of running one | |
| @@ -143,6 +145,7 @@ data/ | ||
| 143 | 145 | pb_data/ PocketBase: users, keys, tokens, repos, issues, workflow runs and logs |
| 144 | 146 | pocketbase-superuser.json the generated PocketBase superuser login |
| 145 | 147 | ssh_host_ed25519_key generated on first start |
| 148 | + hooks/ server-side git hooks for every push, rewritten on start | |
| 146 | 149 | repos/<owner>/<repo>.git bare git repositories |
| 147 | 150 | registry.db container image manifests, tags and blob records (SQLite) |
| 148 | 151 | registry/blobs/sha256/… container image layers, shared by all repositories |
| @@ -156,7 +159,8 @@ kept so pushes in progress don't lose their layers). | ||
| 156 | 159 | |
| 157 | 160 | ## Security notes |
| 158 | 161 | |
| 159 | - - Serve the web UI over HTTPS if it's reachable beyond localhost. Session cookies become `Secure` when `X-Forwarded-Proto: https` is set. | |
| 162 | + - Serve the web UI over HTTPS if it's reachable beyond localhost, and set `PUBLIC_URL`. `Host` and `X-Forwarded-*` headers are client-controlled, so they never decide cookie security, and only stand in for `PUBLIC_URL` in development. | |
| 163 | + - Ref names are never passed to git where they could be read as options: pushes of refs with a component starting with `-` are refused by a pre-receive hook, such refs are ignored when reading, and every revision argument follows `--end-of-options`. | |
| 160 | 164 | - Private repositories show a 404 page to anyone without access, so their existence isn't revealed. |
| 161 | 165 | - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin. |
| 162 | 166 | |
| @@ -14,7 +14,7 @@ const str = (formData: FormData, key: string) => String(formData.get(key) ?? "") | ||
| 14 | 14 | |
| 15 | 15 | /** Blob sha of a path at a commit, or null if it doesn't exist there. */ |
| 16 | 16 | async function blobAt(dir: string, commit: string, path: string) { |
| 17 | - return (await git(dir, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]).catch(() => "")).trim() || null; | |
| 17 | + return (await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}:${path}`]).catch(() => "")).trim() || null; | |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 20 | /** |
| @@ -33,6 +33,7 @@ async function commitFromForm( | ||
| 33 | 33 | ) { |
| 34 | 34 | const branch = str(formData, "branch"); |
| 35 | 35 | const baseSha = str(formData, "baseSha"); |
| 36 | + if (baseSha && !/^[0-9a-f]{40,64}$/.test(baseSha)) throw new ActionError("Invalid base commit."); | |
| 36 | 37 | const head = await revParse(repo.dir, `refs/heads/${branch}`); |
| 37 | 38 | |
| 38 | 39 | if (baseSha) { |
| @@ -1,4 +1,5 @@ | ||
| 1 | - import { json, originOf, readBody, runnerFromRequest } from "@/lib/runner-api"; | |
| 1 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 2 | + import { json, readBody, runnerFromRequest } from "@/lib/runner-api"; | |
| 2 | 3 | import { touchRunner } from "@/lib/runners"; |
| 3 | 4 | import { claimJob } from "@/lib/runs"; |
| 4 | 5 | |
| @@ -14,7 +15,7 @@ const sleep = (ms: number, signal: AbortSignal) => | ||
| 14 | 15 | /** A runner asking for work: answers with a job as soon as one it can run is queued (long polling). */ |
| 15 | 16 | export async function POST(request: Request) { |
| 16 | 17 | const info = await readBody(request); |
| 17 | - const origin = originOf(request); | |
| 18 | + const origin = await serverOrigin(); | |
| 18 | 19 | const deadline = Date.now() + MAX_WAIT_MS; |
| 19 | 20 | |
| 20 | 21 | for (;;) { |
| @@ -1,4 +1,3 @@ | ||
| 1 | - import { headers } from "next/headers"; | |
| 2 | 1 | import Link from "next/link"; |
| 3 | 2 | import { Card, Surface } from "@unlocalhosted/metalui"; |
| 4 | 3 | import { BRAND } from "@/components/brand"; |
| @@ -24,6 +23,7 @@ import { getViewer } from "@/lib/auth"; | ||
| 24 | 23 | import { shortSha, timeAgo } from "@/lib/format"; |
| 25 | 24 | import { recentActivity, withActivity } from "@/lib/listing"; |
| 26 | 25 | import { listReposForUser, searchRepos } from "@/lib/repos"; |
| 26 | + import { serverHost } from "@/lib/request-origin"; | |
| 27 | 27 | import { SIGNUPS_ENABLED } from "@/lib/signups"; |
| 28 | 28 | import { sshCloneUrl } from "@/lib/ssh-config"; |
| 29 | 29 | import type { User } from "@/lib/users"; |
| @@ -31,8 +31,7 @@ import type { User } from "@/lib/users"; | ||
| 31 | 31 | export default async function Home() { |
| 32 | 32 | const viewer = await getViewer(); |
| 33 | 33 | if (viewer) return <Dashboard viewer={viewer} />; |
| 34 | - const host = (await headers()).get("host") ?? "localhost:3000"; | |
| 35 | - return <Landing sshUrl={sshCloneUrl(host, "you/project.git")} />; | |
| 34 | + return <Landing sshUrl={sshCloneUrl(await serverHost(), "you/project.git")} />; | |
| 36 | 35 | } |
| 37 | 36 | |
| 38 | 37 | const features = [ |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | - import { headers } from "next/headers"; | |
| 3 | 2 | import { Accordion, Label as MuLabel } from "@unlocalhosted/metalui"; |
| 4 | 3 | import { deleteSshKeyAction } from "@/app/actions/settings"; |
| 5 | 4 | import { ConfirmButton } from "@/components/confirm-button"; |
| @@ -11,6 +10,7 @@ import { Alert, Blankslate, Box, Label, PageTitle } from "@/components/ui"; | ||
| 11 | 10 | import { requireViewer } from "@/lib/auth"; |
| 12 | 11 | import { formatDate, timeAgo } from "@/lib/format"; |
| 13 | 12 | import { listSshKeys } from "@/lib/keys"; |
| 13 | + import { serverHost } from "@/lib/request-origin"; | |
| 14 | 14 | import { SSH_ENABLED, sshTestCommand } from "@/lib/ssh-config"; |
| 15 | 15 | import { AddKeyForm } from "./form"; |
| 16 | 16 | |
| @@ -19,8 +19,7 @@ export const metadata: Metadata = { title: "SSH keys" }; | ||
| 19 | 19 | export default async function KeysPage() { |
| 20 | 20 | const viewer = await requireViewer(); |
| 21 | 21 | const keys = await listSshKeys(viewer.id); |
| 22 | - const host = (await headers()).get("host") ?? "localhost:3000"; | |
| 23 | - const test = sshTestCommand(host); | |
| 22 | + const test = sshTestCommand(await serverHost()); | |
| 24 | 23 | |
| 25 | 24 | return ( |
| 26 | 25 | <div> |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | - import { headers } from "next/headers"; | |
| 3 | 2 | import { Label as MuLabel } from "@unlocalhosted/metalui"; |
| 4 | 3 | import { deleteTokenAction } from "@/app/actions/settings"; |
| 5 | 4 | import { ConfirmButton } from "@/components/confirm-button"; |
| @@ -11,6 +10,7 @@ import { Blankslate, Label, PageTitle, RefChip } from "@/components/ui"; | ||
| 11 | 10 | import { requireViewer } from "@/lib/auth"; |
| 12 | 11 | import { timeAgo } from "@/lib/format"; |
| 13 | 12 | import { canWrite, listReposForUser, repoAccess } from "@/lib/repos"; |
| 13 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 14 | 14 | import { listTokens, TOKEN_PREFIX } from "@/lib/tokens"; |
| 15 | 15 | import { NewTokenForm } from "./form"; |
| 16 | 16 | |
| @@ -26,8 +26,7 @@ export default async function TokensPage() { | ||
| 26 | 26 | writable: canWrite(await repoAccess(r, viewer.id)), |
| 27 | 27 | })), |
| 28 | 28 | ); |
| 29 | - const h = await headers(); | |
| 30 | - const origin = `${h.get("x-forwarded-proto") ?? "http"}://${h.get("host") ?? "localhost:3000"}`; | |
| 29 | + const origin = await serverOrigin(); | |
| 31 | 30 | |
| 32 | 31 | return ( |
| 33 | 32 | <div> |
| @@ -42,6 +42,9 @@ | ||
| 42 | 42 | "sharp", |
| 43 | 43 | "unrs-resolver", |
| 44 | 44 | ], |
| 45 | + "patchedDependencies": { | |
| 46 | + "ssh2@1.17.0": "patches/ssh2@1.17.0.patch", | |
| 47 | + }, | |
| 45 | 48 | "packages": { |
| 46 | 49 | "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="], |
| 47 | 50 | |
| @@ -10,6 +10,8 @@ services: | ||
| 10 | 10 | # Close registration: the sign-up page and button go away, existing users can still sign in. |
| 11 | 11 | # Create accounts with: docker compose exec -it vectrum node scripts/create-user.mjs <username> <email> |
| 12 | 12 | SIGNUPS_DISABLED: "0" |
| 13 | + # The address users reach Vectrum at, for clone URLs; https also makes session cookies Secure. | |
| 14 | + # PUBLIC_URL: https://vectrum.example.com | |
| 13 | 15 | # Host name shown in SSH clone URLs (defaults to the host the web UI was reached on). |
| 14 | 16 | # SSH_HOST: vectrum.example.com |
| 15 | 17 | # Set to 1 to turn off the built-in SSH server (and remove the 2222 port above). |
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { cache } from "react"; |
| 3 | - import { cookies, headers } from "next/headers"; | |
| 3 | + import { cookies } from "next/headers"; | |
| 4 | 4 | import { redirect } from "next/navigation"; |
| 5 | 5 | import { ClientResponseError, getTokenPayload, isTokenExpired } from "pocketbase"; |
| 6 | 6 | import { client, db } from "./db"; |
| 7 | + import { SECURE_COOKIES } from "./request-origin"; | |
| 7 | 8 | import { authenticateUser, toUser } from "./users"; |
| 8 | 9 | |
| 9 | 10 | // Sessions are PocketBase auth tokens, kept in an httpOnly cookie. They expire after 30 days (the |
| @@ -13,11 +14,10 @@ const SESSION_COOKIE = "session"; | ||
| 13 | 14 | const SESSION_SECONDS = 30 * 86400; |
| 14 | 15 | |
| 15 | 16 | async function setSessionCookie(token: string) { |
| 16 | - const h = await headers(); | |
| 17 | 17 | (await cookies()).set(SESSION_COOKIE, token, { |
| 18 | 18 | httpOnly: true, |
| 19 | 19 | sameSite: "lax", |
| 20 | - secure: h.get("x-forwarded-proto") === "https", | |
| 20 | + secure: SECURE_COOKIES, | |
| 21 | 21 | path: "/", |
| 22 | 22 | expires: new Date(getTokenPayload(token).exp * 1000), |
| 23 | 23 | }); |
| @@ -49,7 +49,7 @@ async function start(repo: RepoRecord, file: WorkflowFile, run: { event: RunEven | ||
| 49 | 49 | /** Files changed between two commits, or null when that isn't known (a new branch). */ |
| 50 | 50 | async function changedFiles(dir: string, before: string | null, after: string) { |
| 51 | 51 | if (!before) return null; |
| 52 | - const out = await git(dir, ["diff", "--name-only", "-z", before, after]).catch(() => null); | |
| 52 | + const out = await git(dir, ["diff", "--name-only", "-z", "--end-of-options", before, after]).catch(() => null); | |
| 53 | 53 | return out === null ? null : out.split("\0").filter(Boolean); |
| 54 | 54 | } |
| 55 | 55 | |
| @@ -48,7 +48,7 @@ export async function commitFiles( | ||
| 48 | 48 | GIT_COMMITTER_EMAIL: opts.author.email, |
| 49 | 49 | }; |
| 50 | 50 | try { |
| 51 | - if (opts.parent) await git(dir, ["read-tree", opts.parent], { env }); | |
| 51 | + if (opts.parent) await git(dir, ["read-tree", "--end-of-options", opts.parent], { env }); | |
| 52 | 52 | else await git(dir, ["read-tree", "--empty"], { env }); |
| 53 | 53 | |
| 54 | 54 | for (const change of opts.changes) { |
| @@ -66,7 +66,7 @@ export async function commitFiles( | ||
| 66 | 66 | |
| 67 | 67 | const tree = (await git(dir, ["write-tree"], { env })).trim(); |
| 68 | 68 | if (opts.parent) { |
| 69 | - const parentTree = (await git(dir, ["rev-parse", `${opts.parent}^{tree}`])).trim(); | |
| 69 | + const parentTree = (await git(dir, ["rev-parse", "--end-of-options", `${opts.parent}^{tree}`])).trim(); | |
| 70 | 70 | if (parentTree === tree) throw new GitError("No changes to commit."); |
| 71 | 71 | } |
| 72 | 72 | const args = ["commit-tree", tree]; |
| @@ -0,0 +1,73 @@ | ||
| 1 | + // Regression tests for git argument injection through ref names (SEC_REPORT #1). | |
| 2 | + // Run with `bun test`. | |
| 3 | + import assert from "node:assert/strict"; | |
| 4 | + import { execFileSync } from "node:child_process"; | |
| 5 | + import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 6 | + import os from "node:os"; | |
| 7 | + import path from "node:path"; | |
| 8 | + import { after, before, test } from "node:test"; | |
| 9 | + | |
| 10 | + const tmp = mkdtempSync(path.join(os.tmpdir(), "vectrum-git-test-")); | |
| 11 | + process.env.DATA_ROOT = path.join(tmp, "data"); | |
| 12 | + const target = path.join(tmp, "PWNED"); | |
| 13 | + const evilBranch = `--output=${target}`; | |
| 14 | + | |
| 15 | + const sh = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); | |
| 16 | + | |
| 17 | + let git: typeof import("./git"); | |
| 18 | + let repo: string; | |
| 19 | + let work: string; | |
| 20 | + | |
| 21 | + before(async () => { | |
| 22 | + git = await import("./git"); | |
| 23 | + repo = path.join(tmp, "srv.git"); | |
| 24 | + await git.initBareRepo(repo); | |
| 25 | + work = path.join(tmp, "work"); | |
| 26 | + sh(tmp, "init", "-q", "-b", "main", work); | |
| 27 | + sh(work, "-c", "user.name=a", "-c", "user.email=a@x", "commit", "-q", "--allow-empty", "-m", "ssh-ed25519 AAAA attacker"); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + after(() => rmSync(tmp, { recursive: true, force: true })); | |
| 31 | + | |
| 32 | + /** Pushes through spawnService, the way the SSH server runs receive-pack. */ | |
| 33 | + function push(refspec: string) { | |
| 34 | + const wrapper = path.join(tmp, "receive-pack.ts"); | |
| 35 | + writeFileSync( | |
| 36 | + wrapper, | |
| 37 | + `const { spawnService } = await import(${JSON.stringify(path.join(import.meta.dirname, "git.ts"))}); | |
| 38 | + const child = spawnService("git-receive-pack", process.argv[2], { stateless: false }); | |
| 39 | + process.stdin.pipe(child.stdin); | |
| 40 | + child.stdout.pipe(process.stdout); | |
| 41 | + child.stderr.pipe(process.stderr); | |
| 42 | + child.on("close", (code) => process.exit(code ?? 1));`, | |
| 43 | + ); | |
| 44 | + return sh(work, "push", "--porcelain", `--receive-pack=${process.execPath} ${wrapper}`, repo, refspec); | |
| 45 | + } | |
| 46 | + | |
| 47 | + test("pushes of dash-prefixed ref names are refused", () => { | |
| 48 | + push("HEAD:refs/heads/main"); | |
| 49 | + const declined = (e: { stdout?: string }) => /pre-receive hook declined/.test(e.stdout ?? ""); | |
| 50 | + assert.throws(() => push(`HEAD:refs/heads/${evilBranch}`), declined); | |
| 51 | + assert.throws(() => push("HEAD:refs/tags/-x"), declined); | |
| 52 | + assert.equal(sh(repo, "for-each-ref", "--format=%(refname)").trim(), "refs/heads/main"); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + test("dash-prefixed refs that already exist are ignored and never reach git as options", async () => { | |
| 56 | + // Simulate a branch pushed before the hook existed. | |
| 57 | + sh(repo, "update-ref", `refs/heads/${evilBranch}`, "refs/heads/main"); | |
| 58 | + sh(repo, "symbolic-ref", "HEAD", `refs/heads/${evilBranch}`); | |
| 59 | + | |
| 60 | + assert.deepEqual((await git.listBranches(repo)).map((b) => b.name), ["main"]); | |
| 61 | + assert.equal(await git.defaultBranch(repo), "main"); | |
| 62 | + assert.equal(await git.resolveRefAndPath(repo, [evilBranch]), null); | |
| 63 | + | |
| 64 | + await assert.rejects(git.log(repo, evilBranch), git.GitError); | |
| 65 | + await assert.rejects(git.commitCount(repo, evilBranch), git.GitError); | |
| 66 | + await assert.rejects(git.listContributors(repo, evilBranch), git.GitError); | |
| 67 | + await assert.rejects(git.listTree(repo, evilBranch, ""), git.GitError); | |
| 68 | + assert.equal(existsSync(target), false); | |
| 69 | + | |
| 70 | + // Normal refs still work. | |
| 71 | + assert.equal((await git.log(repo, "main"))[0].subject, "ssh-ed25519 AAAA attacker"); | |
| 72 | + assert.equal(await git.commitCount(repo, "main"), 1); | |
| 73 | + }); | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { execFile, spawn } from "node:child_process"; |
| 2 | + import { mkdirSync, writeFileSync } from "node:fs"; | |
| 2 | 3 | import { access } from "node:fs/promises"; |
| 3 | 4 | import path from "node:path"; |
| 4 | - import { REPOS_ROOT } from "./paths"; | |
| 5 | + import { DATA_ROOT, REPOS_ROOT } from "./paths"; | |
| 5 | 6 | |
| 6 | 7 | // Note: this module is also used by the SSH server (outside of React), so it |
| 7 | 8 | // must not import "server-only" or anything from next/*. |
| @@ -41,6 +42,21 @@ export async function pathExists(p: string) { | ||
| 41 | 42 | |
| 42 | 43 | export class GitError extends Error {} |
| 43 | 44 | |
| 45 | + /** | |
| 46 | + * Ref names may start with "-" (e.g. a pushed branch named "--output=/etc/x"), | |
| 47 | + * and git would parse such a revision as an option. Every revision argument | |
| 48 | + * goes through this and follows "--end-of-options". The check is needed on top | |
| 49 | + * of the marker: `git shortlog` consumes the marker itself and still parses a | |
| 50 | + * following "--output=…" as an option. | |
| 51 | + */ | |
| 52 | + function rev(revision: string) { | |
| 53 | + if (revision.startsWith("-")) throw new GitError(`Invalid revision: ${revision}`); | |
| 54 | + return revision; | |
| 55 | + } | |
| 56 | + | |
| 57 | + /** Ref names whose short form starts with "-" are refused on push and ignored when reading. */ | |
| 58 | + const isSafeRefName = (name: string) => !name.startsWith("-"); | |
| 59 | + | |
| 44 | 60 | type GitOptions = { env?: Record<string, string>; input?: string | Buffer; maxBuffer?: number }; |
| 45 | 61 | |
| 46 | 62 | function run<T extends "utf8" | "buffer">(dir: string, args: string[], encoding: T, opts: GitOptions) { |
| @@ -88,14 +104,17 @@ export async function lastActivity(dir: string) { | ||
| 88 | 104 | } |
| 89 | 105 | |
| 90 | 106 | export async function isBranch(dir: string, name: string) { |
| 91 | - return git(dir, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]).then( | |
| 107 | + return git(dir, ["show-ref", "--verify", "--quiet", "--end-of-options", `refs/heads/${name}`]).then( | |
| 92 | 108 | () => true, |
| 93 | 109 | () => false, |
| 94 | 110 | ); |
| 95 | 111 | } |
| 96 | 112 | |
| 97 | - export async function revParse(dir: string, rev: string) { | |
| 98 | - return (await git(dir, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]).catch(() => "")).trim() || null; | |
| 113 | + export async function revParse(dir: string, revision: string) { | |
| 114 | + const out = await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${rev(revision)}^{commit}`]).catch( | |
| 115 | + () => "", | |
| 116 | + ); | |
| 117 | + return out.trim() || null; | |
| 99 | 118 | } |
| 100 | 119 | |
| 101 | 120 | // --------------------------------------------------------------------------- |
| @@ -116,7 +135,8 @@ export async function listBranches(dir: string): Promise<Branch[]> { | ||
| 116 | 135 | .map((line) => { |
| 117 | 136 | const [name, sha, date, subject] = line.split("\x1f"); |
| 118 | 137 | return { name, sha, date: Number(date) * 1000, subject }; |
| 119 | - }); | |
| 138 | + }) | |
| 139 | + .filter((b) => isSafeRefName(b.name)); | |
| 120 | 140 | } |
| 121 | 141 | |
| 122 | 142 | /** Every branch and tag with the commit it points to, to tell what a push changed. */ |
| @@ -132,7 +152,7 @@ export async function snapshotRefs(dir: string) { | ||
| 132 | 152 | |
| 133 | 153 | export async function listTags(dir: string) { |
| 134 | 154 | const out = await git(dir, ["for-each-ref", "--format=%(refname:short)", "refs/tags"]); |
| 135 | - return out.split("\n").filter(Boolean); | |
| 155 | + return out.split("\n").filter((name) => name && isSafeRefName(name)); | |
| 136 | 156 | } |
| 137 | 157 | |
| 138 | 158 | /** The branch HEAD points to, or the first existing branch if HEAD dangles. */ |
| @@ -162,7 +182,7 @@ export async function resolveRefAndPath(dir: string, segments: string[]) { | ||
| 162 | 182 | } |
| 163 | 183 | |
| 164 | 184 | async function commitExists(dir: string, ref: string) { |
| 165 | - return git(dir, ["cat-file", "-e", `${ref}^{commit}`]).then( | |
| 185 | + return git(dir, ["cat-file", "-e", "--end-of-options", `${rev(ref)}^{commit}`]).then( | |
| 166 | 186 | () => true, |
| 167 | 187 | () => false, |
| 168 | 188 | ); |
| @@ -181,11 +201,11 @@ export type TreeEntry = { | ||
| 181 | 201 | }; |
| 182 | 202 | |
| 183 | 203 | export async function objectType(dir: string, ref: string, filePath: string) { |
| 184 | - return (await git(dir, ["cat-file", "-t", `${ref}:${filePath}`]).catch(() => "")).trim() || null; | |
| 204 | + return (await git(dir, ["cat-file", "-t", "--end-of-options", `${rev(ref)}:${filePath}`]).catch(() => "")).trim() || null; | |
| 185 | 205 | } |
| 186 | 206 | |
| 187 | 207 | export async function listTree(dir: string, ref: string, treePath: string): Promise<TreeEntry[]> { |
| 188 | - const out = await git(dir, ["ls-tree", "-z", "-l", `${ref}:${treePath}`]); | |
| 208 | + const out = await git(dir, ["ls-tree", "-z", "-l", "--end-of-options", `${rev(ref)}:${treePath}`]); | |
| 189 | 209 | const entries = out |
| 190 | 210 | .split("\0") |
| 191 | 211 | .filter(Boolean) |
| @@ -210,7 +230,7 @@ export async function listTree(dir: string, ref: string, treePath: string): Prom | ||
| 210 | 230 | |
| 211 | 231 | /** Every file in the tree at `ref`, with its size, for language statistics. */ |
| 212 | 232 | export async function listFilesWithSizes(dir: string, ref: string) { |
| 213 | - const out = await git(dir, ["ls-tree", "-r", "-z", "-l", ref]); | |
| 233 | + const out = await git(dir, ["ls-tree", "-r", "-z", "-l", "--end-of-options", rev(ref)]); | |
| 214 | 234 | return out |
| 215 | 235 | .split("\0") |
| 216 | 236 | .filter(Boolean) |
| @@ -222,9 +242,11 @@ export async function listFilesWithSizes(dir: string, ref: string) { | ||
| 222 | 242 | } |
| 223 | 243 | |
| 224 | 244 | export async function readBlob(dir: string, ref: string, filePath: string) { |
| 225 | - const size = Number((await git(dir, ["cat-file", "-s", `${ref}:${filePath}`])).trim()); | |
| 245 | + const size = Number((await git(dir, ["cat-file", "-s", "--end-of-options", `${rev(ref)}:${filePath}`])).trim()); | |
| 226 | 246 | const tooLarge = size > 2 * 1024 * 1024; |
| 227 | - const content = tooLarge ? Buffer.alloc(0) : await gitBuffer(dir, ["cat-file", "blob", `${ref}:${filePath}`]); | |
| 247 | + const content = tooLarge | |
| 248 | + ? Buffer.alloc(0) | |
| 249 | + : await gitBuffer(dir, ["cat-file", "blob", "--end-of-options", `${rev(ref)}:${filePath}`]); | |
| 228 | 250 | const binary = content.subarray(0, 8000).includes(0); |
| 229 | 251 | return { size, tooLarge, binary, content }; |
| 230 | 252 | } |
| @@ -273,7 +295,8 @@ export async function log( | ||
| 273 | 295 | `--format=${COMMIT_FORMAT}`, |
| 274 | 296 | `-n${opts.limit ?? 30}`, |
| 275 | 297 | `--skip=${opts.skip ?? 0}`, |
| 276 | - ref, | |
| 298 | + "--end-of-options", | |
| 299 | + rev(ref), | |
| 277 | 300 | "--", |
| 278 | 301 | ]; |
| 279 | 302 | if (opts.path) args.push(opts.path); |
| @@ -285,14 +308,14 @@ export async function lastCommit(dir: string, ref: string, filePath?: string) { | ||
| 285 | 308 | } |
| 286 | 309 | |
| 287 | 310 | export async function commitCount(dir: string, ref: string) { |
| 288 | - return Number((await git(dir, ["rev-list", "--count", ref])).trim()); | |
| 311 | + return Number((await git(dir, ["rev-list", "--count", "--end-of-options", rev(ref)])).trim()); | |
| 289 | 312 | } |
| 290 | 313 | |
| 291 | 314 | export type Contributor = { name: string; email: string; commits: number }; |
| 292 | 315 | |
| 293 | 316 | /** Commit authors reachable from `ref`, most active first. */ |
| 294 | 317 | export async function listContributors(dir: string, ref: string): Promise<Contributor[]> { |
| 295 | - const out = await git(dir, ["shortlog", "-sne", ref, "--"]); | |
| 318 | + const out = await git(dir, ["shortlog", "-sne", "--end-of-options", rev(ref), "--"]); | |
| 296 | 319 | return out |
| 297 | 320 | .split("\n") |
| 298 | 321 | .map((line) => line.match(/^\s*(\d+)\t(.*?)\s*<([^>]*)>$/)) |
| @@ -302,7 +325,7 @@ export async function listContributors(dir: string, ref: string): Promise<Contri | ||
| 302 | 325 | |
| 303 | 326 | export async function getCommit(dir: string, sha: string) { |
| 304 | 327 | if (!/^[0-9a-f]{4,40}$/i.test(sha)) return null; |
| 305 | - const out = await git(dir, ["show", "-s", `--format=${COMMIT_FORMAT}`, sha, "--"]).catch(() => null); | |
| 328 | + const out = await git(dir, ["show", "-s", `--format=${COMMIT_FORMAT}`, "--end-of-options", sha, "--"]).catch(() => null); | |
| 306 | 329 | return out ? (parseCommits(out)[0] ?? null) : null; |
| 307 | 330 | } |
| 308 | 331 | |
| @@ -323,7 +346,7 @@ export type FileDiff = { | ||
| 323 | 346 | export async function diffCommit(dir: string, commit: Commit) { |
| 324 | 347 | // Diff against the first parent, or the empty tree for root commits. |
| 325 | 348 | const base = commit.parents[0] ?? (await git(dir, ["hash-object", "-t", "tree", "/dev/null"])).trim(); |
| 326 | - const out = await git(dir, ["diff", "--no-color", "-M", "--no-ext-diff", base, commit.sha]); | |
| 349 | + const out = await git(dir, ["diff", "--no-color", "-M", "--no-ext-diff", "--end-of-options", rev(base), rev(commit.sha)]); | |
| 327 | 350 | return parseDiff(out); |
| 328 | 351 | } |
| 329 | 352 | |
| @@ -380,12 +403,45 @@ export function parseDiff(out: string): FileDiff[] { | ||
| 380 | 403 | |
| 381 | 404 | export type GitService = "git-upload-pack" | "git-receive-pack"; |
| 382 | 405 | |
| 406 | + const HOOKS_DIR = path.join(DATA_ROOT, "hooks"); | |
| 407 | + | |
| 408 | + // Receives "<old> <new> <ref>" lines; refuses refs with a path component | |
| 409 | + // starting with "-", which git commands could mistake for options. | |
| 410 | + const PRE_RECEIVE_HOOK = `#!/bin/sh | |
| 411 | + status=0 | |
| 412 | + while read -r old new ref; do | |
| 413 | + case "/$ref" in | |
| 414 | + */-*) echo "Vectrum: refusing $ref (ref name components must not start with '-')" >&2; status=1 ;; | |
| 415 | + esac | |
| 416 | + done | |
| 417 | + exit $status | |
| 418 | + `; | |
| 419 | + | |
| 420 | + let hooksInstalled = false; | |
| 421 | + | |
| 422 | + /** Server-side hooks for every repository, (re)written once per process so updates apply. */ | |
| 423 | + function hooksDir() { | |
| 424 | + if (!hooksInstalled) { | |
| 425 | + mkdirSync(HOOKS_DIR, { recursive: true }); | |
| 426 | + writeFileSync(path.join(HOOKS_DIR, "pre-receive"), PRE_RECEIVE_HOOK, { mode: 0o755 }); | |
| 427 | + hooksInstalled = true; | |
| 428 | + } | |
| 429 | + return HOOKS_DIR; | |
| 430 | + } | |
| 431 | + | |
| 432 | + /** | |
| 433 | + * Starts upload-pack or receive-pack for a transport. HTTP uses stateless RPC; | |
| 434 | + * SSH streams a whole session. Pushes always run Vectrum's hooks, whatever the | |
| 435 | + * repository's own hook configuration. | |
| 436 | + */ | |
| 383 | 437 | export function spawnService( |
| 384 | 438 | service: GitService, |
| 385 | 439 | dir: string, |
| 386 | - opts: { advertise?: boolean; protocol?: string | null } = {}, | |
| 440 | + opts: { stateless?: boolean; advertise?: boolean; protocol?: string | null } = {}, | |
| 387 | 441 | ) { |
| 388 | - const args = [service.slice(4), "--stateless-rpc"]; | |
| 442 | + const args = service === "git-receive-pack" ? ["-c", `core.hooksPath=${hooksDir()}`] : []; | |
| 443 | + args.push(service.slice(4)); | |
| 444 | + if (opts.stateless ?? true) args.push("--stateless-rpc"); | |
| 389 | 445 | if (opts.advertise) args.push("--advertise-refs"); |
| 390 | 446 | args.push(dir); |
| 391 | 447 | return spawn("git", args, { |
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { cache } from "react"; |
| 3 | - import { headers } from "next/headers"; | |
| 4 | 3 | import { forbidden, notFound } from "next/navigation"; |
| 5 | 4 | import { getViewer } from "./auth"; |
| 6 | 5 | import { defaultBranch, isBranch, normalizeRepoName, resolveRefAndPath } from "./git"; |
| 7 | 6 | import { canWrite, findRepo, repoAccess, type RepoRecord } from "./repos"; |
| 7 | + import { serverHost, serverOrigin } from "./request-origin"; | |
| 8 | 8 | import { sshCloneUrl } from "./ssh-config"; |
| 9 | 9 | |
| 10 | 10 | /** |
| @@ -58,23 +58,17 @@ export async function getBranchAndPath(repo: Repo, segments: string[]) { | ||
| 58 | 58 | return { branch: resolved.ref, path: resolved.path, exists: true }; |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | - async function requestHost() { | |
| 62 | - const h = await headers(); | |
| 63 | - return { host: h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000", proto: h.get("x-forwarded-proto") ?? "http" }; | |
| 64 | - } | |
| 65 | - | |
| 66 | 61 | export async function cloneUrls(repo: RepoRecord) { |
| 67 | - const { host, proto } = await requestHost(); | |
| 62 | + const [origin, host] = await Promise.all([serverOrigin(), serverHost()]); | |
| 68 | 63 | return { |
| 69 | - https: `${proto}://${host}/${repo.owner}/${repo.name}.git`, | |
| 64 | + https: `${origin}/${repo.owner}/${repo.name}.git`, | |
| 70 | 65 | ssh: sshCloneUrl(host, `${repo.owner}/${repo.name}.git`), |
| 71 | 66 | }; |
| 72 | 67 | } |
| 73 | 68 | |
| 74 | 69 | /** The repository's container image, e.g. vectrum.cc/alice/app. Image names are lowercase. */ |
| 75 | 70 | export async function imageName(repo: RepoRecord) { |
| 76 | - const { host } = await requestHost(); | |
| 77 | - return `${host}/${repo.owner}/${repo.name}`.toLowerCase(); | |
| 71 | + return `${await serverHost()}/${repo.owner}/${repo.name}`.toLowerCase(); | |
| 78 | 72 | } |
| 79 | 73 | |
| 80 | 74 | export type { RepoRecord }; |
| @@ -1,8 +1,21 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { headers } from "next/headers"; |
| 3 | 3 | |
| 4 | - /** The address this request reached the server at, e.g. https://vectrum.cc. */ | |
| 4 | + // Host and X-Forwarded-* headers are client-controlled, so the canonical | |
| 5 | + // address comes from configuration. Without PUBLIC_URL (local development), | |
| 6 | + // URLs fall back to the request's Host header over plain HTTP. | |
| 7 | + const PUBLIC_URL = process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL) : null; | |
| 8 | + | |
| 9 | + /** Session cookies are marked Secure when the instance is served over HTTPS (or SECURE_COOKIES=1). */ | |
| 10 | + export const SECURE_COOKIES = process.env.SECURE_COOKIES === "1" || PUBLIC_URL?.protocol === "https:"; | |
| 11 | + | |
| 12 | + /** The address users reach this server at, e.g. https://vectrum.cc. */ | |
| 5 | 13 | export async function serverOrigin() { |
| 6 | - const h = await headers(); | |
| 7 | - return `${h.get("x-forwarded-proto") ?? "http"}://${h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000"}`; | |
| 14 | + if (PUBLIC_URL) return PUBLIC_URL.origin; | |
| 15 | + return `http://${(await headers()).get("host") ?? "localhost:3000"}`; | |
| 16 | + } | |
| 17 | + | |
| 18 | + /** Host (and port) part of serverOrigin(), e.g. vectrum.cc. */ | |
| 19 | + export async function serverHost() { | |
| 20 | + return new URL(await serverOrigin()).host; | |
| 8 | 21 | } |
| @@ -26,11 +26,3 @@ export async function readBody(request: Request) { | ||
| 26 | 26 | return {}; |
| 27 | 27 | } |
| 28 | 28 | } |
| 29 | - | |
| 30 | - /** The server's address as the runner reached it, for clone URLs. */ | |
| 31 | - export function originOf(request: Request) { | |
| 32 | - const url = new URL(request.url); | |
| 33 | - const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host; | |
| 34 | - const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", ""); | |
| 35 | - return `${proto}://${host}`; | |
| 36 | - } | |
| @@ -1,9 +1,8 @@ | ||
| 1 | - import { spawn } from "node:child_process"; | |
| 2 | 1 | import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; |
| 3 | 2 | import path from "node:path"; |
| 4 | 3 | import { Server, utils, type Connection } from "ssh2"; |
| 5 | 4 | import { onRefsUpdated, startWorkflowsInBackground } from "./ci"; |
| 6 | - import { normalizeRepoName, snapshotRefs } from "./git"; | |
| 5 | + import { normalizeRepoName, snapshotRefs, spawnService } from "./git"; | |
| 7 | 6 | import { findKeyByBlob, touchSshKey } from "./keys"; |
| 8 | 7 | import { DATA_ROOT } from "./paths"; |
| 9 | 8 | import { canWrite, findRepo, markPushed, repoAccess } from "./repos"; |
| @@ -87,7 +86,10 @@ function handleConnection(client: Connection) { | ||
| 87 | 86 | |
| 88 | 87 | // Pushes start workflows for the refs they changed. |
| 89 | 88 | const refsBefore = pushing ? await snapshotRefs(repo.dir) : null; |
| 90 | - const child = spawn("git", [service, repo.dir], { env: { ...process.env, ...env } }); | |
| 89 | + const child = spawnService(pushing ? "git-receive-pack" : "git-upload-pack", repo.dir, { | |
| 90 | + stateless: false, | |
| 91 | + protocol: env.GIT_PROTOCOL, | |
| 92 | + }); | |
| 91 | 93 | channel.pipe(child.stdin); |
| 92 | 94 | child.stdout.pipe(channel, { end: false }); |
| 93 | 95 | child.stderr.pipe(channel.stderr, { end: false }); |
| @@ -8,7 +8,8 @@ | ||
| 8 | 8 | "start": "next start", |
| 9 | 9 | "lint": "eslint", |
| 10 | 10 | "postinstall": "node scripts/install-pocketbase.mjs", |
| 11 | - "pocketbase:install": "node scripts/install-pocketbase.mjs" | |
| 11 | + "pocketbase:install": "node scripts/install-pocketbase.mjs", | |
| 12 | + "test": "bun test" | |
| 12 | 13 | }, |
| 13 | 14 | "dependencies": { |
| 14 | 15 | "@codemirror/language": "^6.12.4", |
| @@ -50,5 +51,8 @@ | ||
| 50 | 51 | "trustedDependencies": [ |
| 51 | 52 | "sharp", |
| 52 | 53 | "unrs-resolver" |
| 53 | - ] | |
| 54 | + ], | |
| 55 | + "patchedDependencies": { | |
| 56 | + "ssh2@1.17.0": "patches/ssh2@1.17.0.patch" | |
| 57 | + } | |
| 54 | 58 | } |
| @@ -0,0 +1,283 @@ | ||
| 1 | + diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js | |
| 2 | + index ad7759257d6bdbcafa353dec8946258d64c14eb0..6fb70b8720f6d7cf8ae3692a0fa14fbeb35eb16f 100644 | |
| 3 | + --- a/lib/protocol/constants.js | |
| 4 | + +++ b/lib/protocol/constants.js | |
| 5 | + @@ -32,6 +32,29 @@ const curve25519Supported = (typeof crypto.diffieHellman === 'function' | |
| 6 | + && typeof crypto.generateKeyPairSync === 'function' | |
| 7 | + && typeof crypto.createPublicKey === 'function'); | |
| 8 | + | |
| 9 | + +const mlkemSupported = (() => { | |
| 10 | + + try { | |
| 11 | + + if (!curve25519Supported | |
| 12 | + + || typeof crypto.encapsulate !== 'function' | |
| 13 | + + || typeof crypto.decapsulate !== 'function') { | |
| 14 | + + return false; | |
| 15 | + + } | |
| 16 | + + const keys = crypto.generateKeyPairSync('ml-kem-768'); | |
| 17 | + + const { ciphertext } = crypto.encapsulate( | |
| 18 | + + keys.publicKey | |
| 19 | + + ); | |
| 20 | + + | |
| 21 | + + crypto.decapsulate( | |
| 22 | + + keys.privateKey, | |
| 23 | + + ciphertext | |
| 24 | + + ); | |
| 25 | + + | |
| 26 | + + return true; | |
| 27 | + + } catch { | |
| 28 | + + return false; | |
| 29 | + + } | |
| 30 | + +})(); | |
| 31 | + + | |
| 32 | + const DEFAULT_KEX = [ | |
| 33 | + // https://tools.ietf.org/html/rfc5656#section-10.1 | |
| 34 | + 'ecdh-sha2-nistp256', | |
| 35 | + @@ -52,6 +75,11 @@ if (curve25519Supported) { | |
| 36 | + DEFAULT_KEX.unshift('curve25519-sha256'); | |
| 37 | + DEFAULT_KEX.unshift('curve25519-sha256@libssh.org'); | |
| 38 | + } | |
| 39 | + + | |
| 40 | + +if (mlkemSupported) | |
| 41 | + + DEFAULT_KEX.unshift('mlkem768x25519-sha256'); | |
| 42 | + + | |
| 43 | + + | |
| 44 | + const SUPPORTED_KEX = DEFAULT_KEX.concat([ | |
| 45 | + // https://tools.ietf.org/html/rfc4419#section-4 | |
| 46 | + 'diffie-hellman-group-exchange-sha1', | |
| 47 | + @@ -189,6 +217,9 @@ module.exports = { | |
| 48 | + KEXECDH_INIT: 30, | |
| 49 | + KEXECDH_REPLY: 31, | |
| 50 | + | |
| 51 | + + KEX_HYBRID_INIT: 30, | |
| 52 | + + KEX_HYBRID_REPLY: 31, | |
| 53 | + + | |
| 54 | + // User auth protocol -- generic (50-59) | |
| 55 | + USERAUTH_REQUEST: 50, | |
| 56 | + USERAUTH_FAILURE: 51, | |
| 57 | + @@ -349,6 +380,7 @@ module.exports = { | |
| 58 | + | |
| 59 | + curve25519Supported, | |
| 60 | + eddsaSupported, | |
| 61 | + + mlkemSupported, | |
| 62 | + }; | |
| 63 | + | |
| 64 | + module.exports.DISCONNECT_REASON_BY_VALUE = | |
| 65 | + diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js | |
| 66 | + index 811e631b8ab78d32776e540919d25aae56d628f7..75252c5632c55ae7b9f86cc58011a64254742a2e 100644 | |
| 67 | + --- a/lib/protocol/kex.js | |
| 68 | + +++ b/lib/protocol/kex.js | |
| 69 | + @@ -6,7 +6,9 @@ const { | |
| 70 | + createECDH, | |
| 71 | + createHash, | |
| 72 | + createPublicKey, | |
| 73 | + + decapsulate, | |
| 74 | + diffieHellman, | |
| 75 | + + encapsulate, | |
| 76 | + generateKeyPairSync, | |
| 77 | + randomFillSync, | |
| 78 | + } = require('crypto'); | |
| 79 | + @@ -23,6 +25,7 @@ const { | |
| 80 | + DEFAULT_COMPRESSION, | |
| 81 | + DISCONNECT_REASON, | |
| 82 | + MESSAGE, | |
| 83 | + + mlkemSupported, | |
| 84 | + } = require('./constants.js'); | |
| 85 | + const { | |
| 86 | + CIPHER_INFO, | |
| 87 | + @@ -1580,6 +1583,184 @@ const createKeyExchange = (() => { | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + + class MLKEMx25519Exchange extends KeyExchange { | |
| 92 | + + // NOTE: based on the following draft | |
| 93 | + + // https://datatracker.ietf.org/doc/html/draft-ietf-sshm-mlkem-hybrid-kex-03.html | |
| 94 | + + constructor(hashName, ...args) { | |
| 95 | + + super(...args); | |
| 96 | + + | |
| 97 | + + this.type = 'mlkem768x25519'; | |
| 98 | + + this.hashName = hashName; | |
| 99 | + + | |
| 100 | + + this._x25519Keys = null; | |
| 101 | + + this._mlkemKeys = null; | |
| 102 | + + | |
| 103 | + + this._S_CT2 = null; // needed for server | |
| 104 | + + } | |
| 105 | + + | |
| 106 | + + generateKeys() { | |
| 107 | + + if (!this._x25519Keys) | |
| 108 | + + this._x25519Keys = generateKeyPairSync('x25519'); | |
| 109 | + + | |
| 110 | + + if (!this._mlkemKeys) | |
| 111 | + + this._mlkemKeys = generateKeyPairSync('ml-kem-768'); | |
| 112 | + + } | |
| 113 | + + | |
| 114 | + + convertPublicKey(key) { | |
| 115 | + + // We need to override this because the key | |
| 116 | + + // is not an mpint (compare section 2.1 on the draft | |
| 117 | + + // to section 8 on rfc4253) | |
| 118 | + + return key; | |
| 119 | + + } | |
| 120 | + + | |
| 121 | + + getPublicKey() { | |
| 122 | + + this.generateKeys(); | |
| 123 | + + | |
| 124 | + + const isServer = this._protocol._server; | |
| 125 | + + | |
| 126 | + + if (!isServer) { | |
| 127 | + + // client | |
| 128 | + + const C_PK1 = this._x25519Keys.publicKey | |
| 129 | + + .export({ type: 'spki', format: 'der' }) | |
| 130 | + + .slice(-32); | |
| 131 | + + | |
| 132 | + + const C_PK2 = this._mlkemKeys.publicKey | |
| 133 | + + .export({ type: 'spki', format: 'der' }) | |
| 134 | + + .slice(-1184); | |
| 135 | + + | |
| 136 | + + return Buffer.concat([C_PK2, C_PK1]); | |
| 137 | + + } | |
| 138 | + + | |
| 139 | + + // server | |
| 140 | + + | |
| 141 | + + // NOTE: S_CT2 should already have been | |
| 142 | + + // calculated by now, since client is the | |
| 143 | + + // one that initiates the kex | |
| 144 | + + const S_PK1 = this._x25519Keys.publicKey | |
| 145 | + + .export({ type: 'spki', format: 'der' }) | |
| 146 | + + .slice(-32); | |
| 147 | + + | |
| 148 | + + return Buffer.concat([this._S_CT2, S_PK1]); | |
| 149 | + + } | |
| 150 | + + | |
| 151 | + + buildX25519SPKI(key) { | |
| 152 | + + const asnWriter = new Ber.Writer(); | |
| 153 | + + asnWriter.startSequence(); | |
| 154 | + + // algorithm | |
| 155 | + + asnWriter.startSequence(); | |
| 156 | + + asnWriter.writeOID('1.3.101.110'); // id-X25519 | |
| 157 | + + asnWriter.endSequence(); | |
| 158 | + + | |
| 159 | + + // PublicKey | |
| 160 | + + asnWriter.startSequence(Ber.BitString); | |
| 161 | + + asnWriter.writeByte(0x00); | |
| 162 | + + // XXX: hack to write a raw buffer without a tag -- yuck | |
| 163 | + + asnWriter._ensure(key.length); | |
| 164 | + + key.copy( | |
| 165 | + + asnWriter._buf, | |
| 166 | + + asnWriter._offset, | |
| 167 | + + 0, | |
| 168 | + + key.length | |
| 169 | + + ); | |
| 170 | + + asnWriter._offset += key.length; | |
| 171 | + + asnWriter.endSequence(); | |
| 172 | + + asnWriter.endSequence(); | |
| 173 | + + | |
| 174 | + + return asnWriter; | |
| 175 | + + } | |
| 176 | + + | |
| 177 | + + computeSecret(otherPublicKey) { | |
| 178 | + + try { | |
| 179 | + + this.generateKeys(); | |
| 180 | + + const isServer = this._protocol._server; | |
| 181 | + + if (isServer && !this._S_CT2) { | |
| 182 | + + // server | |
| 183 | + + const C_PK2 = otherPublicKey.slice(0, 1184); | |
| 184 | + + const C_PK1 = otherPublicKey.slice(1184); | |
| 185 | + + | |
| 186 | + + if (C_PK1.length !== 32) { | |
| 187 | + + const got = C_PK1.length; | |
| 188 | + + throw new Error( | |
| 189 | + + `Invalid C_PK1 length: expected 32, got ${got}` | |
| 190 | + + ); | |
| 191 | + + } | |
| 192 | + + | |
| 193 | + + const x25519SPKI = this.buildX25519SPKI(C_PK1); | |
| 194 | + + | |
| 195 | + + const K_CL = diffieHellman({ | |
| 196 | + + privateKey: this._x25519Keys.privateKey, | |
| 197 | + + publicKey: createPublicKey({ | |
| 198 | + + key: x25519SPKI.buffer, | |
| 199 | + + type: 'spki', | |
| 200 | + + format: 'der', | |
| 201 | + + }), | |
| 202 | + + }); | |
| 203 | + + | |
| 204 | + + const templateDER = this._mlkemKeys.publicKey | |
| 205 | + + .export({ type: 'spki', format: 'der' }); | |
| 206 | + + | |
| 207 | + + const C_PK2_DER = Buffer.allocUnsafe(templateDER.length); | |
| 208 | + + | |
| 209 | + + templateDER.copy(C_PK2_DER, 0, 0, templateDER.length - 1184); | |
| 210 | + + | |
| 211 | + + C_PK2.copy(C_PK2_DER, templateDER.length - 1184); | |
| 212 | + + | |
| 213 | + + const { sharedKey: K_PQ, ciphertext: S_CT2 } = encapsulate( | |
| 214 | + + createPublicKey({ | |
| 215 | + + key: C_PK2_DER, | |
| 216 | + + type: 'spki', | |
| 217 | + + format: 'der', | |
| 218 | + + }) | |
| 219 | + + ); | |
| 220 | + + | |
| 221 | + + this._S_CT2 = S_CT2; | |
| 222 | + + | |
| 223 | + + // K = HASH(K_PQ || K_CL) from 2.4 on draft | |
| 224 | + + const K = createHash('sha256') | |
| 225 | + + .update(K_PQ) | |
| 226 | + + .update(K_CL) | |
| 227 | + + .digest(); | |
| 228 | + + | |
| 229 | + + return K; | |
| 230 | + + } | |
| 231 | + + // client | |
| 232 | + + const S_CT2 = otherPublicKey.slice(0, 1088); | |
| 233 | + + const S_PK1 = otherPublicKey.slice(1088); | |
| 234 | + + | |
| 235 | + + if (S_PK1.length !== 32) { | |
| 236 | + + const got = S_PK1.length; | |
| 237 | + + throw new Error( | |
| 238 | + + `Invalid S_PK1 length: expected 32, got ${got}` | |
| 239 | + + ); | |
| 240 | + + } | |
| 241 | + + | |
| 242 | + + const x25519SPKI = this.buildX25519SPKI(S_PK1); | |
| 243 | + + | |
| 244 | + + const K_CL = diffieHellman({ | |
| 245 | + + privateKey: this._x25519Keys.privateKey, | |
| 246 | + + publicKey: createPublicKey({ | |
| 247 | + + key: x25519SPKI.buffer, | |
| 248 | + + type: 'spki', | |
| 249 | + + format: 'der', | |
| 250 | + + }), | |
| 251 | + + }); | |
| 252 | + + | |
| 253 | + + const K_PQ = decapsulate(this._mlkemKeys.privateKey, S_CT2); | |
| 254 | + + | |
| 255 | + + // K = HASH(K_PQ || K_CL) from 2.4 on draft | |
| 256 | + + const K = createHash('sha256') | |
| 257 | + + .update(K_PQ) | |
| 258 | + + .update(K_CL) | |
| 259 | + + .digest(); | |
| 260 | + + | |
| 261 | + + return K; | |
| 262 | + + | |
| 263 | + + } catch (error) { | |
| 264 | + + return error; | |
| 265 | + + } | |
| 266 | + + } | |
| 267 | + + } | |
| 268 | + + | |
| 269 | + return (negotiated, ...args) => { | |
| 270 | + if (typeof negotiated !== 'object' || negotiated === null) | |
| 271 | + throw new Error('Invalid negotiated argument'); | |
| 272 | + @@ -1587,6 +1768,11 @@ const createKeyExchange = (() => { | |
| 273 | + if (typeof kexType === 'string') { | |
| 274 | + args = [negotiated, ...args]; | |
| 275 | + switch (kexType) { | |
| 276 | + + case 'mlkem768x25519-sha256': | |
| 277 | + + if (!mlkemSupported) | |
| 278 | + + break; | |
| 279 | + + return new MLKEMx25519Exchange('sha256', ...args); | |
| 280 | + + | |
| 281 | + case 'curve25519-sha256': | |
| 282 | + case 'curve25519-sha256@libssh.org': | |
| 283 | + if (!curve25519Supported) | |