ReviewOS

also looking at this

loghqorg/loghq

A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials

#15
Merged glennmichael123 wants to merge feat/read-scoped-project-key into main
9 files +537 -17
app/Support/read-key.tsadded+104-0
Changes to app/Support/read-key.ts
@@ -0,0 +1,104 @@
1/**
2 * A project's read key: the credential a machine presents to read that
3 * project's logs.
4 *
5 * The ingest key next to it is deliberately public — it ships inside client
6 * bundles, it only permits writes to one project, and `app/Errors/ingest.ts`
7 * compares it with `===` because nothing is lost by leaking it. A read key is
8 * the opposite on both counts: it returns data, and it is meant to sit in a
9 * server's configuration and nowhere else. So it is longer, it is compared in
10 * constant time, and it is never accepted on an ingest route.
11 *
12 * It authorises exactly one project. There is no account behind it, so it
13 * outlives the person who created it and carries none of their scope — which
14 * is the whole reason it exists (loghqorg/loghq#14): a Stacks dashboard
15 * reading logs server-to-server should not be holding somebody's session.
16 */
17
18/**
19 * `lhq_read_` then 256 bits of hex.
20 *
21 * The prefix is doing real work. It tells a read key apart from a user's
22 * bearer token before either is verified, so a read key is never handed to
23 * `Auth.getUserFromToken`; and it gives secret scanners something to match, so
24 * a key pasted into a repository can be found and revoked.
25 */
26const PREFIX = 'lhq_read_'
27const BODY = /^[0-9a-f]{64}$/
28
29export function newReadKey(): string {
30 const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32))
31 return PREFIX + Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
32}
33
34/** Whether a presented credential is shaped like a read key at all. */
35export function looksLikeReadKey(value: unknown): value is string {
36 return typeof value === 'string' && value.startsWith(PREFIX) && BODY.test(value.slice(PREFIX.length))
37}
38
39/**
40 * Equal, in time that does not depend on how much of the key is right.
41 *
42 * `===` on a string short-circuits at the first differing byte, which leaks
43 * the length of a correct prefix to anyone who can measure the response. That
44 * is a real attack on a secret an attacker can guess at repeatedly, and not
45 * one on the public ingest key, which is why only this side does it.
46 */
47export function sameKey(presented: string, stored: string): boolean {
48 if (presented.length !== stored.length)
49 return false
50 let diff = 0
51 for (let i = 0; i < presented.length; i++)
52 diff |= presented.charCodeAt(i) ^ stored.charCodeAt(i)
53 return diff === 0
54}
55
56export interface ReadKeyProject {
57 id: string
58 read_key?: string | null
59 is_active?: boolean | number | null
60}
61
62export type ReadAuthorization =
63 | { ok: true }
64 | { ok: false, status: 401 | 403 | 404, error: string }
65
66/**
67 * Whether this key may read this project.
68 *
69 * A project nobody can name is a 404 rather than a 401: answering "that
70 * project exists, your key is wrong" tells an unauthenticated caller which
71 * project ids are real.
72 */
73export function authorizeRead(project: ReadKeyProject | null | undefined, presented: unknown): ReadAuthorization {
74 if (!project)
75 return { ok: false, status: 404, error: 'unknown project' }
76 if (project.is_active === false || project.is_active === 0)
77 return { ok: false, status: 403, error: 'project inactive' }
78 // No key issued is not the same as "any key will do": a project without one
79 // must refuse every presented key, including an empty one.
80 if (!looksLikeReadKey(project.read_key))
81 return { ok: false, status: 403, error: 'project has no read key' }
82 if (!looksLikeReadKey(presented) || !sameKey(presented, project.read_key))
83 return { ok: false, status: 401, error: 'invalid read key' }
84 return { ok: true }
85}
86
87/**
88 * How often a key's last-used stamp is written.
89 *
90 * The stamp is there so somebody can see whether a key is still in use before
91 * revoking it, which a minute's resolution answers. Writing on every request
92 * would put an UPDATE in front of every read of a log stream that a dashboard
93 * polls.
94 */
95export const LAST_USED_RESOLUTION_MS = 60_000
96
97export function shouldRecordUse(lastUsedAt: string | Date | null | undefined, now: Date): boolean {
98 if (!lastUsedAt)
99 return true
100 const previous = lastUsedAt instanceof Date ? lastUsedAt.getTime() : Date.parse(String(lastUsedAt))
101 if (Number.isNaN(previous))
102 return true
103 return now.getTime() - previous >= LAST_USED_RESOLUTION_MS
104}