also looking at this
A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials
#15
9 files
+537
-17
| @@ -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 | */ | |
| 26 | const PREFIX = 'lhq_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 ReadKeyProject { | |
| 57 | id: string | |
| 58 | read_key?: string | null | |
| 59 | is_active?: boolean | number | null | |
| 60 | } | |
| 61 | ||
| 62 | export 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 | */ | |
| 73 | export 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 | */ | |
| 95 | export const LAST_USED_RESOLUTION_MS = 60_000 | |
| 96 | ||
| 97 | export 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 | } | |