189 lines
8.1 KiB
TypeScript
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)
|
|
}
|
|
}
|