Skip to content
Closed
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
97 changes: 97 additions & 0 deletions backend/src/core/downloadTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import crypto from "crypto";

export type DownloadTokenPayload = {
path: string;
filename: string;
/** Unix timestamp (seconds) after which the token is invalid. */
exp?: number;
};

function b64urlEncode(buf: Buffer): string {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}

function b64urlDecode(s: string): Buffer {
let t = s.replace(/-/g, "+").replace(/_/g, "/");
while (t.length % 4) t += "=";
return Buffer.from(t, "base64");
}

function timingSafeEqStr(a: string, b: string): boolean {
const maxLen = Math.max(a.length, b.length, 1);
const aBuf = Buffer.alloc(maxLen, 0);
const bBuf = Buffer.alloc(maxLen, 0);
Buffer.from(a).copy(aBuf);
Buffer.from(b).copy(bBuf);
return crypto.timingSafeEqual(aBuf, bBuf) && a.length === b.length;
}

const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days

export function signDownloadPayload(
payload: DownloadTokenPayload,
secret: string,
ttlSeconds = DEFAULT_TTL_SECONDS,
): string {
const exp = payload.exp ?? Math.floor(Date.now() / 1000) + ttlSeconds;
const encodedPayload = b64urlEncode(
Buffer.from(
JSON.stringify({ p: payload.path, f: payload.filename, e: exp }),
"utf8",
),
);
const signature = crypto
.createHmac("sha256", secret)
.update(encodedPayload)
.digest();
return `${encodedPayload}.${b64urlEncode(signature)}`;
}

export function verifyDownloadPayload(
token: string,
secret: string,
): DownloadTokenPayload | null {
const parts = token.split(".");
if (parts.length !== 2) return null;

const [encodedPayload, encodedSignature] = parts;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(encodedPayload)
.digest();

if (!timingSafeEqStr(encodedSignature, b64urlEncode(expectedSignature))) {
return null;
}

try {
const parsed = JSON.parse(
b64urlDecode(encodedPayload).toString("utf8"),
) as {
p: unknown;
f: unknown;
e?: unknown;
};
if (typeof parsed.p !== "string" || typeof parsed.f !== "string") {
return null;
}
if (!parsed.p || !parsed.f) return null;
// Every token must carry an expiry. A token without `e` is legacy (issued
// before expiry existed) and would otherwise be valid forever, so reject it
// — any such link is long stale (all issuers have set `e` since), and a
// fresh, expiring token is re-issued on next access.
if (
typeof parsed.e !== "number" ||
parsed.e < Math.floor(Date.now() / 1000)
) {
return null;
}
return { path: parsed.p, filename: parsed.f };
} catch {
return null;
}
}
37 changes: 37 additions & 0 deletions backend/src/lib/__tests__/downloadTokens.expiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { signDownloadPayload, verifyDownloadPayload } from "../../core/downloadTokens";

describe("token expiry", () => {
const SECRET = "expiry-test-secret-value-32bytes!";

it("accepts a token whose exp is in the future", () => {
const futureExp = Math.floor(Date.now() / 1000) + 3600;
const token = signDownloadPayload(
{ path: "p", filename: "f.pdf", exp: futureExp },
SECRET,
);
expect(verifyDownloadPayload(token, SECRET)).not.toBeNull();
});

it("rejects a token whose exp is in the past", () => {
const pastExp = Math.floor(Date.now() / 1000) - 1;
const token = signDownloadPayload(
{ path: "p", filename: "f.pdf", exp: pastExp },
SECRET,
);
expect(verifyDownloadPayload(token, SECRET)).toBeNull();
});

it("rejects a legacy token with no exp field (would otherwise never expire)", () => {
// Manually build a well-signed token without the 'e' field to simulate
// old tokens. Such a token was previously accepted forever; it must now
// be rejected so every valid token carries an expiry.
const b64u = (buf: Buffer) =>
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
const crypto = require("crypto") as typeof import("crypto");
const payload = b64u(Buffer.from(JSON.stringify({ p: "path", f: "file.pdf" })));
const sig = b64u(crypto.createHmac("sha256", SECRET).update(payload).digest());
const token = `${payload}.${sig}`;
expect(verifyDownloadPayload(token, SECRET)).toBeNull();
});
});
71 changes: 16 additions & 55 deletions backend/src/lib/downloadTokens.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import crypto from "crypto";
import {
signDownloadPayload,
verifyDownloadPayload,
} from "../core/downloadTokens";

/**
* HMAC-signed, non-expiring download tokens.
Expand All @@ -10,72 +13,30 @@ import crypto from "crypto";
*/

function getSecret(): string {
const secret = process.env.DOWNLOAD_SIGNING_SECRET;
if (!secret) {
throw new Error(
"DOWNLOAD_SIGNING_SECRET must be set. " +
"Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.",
);
}
return secret;
}

function b64urlEncode(buf: Buffer): string {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}

function b64urlDecode(s: string): Buffer {
let t = s.replace(/-/g, "+").replace(/_/g, "/");
while (t.length % 4) t += "=";
return Buffer.from(t, "base64");
}

function timingSafeEqStr(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
const secret = process.env.DOWNLOAD_SIGNING_SECRET;
if (!secret) {
throw new Error(
"DOWNLOAD_SIGNING_SECRET must be set. " +
"Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.",
);
}
return secret;
}

export function signDownload(path: string, filename: string): string {
const payload = JSON.stringify({ p: path, f: filename });
const enc = b64urlEncode(Buffer.from(payload, "utf8"));
const sig = crypto
.createHmac("sha256", getSecret())
.update(enc)
.digest();
return `${enc}.${b64urlEncode(sig)}`;
return signDownloadPayload({ path, filename }, getSecret());
}

export function verifyDownload(
token: string,
token: string,
): { path: string; filename: string } | null {
const parts = token.split(".");
if (parts.length !== 2) return null;
const [enc, sigEnc] = parts;
const expected = crypto
.createHmac("sha256", getSecret())
.update(enc)
.digest();
if (!timingSafeEqStr(sigEnc, b64urlEncode(expected))) return null;
try {
const parsed = JSON.parse(b64urlDecode(enc).toString("utf8")) as {
p: string;
f: string;
};
if (!parsed?.p || !parsed?.f) return null;
return { path: parsed.p, filename: parsed.f };
} catch {
return null;
}
return verifyDownloadPayload(token, getSecret());
}

/**
* Returns a relative download URL (e.g. "/download/abc.def"). The frontend
* prefixes it with NEXT_PUBLIC_API_BASE_URL when rendering `<a href=…>`.
*/
export function buildDownloadUrl(path: string, filename: string): string {
return `/download/${signDownload(path, filename)}`;
return `/download/${signDownload(path, filename)}`;
}
2 changes: 1 addition & 1 deletion backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"]
}