ReviewOS

also looking at this

stacks/analyticshq

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

#68
Merged glennmichael123 wants to merge feat/read-scoped-site-key into main
7 files +550 -11
app/Support/read-key.tsadded+102-0
Changes to app/Support/read-key.ts
@@ -0,0 +1,102 @@
1/**
2 * A site's read key: the credential a machine presents to read that site's
3 * analytics.
4 *
5 * There was no per-site credential to extend. The tracker is a public script
6 * beaconing to a site id, so unlike loghq and bughq this is a new credential
7 * rather than a second mode on an existing one — and unlike those, it has no
8 * public sibling to be confused with. It returns data, so it is a secret: 256
9 * bits, compared in constant time, and accepted only on the reads listed on
10 * the issue rather than on all forty-odd site routes.
11 *
12 * It authorises exactly one site. 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 (stacksjs/analyticshq#67): a Stacks dashboard
15 * reading a site server-to-server should not be holding somebody's session.
16 */
17
18/**
19 * `ahq_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 = 'ahq_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 ReadKeySite {
57 id: string
58 read_key?: string | null
59}
60
61export type ReadAuthorization =
62 | { ok: true }
63 | { ok: false, status: 401 | 403 | 404, error: string }
64
65/**
66 * Whether this key may read this site.
67 *
68 * Site ids are public — they are embedded in the tracking snippet — so unlike
69 * loghq and bughq, telling 404 from 403 here leaks nothing, and the existing
70 * requireSiteRole already distinguishes them. The wording matches it so a
71 * refusal reads the same whichever credential was presented.
72 */
73export function authorizeRead(site: ReadKeySite | null | undefined, presented: unknown): ReadAuthorization {
74 if (!site)
75 return { ok: false, status: 404, error: 'Site not found' }
76 // No key issued is not the same as "any key will do": a site without one
77 // must refuse every presented key, including an empty one.
78 if (!looksLikeReadKey(site.read_key))
79 return { ok: false, status: 403, error: 'Forbidden' }
80 if (!looksLikeReadKey(presented) || !sameKey(presented, site.read_key))
81 return { ok: false, status: 401, error: 'Unauthorized' }
82 return { ok: true }
83}
84
85/**
86 * How often a key's last-used stamp is written.
87 *
88 * The stamp is there so somebody can see whether a key is still in use before
89 * revoking it, which a minute's resolution answers. Writing on every request
90 * would put an UPDATE in front of every read of a log stream that a dashboard
91 * polls.
92 */
93export const LAST_USED_RESOLUTION_MS = 60_000
94
95export function shouldRecordUse(lastUsedAt: string | Date | null | undefined, now: Date): boolean {
96 if (!lastUsedAt)
97 return true
98 const previous = lastUsedAt instanceof Date ? lastUsedAt.getTime() : Date.parse(String(lastUsedAt))
99 if (Number.isNaN(previous))
100 return true
101 return now.getTime() - previous >= LAST_USED_RESOLUTION_MS
102}