also looking at this
A read-scoped site key, so a Stacks dashboard can read analytics without a person's credentials
#68
7 files
+550
-11
| @@ -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 | */ | |
| 26 | const PREFIX = 'ahq_read_' | |
| 27 | const BODY = /^[0-9a-f]{64}$/ | |
| 28 | ||
| 29 | export 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. */ | |
| 35 | export 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 | */ | |
| 47 | export 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 | ||
| 56 | export interface ReadKeySite { | |
| 57 | id: string | |
| 58 | read_key?: string | null | |
| 59 | } | |
| 60 | ||
| 61 | export 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 | */ | |
| 73 | export 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 | */ | |
| 93 | export const LAST_USED_RESOLUTION_MS = 60_000 | |
| 94 | ||
| 95 | export 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 | } | |