This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user