Skip to content
File

Blob: src/worker/auth/pat.ts

typescript264 lines
1import { createDb, type Db } from "@/worker/db/d1/client";
2import {
3 findPatByPrefix,
4 findPatGrantForNamespace,
5 findPatGrantForRepo,
6 type PatGrantLevel,
7} from "@/worker/db/d1/dal/tokens";
8import { findMembership, findNamespaceBySlug } from "@/worker/db/d1/dal/namespaces";
9 
10// Parse/generate/hash/validate helpers and `verifyPat` share a single file
11// so the management and verification surfaces stay on the same parsing and
12// hashing rules.
13 
14const PAT_PREFIX_PUBLIC = "goc_";
15const PAT_PREFIX_HEX_LENGTH = 8;
16// 32-char base32 secret, 5 bits per char => 160 bits of entropy.
17const PAT_SECRET_BASE32_LENGTH = 32;
18const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
19 
20const PAT_PATTERN = /^goc_([0-9a-f]{8})_([abcdefghijklmnopqrstuvwxyz234567]{32})$/;
21 
22export function formatPatPublicPrefix(hexPrefix: string): string {
23 return `${PAT_PREFIX_PUBLIC}${hexPrefix}`;
24}
25 
26function bytesToHex(bytes: Uint8Array): string {
27 let out = "";
28 for (let i = 0; i < bytes.length; i += 1) {
29 out += bytes[i]!.toString(16).padStart(2, "0");
30 }
31 return out;
32}
33 
34function bytesToBase32(bytes: Uint8Array): string {
35 let bits = 0;
36 let value = 0;
37 let out = "";
38 for (let i = 0; i < bytes.length; i += 1) {
39 value = (value << 8) | bytes[i]!;
40 bits += 8;
41 while (bits >= 5) {
42 bits -= 5;
43 out += BASE32_ALPHABET[(value >>> bits) & 0x1f];
44 }
45 }
46 if (bits > 0) {
47 out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
48 }
49 return out;
50}
51 
52export type GeneratedPat = {
53 plaintext: string;
54 publicPrefix: string;
55};
56 
57// Mint a fresh PAT plaintext + its public prefix. The hex prefix lookups
58// the row; the secret is what we hash. The full plaintext (prefix +
59// secret) is what callers must hash via `hashPatPlaintext`.
60export function generatePatPlaintext(): GeneratedPat {
61 const prefixBytes = new Uint8Array(PAT_PREFIX_HEX_LENGTH / 2);
62 crypto.getRandomValues(prefixBytes);
63 // base32 needs ceil(secretLength * 5 / 8) bytes of entropy. 32 chars * 5 bits = 160 bits = 20 bytes.
64 const secretBytes = new Uint8Array(20);
65 crypto.getRandomValues(secretBytes);
66 const hexPrefix = bytesToHex(prefixBytes);
67 const secret = bytesToBase32(secretBytes).slice(0, PAT_SECRET_BASE32_LENGTH);
68 const publicPrefix = formatPatPublicPrefix(hexPrefix);
69 return { plaintext: `${publicPrefix}_${secret}`, publicPrefix };
70}
71 
72export type ParsePatResult =
73 | { ok: true; publicPrefix: string; secret: string }
74 | { ok: false; reason: "malformed" };
75 
76// Validate basic shape and split a plaintext into its public prefix + secret
77// segments. We never compare segments; the verifier hashes the full
78// plaintext and compares against the stored hash.
79export function parsePatPlaintext(plaintext: string): ParsePatResult {
80 if (typeof plaintext !== "string") return { ok: false, reason: "malformed" };
81 const match = PAT_PATTERN.exec(plaintext);
82 if (!match) return { ok: false, reason: "malformed" };
83 return {
84 ok: true,
85 publicPrefix: formatPatPublicPrefix(match[1]!),
86 secret: match[2]!,
87 };
88}
89 
90export async function hashPatPlaintext(plaintext: string): Promise<string> {
91 const data = new TextEncoder().encode(plaintext);
92 const digest = await crypto.subtle.digest("SHA-256", data);
93 return bytesToHex(new Uint8Array(digest));
94}
95 
96const PAT_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9 _-]{0,38}[A-Za-z0-9])?$/;
97 
98export type PatNameValidation =
99 | { ok: true; name: string }
100 | { ok: false; reason: "format" | "length" };
101 
102export function validatePatName(input: string): PatNameValidation {
103 if (typeof input !== "string") return { ok: false, reason: "format" };
104 const trimmed = input.trim();
105 if (trimmed.length === 0 || trimmed.length > 40) return { ok: false, reason: "length" };
106 if (!PAT_NAME_PATTERN.test(trimmed)) return { ok: false, reason: "format" };
107 return { ok: true, name: trimmed };
108}
109 
110export type PatVerifyOk = {
111 ok: true;
112 patId: string;
113 userId: string;
114 namespaceId: string;
115 repositoryId?: string;
116 // `level === "push"` authorizes receive-pack; any present grant
117 // authorizes fetch/clone because `push` includes `pull` by construction
118 // (DB CHECK constraint on `pat_*_grants.level`).
119 level: PatGrantLevel;
120 // Loaded value at verification time so callers throttle the write
121 // decision in Worker memory; see `shouldTouchPatLastUsedAt`.
122 lastUsedAt: number | null;
123};
124 
125export type PatVerifyError =
126 | { ok: false; reason: "malformed" }
127 | { ok: false; reason: "username-mismatch" }
128 | { ok: false; reason: "token-not-found" }
129 | { ok: false; reason: "token-revoked" }
130 | { ok: false; reason: "token-expired" }
131 | { ok: false; reason: "grant-missing" };
132 
133export type PatVerifyResult = PatVerifyOk | PatVerifyError;
134 
135async function constantTimeEquals(a: string, b: string): Promise<boolean> {
136 if (a.length !== b.length) return false;
137 const encoder = new TextEncoder();
138 const aBytes = encoder.encode(a);
139 const bBytes = encoder.encode(b);
140 // Prefer Cloudflare's timingSafeEqual when present; fall back to a tight
141 // JS XOR so this still works in non-Workers vitest unit-only contexts.
142 type CfSubtle = SubtleCrypto & {
143 timingSafeEqual?: (
144 left: ArrayBuffer | ArrayBufferView,
145 right: ArrayBuffer | ArrayBufferView
146 ) => boolean;
147 };
148 const subtle = crypto.subtle as CfSubtle;
149 if (typeof subtle.timingSafeEqual === "function") {
150 return subtle.timingSafeEqual(aBytes, bBytes);
151 }
152 let result = 0;
153 for (let i = 0; i < aBytes.length; i += 1) {
154 result |= aBytes[i]! ^ bBytes[i]!;
155 }
156 return result === 0;
157}
158 
159export type VerifyPatArgs = {
160 username: string;
161 plaintext: string;
162 // Resolved route context supplies these ids before verification. The
163 // verifier does not infer repository identity from storage names.
164 namespaceId?: string;
165 repositoryId?: string;
166 now?: number;
167 db?: Db;
168};
169 
170// Verify a PAT against the stored hash and grants. Returns the user/scope
171// when the token is currently valid for the requested namespace + repo
172// pair, otherwise a tagged failure that the caller maps to an HTTP
173// status (typically 401 for malformed/not-found, 403 for grant-missing).
174export async function verifyPat(env: Env, args: VerifyPatArgs): Promise<PatVerifyResult> {
175 const now = args.now ?? Date.now();
176 const parsed = parsePatPlaintext(args.plaintext);
177 if (!parsed.ok) return { ok: false, reason: "malformed" };
178 
179 const db = args.db ?? createDb(env.DB);
180 
181 const pat = await findPatByPrefix(db, parsed.publicPrefix);
182 if (!pat) return { ok: false, reason: "token-not-found" };
183 if (pat.revokedAt !== null) return { ok: false, reason: "token-revoked" };
184 if (pat.expiresAt !== null && pat.expiresAt <= now) return { ok: false, reason: "token-expired" };
185 
186 const expectedHash = pat.hash;
187 const presentedHash = await hashPatPlaintext(args.plaintext);
188 if (!(await constantTimeEquals(expectedHash, presentedHash))) {
189 return { ok: false, reason: "token-not-found" };
190 }
191 
192 // Resolve namespace either directly via id or by username; both must align
193 // for the username-as-namespace-slug Git authentication contract.
194 let resolvedNamespaceId: string | undefined = args.namespaceId;
195 let resolvedRepositoryId: string | undefined = args.repositoryId;
196 if (!resolvedNamespaceId) {
197 const namespace = await findNamespaceBySlug(db, args.username);
198 if (!namespace) return { ok: false, reason: "username-mismatch" };
199 resolvedNamespaceId = namespace.id;
200 } else {
201 const namespace = await findNamespaceBySlug(db, args.username);
202 if (!namespace || namespace.id !== resolvedNamespaceId) {
203 return { ok: false, reason: "username-mismatch" };
204 }
205 }
206 
207 // Repo grant takes precedence; namespace grant covers remaining repos.
208 if (resolvedRepositoryId) {
209 const repoGrant = await findPatGrantForRepo(db, pat.id, resolvedRepositoryId);
210 if (repoGrant) {
211 return {
212 ok: true,
213 patId: pat.id,
214 userId: pat.userId,
215 namespaceId: resolvedNamespaceId,
216 repositoryId: resolvedRepositoryId,
217 level: repoGrant.level,
218 lastUsedAt: pat.lastUsedAt,
219 };
220 }
221 }
222 const namespaceGrant = await findPatGrantForNamespace(db, pat.id, resolvedNamespaceId);
223 if (namespaceGrant) {
224 return {
225 ok: true,
226 patId: pat.id,
227 userId: pat.userId,
228 namespaceId: resolvedNamespaceId,
229 repositoryId: resolvedRepositoryId,
230 level: namespaceGrant.level,
231 lastUsedAt: pat.lastUsedAt,
232 };
233 }
234 
235 return { ok: false, reason: "grant-missing" };
236}
237 
238// Writes always update; reads only when the prior value is older than this
239// window (or null) so D1 is not written on every clone.
240export const PAT_LAST_USED_READ_THROTTLE_MS = 15 * 60 * 1000;
241 
242export type PatLastUsedOp = "read" | "write";
243 
244export function shouldTouchPatLastUsedAt(
245 lastUsedAt: number | null,
246 op: PatLastUsedOp,
247 now: number = Date.now()
248): boolean {
249 if (op === "write") return true;
250 if (lastUsedAt === null) return true;
251 return now - lastUsedAt >= PAT_LAST_USED_READ_THROTTLE_MS;
252}
253 
254// Internal: callers in management endpoints need to confirm the
255// authenticated viewer is a member of the namespace they want to scope a
256// PAT to. Wraps the DAL composite-key lookup.
257export async function viewerIsNamespaceMember(
258 db: Db,
259 userId: string,
260 namespaceId: string
261): Promise<boolean> {
262 return (await findMembership(db, namespaceId, userId)) !== undefined;
263}