Files

189 lines
8.1 KiB
TypeScript

// The person's platform analytics and observability data (ADR-049): start its erasure or export,
// follow the job, take the export away. Pure TypeScript over the ACCOUNT_PRIVACY port.
//
// The platform's GET routes answer every request made by this project's deployment tokens, so a
// request id from the browser would let one person read another's job or export. The browser
// therefore only ever holds a ticket, signed here:
//
// `v1.<request id>.<expires at, ms since the epoch>.<HMAC-SHA256(K, payload)>`
// payload = `v1|<expires at>|<len(user id)>:<user id>|<request id>`
//
// K is not the deployment token itself: it is HKDF-SHA256 over `ticketKey` (the token) with the info
// label TICKET_KEY_INFO, so the bearer credential never doubles as a MAC key and the ticket MAC is
// useless for anything else. The version and the expiry are inside the signed payload, so neither
// can be edited. A ticket is accepted only for the user it was signed for and only until it
// expires (TICKET_LIFETIME_MS); a ticket of somebody else, an expired one or a forged one is the
// same NotFound.
//
// Rotation: rotating the deployment token changes K, so every outstanding ticket stops verifying
// (NotFound). That is deliberate and harmless: nothing is stored, a ticket only lives in the open
// page, and asking again answers the open job (the platform's own idempotency) with a fresh ticket,
// or starts a new export once the last one finished. A reload forgets the ticket the same way.
import { AppError, conflict, notFound } from "@conusai-kit/core"
import type { AnalyticsView } from "../contract/procedures.js"
import type { AccountPrivacy, AccountPrivacyRequest } from "./ports.js"
const encoder = new TextEncoder()
/** The ticket format's version; part of both the ticket and its signed payload. */
export const TICKET_VERSION = "v1"
/** The HKDF info label that makes the ticket MAC key purpose-specific. */
export const TICKET_KEY_INFO = "conusai-template/account-privacy-ticket/v1"
/** How long a ticket names its request: a day, longer than any page stays open on a job. */
export const TICKET_LIFETIME_MS = 24 * 60 * 60 * 1000
const unavailable = (detail: string) =>
new AppError({
type: "/problems/unavailable",
title: "Service unavailable",
status: 503,
code: "unavailable",
detail,
})
function hex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
}
/** HKDF-SHA256(secret, salt = empty, info = TICKET_KEY_INFO) as a non-extractable HMAC key. */
async function ticketMacKey(secret: string): Promise<CryptoKey> {
const ikm = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"])
return crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(TICKET_KEY_INFO) },
ikm,
{ name: "HMAC", hash: "SHA-256", length: 256 },
false,
["sign"],
)
}
async function signature(secret: string, userId: string, requestId: string, expiresAt: number): Promise<string> {
// A length prefix, so ("ab", "c") and ("a", "bc") never sign alike; the request id is last and the
// other fields are fixed-form, so the payload parses one way only.
const payload = `${TICKET_VERSION}|${expiresAt}|${userId.length}:${userId}|${requestId}`
return hex(await crypto.subtle.sign("HMAC", await ticketMacKey(secret), encoder.encode(payload)))
}
/** Constant-time comparison of two hex strings. */
function sameHex(a: string, b: string): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
return diff === 0
}
/** A ticket naming `requestId` for `userId`, valid for TICKET_LIFETIME_MS from `now`. */
export async function ticketFor(secret: string, userId: string, requestId: string, now: number): Promise<string> {
const expiresAt = now + TICKET_LIFETIME_MS
return `${TICKET_VERSION}.${requestId}.${expiresAt}.${await signature(secret, userId, requestId, expiresAt)}`
}
/**
* The request id a ticket names, when it was signed for this user under this secret and has not
* expired at `now`; NotFound otherwise (one answer for forged, foreign and expired tickets).
*/
export async function requestIdOf(secret: string, userId: string, ticket: string, now: number): Promise<string> {
const missing = () => notFound("no such request for this account")
const parts = ticket.split(".")
// The request id may in principle hold dots: version first, expiry and MAC last.
if (parts.length < 4 || parts[0] !== TICKET_VERSION) throw missing()
const given = parts.at(-1)
const expiresText = parts.at(-2)
const requestId = parts.slice(1, -2).join(".")
if (!given || !expiresText || !requestId || !/^[0-9]{1,16}$/.test(expiresText)) throw missing()
const expiresAt = Number(expiresText)
const expected = await signature(secret, userId, requestId, expiresAt)
// The MAC first and always, then the clock: an expired ticket answers exactly like a forged one.
const authentic = sameHex(given, expected)
if (!authentic || !(now < expiresAt)) throw missing()
return requestId
}
/** The platform's refusals as the app's problems; the deployment token never shows in one. */
function platformFailure(error: unknown): AppError {
const status = typeof (error as { status?: unknown })?.status === "number" ? (error as { status: number }).status : 0
if (status === 404) return notFound("no such request for this account")
if (status === 409) return conflict("the export is not ready, has expired, or failed")
if (status === 403) return unavailable("this app's deployment token does not hold privacy:subjects")
return unavailable("the platform's privacy service did not answer")
}
const view = async (
privacy: AccountPrivacy,
userId: string,
request: AccountPrivacyRequest,
created: boolean,
now: number,
): Promise<AnalyticsView> => ({
ticket: await ticketFor(privacy.ticketKey, userId, request.id, now),
kind: request.kind,
status: request.status,
created,
stores: Object.fromEntries(
Object.entries(request.stores).map(([store, outcome]) => [
store,
{
status: outcome.status === "not_keyed" || outcome.status === "failed" ? outcome.status : "completed",
deleted: outcome.deleted ?? 0,
anonymized: outcome.anonymized ?? 0,
exported: outcome.exported ?? 0,
},
]),
) as AnalyticsView["stores"],
requestedAt: request.created_at,
finishedAt: request.finished_at ?? null,
expiresAt: request.export_expires_at ?? null,
})
const required = (privacy: AccountPrivacy | undefined): AccountPrivacy => {
if (!privacy) throw unavailable("this deployment has no platform privacy service")
return privacy
}
export async function requestAnalytics(
privacy: AccountPrivacy | undefined,
userId: string,
kind: AnalyticsView["kind"],
now: () => number,
): Promise<AnalyticsView> {
const port = required(privacy)
try {
const { request, created } =
kind === "erase" ? await port.eraseSubject({ userId }) : await port.exportSubject({ userId })
return await view(port, userId, request, created, now())
} catch (error) {
throw error instanceof AppError ? error : platformFailure(error)
}
}
export async function analyticsStatus(
privacy: AccountPrivacy | undefined,
userId: string,
ticket: string,
now: () => number,
): Promise<AnalyticsView> {
const port = required(privacy)
const requestId = await requestIdOf(port.ticketKey, userId, ticket, now())
try {
return await view(port, userId, await port.getRequest(requestId), false, now())
} catch (error) {
throw error instanceof AppError ? error : platformFailure(error)
}
}
export async function analyticsExport(
privacy: AccountPrivacy | undefined,
userId: string,
ticket: string,
now: () => number,
): Promise<{ exportedAt: string; document: Record<string, unknown> }> {
const port = required(privacy)
const requestId = await requestIdOf(port.ticketKey, userId, ticket, now())
try {
return { exportedAt: new Date(now()).toISOString(), document: await port.downloadExport(requestId) }
} catch (error) {
throw error instanceof AppError ? error : platformFailure(error)
}
}