import { elementAnchor } from './dom-xpath.ts' import { normalizeCandidates } from './normalize.ts' import type { OprfMode, ResponseMatch, ResponseRedaction } from './schema.ts' /** Reclaim attestor caps OPRF-hashed values at 62 bytes (TOPRF marker * encoding). Surfaces as a clean error here rather than a confusing * attestor-side failure at proof time. */ export const OPRF_MAX_BYTES = 62 export interface MatcherSynthesis { match: ResponseMatch redaction?: ResponseRedaction /** The captured value, keyed by `paramName`. NOT baked into the shipped * recipe — the caller uses it for the author's own proof; each verifying * user's value is re-extracted live (see verify/run.ts `extractBareParams`). */ paramValues?: Record } /** * Build the matcher pieces (one responseMatch + one responseRedaction) for a * target value seen in a captured response. The design, in one line: * * the REDACTION selects with xPath/jsonPath and names the value with a * capture group; the MATCH is a `contains` of the surrounding structure with * the value replaced by `{{paramName}}`. * * The match is what the attestor enforces (against the prover's revealed * bytes), so it must anchor the value in context the prover can't relocate — a * JSON key (`"login":"{{v}}"`) or the value's HTML element (`{{v}}`). * The redaction's regex only extracts/narrows; xPath/jsonPath do the selecting. * * `hash` (OPRF) is opt-in — enable it only when the request asks to protect * PII. It rides the `contains {{v}}` path: the redaction carries `hash`, match * keeps `{{v}}`, and the attestor substitutes the nullifier from paramValues at * proof time. Throws when `hash` is set on a free-form value (no safe class). */ export function synthesizeMatcher( body: string, contentType: string, target: string, paramName: string, hash?: OprfMode, ): MatcherSynthesis | undefined { if( !body.includes(target) && !normalizeCandidates(target).some((v) => body.includes(v)) ) { return undefined } if(hash && !inferValueClass(target)) { throw new Error( 'OPRF hashing requires a target with a known-safe character class ' + '(digits, slug, or email). ' + `Got "${target}" which is free-form. Use a tighter target ` + '(for example, just the alphanumeric portion of the value) ' + 'or call propose_provider without `hash`.', ) } const valueClass = inferValueClass(target) ?? '[^"<>\\n]+' // JSON body → the value lives in a `"key": value` pair. jsonPath pins the // exact property; the `contains` match anchors on the key. if(/json/i.test(contentType) || isJsonBody(body)) { const r = jsonMatcher(body, target, paramName, valueClass, hash) if(r) { return r } } // HTML/XML → the value lives in an element. xPath selects it (an id/class // ancestor, else the value's own tag); the `contains` match is the element // markup with the value swapped for the placeholder. A `` element, return its text * content (the JSON body the attestor exposes when a jsonPath is paired with * the script's xPath); otherwise undefined. */ function scriptJsonContent(markup: string): string | undefined { const match = /^]*>([\s\S]*)<\/script>\s*$/i.exec(markup) if(!match) { return undefined } const content = match[1].trim() return content.length > 0 ? content : undefined } // --------------------------------------------------------------------------- // Content-anchored fallback (no stable structural anchor) // --------------------------------------------------------------------------- /** Longest opening/closing HTML tag folded into the match context. */ const MAX_TAG_BYTES = 256 /** Bytes the left context grows per step while searching for uniqueness. */ const CONTEXT_STEP = 4 /** * Matcher for a value with no JSON key and no stable xPath anchor: a * content-anchored regex redaction (position-independent) paired with a match * carrying surrounding literal context, so it is never a bare — hence * spoofable — `{{value}}`. When there is genuinely no literal context, the * value-isolating regex itself becomes the (regex-type) match. */ function contentMatcher( body: string, target: string, paramName: string, valueClass: string, hash?: OprfMode, ): MatcherSynthesis | undefined { const regex = buildContentRegex(body, target, paramName, valueClass) if(!regex) { return undefined } const redaction: ResponseRedaction = { regex } if(hash) { redaction.hash = hash // OPRF needs a `contains {{v}}` match — the attestor substitutes the // nullifier into it (a regex match can't express that). Anchor on the // surrounding literal context so it's never a bare, spoofable `{{v}}`. return { match: { type: 'contains', value: buildContextualTemplate(body, target, paramName, valueClass), }, redaction, paramValues: { [paramName]: target }, } } // The value-isolating regex IS the match: grown to uniquely capture THIS // occurrence, so it's position-independent, self-extracting (the attestor // reads its named group — verification's `extractBareParams` can't grab a // same-shaped earlier occurrence, as a `contains "key":"{{v}}"` would), and // never a bare, spoofable `{{v}}`. return { match: { type: 'regex', value: regex }, redaction, paramValues: { [paramName]: target }, } } /** * A value-isolating regex around the first occurrence of `target`, matched by * CONTENT so it reproduces regardless of DOM position: leading literal context * (grown until the first whole-body match captures THIS occurrence) + a * `(?valueClass)` group + any trailing delimiter. The revealed span * must CONTAIN the match template (buildContextualTemplate), so the trailing * delimiter is kept unless it's whitespace (which the template also drops). */ /** Indices where `cand` occurs in `body` as a WHOLE match of `valueClass` — * that is, not a substring inside a longer run of the same class (the digit "5" * inside "354.85" is never a whole `\d+` match; "354" and "85" are). Only * meaningful for a structural (non-free-form) class. */ function wholeTokenIndices( body: string, cand: string, valueClass: string, ): number[] { const indices: number[] = [] let re: RegExp try { re = new RegExp(valueClass, 'g') } catch{ return indices } let m: RegExpExecArray | null while((m = re.exec(body))) { if(m[0] === cand) { indices.push(m.index) } if(re.lastIndex === m.index) { re.lastIndex++ } } return indices } function buildContentRegex( body: string, target: string, paramName: string, valueClass: string, ): string | undefined { const structural = inferValueClass(target) !== undefined for(const cand of normalizeCandidates(target)) { const occurrences = structural ? wholeTokenIndices(body, cand, valueClass) : undefined const idx = occurrences ? occurrences[0] ?? -1 : body.indexOf(cand) if(idx < 0) { continue } if(occurrences && occurrences.length > 1 && cand.replace(/[^A-Za-z0-9]/g, '').length < 4) { throw new Error( `target value "${target}" occurs ${occurrences.length} times as a ` + 'standalone token with no JSON key or HTML anchor to ' + "disambiguate — capture a request where it's unique, or use a " + 'longer/more specific target.', ) } const endIdx = idx + cand.length const rawRight = findRightContext(body, endIdx) const right = /^\s/.test(rawRight) ? '' : escapeRegexFlexWs(rawRight) const baseStart = idx - findLeftContext(body, idx).length for(let start = baseStart; ; start -= CONTEXT_STEP) { const from = Math.max(0, start) const before = escapeRegexFlexWs(body.slice(from, idx)) const re = `${before}(?<${paramName}>${valueClass})${right}` try { if(new RegExp(re).exec(body)?.groups?.[paramName] === cand) { return re } } catch{ break } if(from === 0) { break } } } return undefined } /** * The literal `contains` template: surrounding structural context with the * value replaced by `{{paramName}}`. Whitespace-adjacent context is dropped (a * literal contains can't flex whitespace); stable context (a JSON key, an HTML * opening tag) is kept so the match anchors on it. */ function buildContextualTemplate( body: string, target: string, paramName: string, valueClass: string, ): string { const idx = wholeTokenIndices(body, target, valueClass)[0] ?? body.indexOf(target) if(idx < 0) { return `{{${paramName}}}` } const endIdx = idx + target.length const leftCtx = /\s$/.test(findLeftContext(body, idx)) ? '' : findLeftContext(body, idx) const rightCtx = /^\s/.test(findRightContext(body, endIdx)) ? '' : findRightContext(body, endIdx) return `${leftCtx}{{${paramName}}}${rightCtx}` } /** * Walk left up to 32 bytes to the nearest meaningful anchor, stopping at the * first structural delimiter. On an HTML tag boundary (`>`) fold in the whole * opening tag — its class/id is the anchor; `>{{value}}<` alone would let a * prover surface the value from any element. A leading run of volatile * whitespace is skipped first so the anchor lands on the tag, not on `\s*`. */ function findLeftContext(body: string, idx: number): string { const MAX_CONTEXT_BYTES = 32 const stop = Math.max(0, idx - MAX_CONTEXT_BYTES) let scan = idx - 1 while(scan >= stop && /\s/.test(body[scan] ?? '')) { scan-- } for(let i = scan; i >= stop; i--) { const c = body[i] if(c === '>') { const tagStart = body.lastIndexOf('<', i) if(tagStart >= 0 && i - tagStart <= MAX_TAG_BYTES && body[tagStart + 1] !== '/') { return body.slice(tagStart, idx) } return body.slice(i, idx) } if(c === '{' || c === '}' || c === ',' || c === ';') { return body.slice(i + 1, idx) } } return body.slice(stop, idx) } function findRightContext(body: string, endIdx: number): string { const MAX_CONTEXT_BYTES = 16 const stop = Math.min(body.length, endIdx + MAX_CONTEXT_BYTES) for(let i = endIdx; i < stop; i++) { const c = body[i] if(c === '<') { const tagEnd = body.indexOf('>', i) if(tagEnd >= 0 && tagEnd - i <= MAX_TAG_BYTES) { return body.slice(endIdx, tagEnd + 1) } return body.slice(endIdx, i + 1) } if(c === '{' || c === '}' || c === ',' || c === ';' || c === '\n') { return body.slice(endIdx, i) } } return body.slice(endIdx, stop) } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** Escape `s` as a regex literal but match any run of whitespace flexibly * (`\s*`): HTML varies inter-token whitespace, so literal whitespace baked * into a redaction's capture regex would make it miss at proof time. */ function escapeRegexFlexWs(s: string): string { return s.split(/\s+/).map(escapeRegex).join('\\s*') } /** Derive a regex character class from the target's shape. Returns * `undefined` for free-form values — those can't be OPRF'd because we * can't write a regex capture that reliably matches arbitrary unicode / * punctuation. Caller refuses the hash request. */ function inferValueClass(target: string): string | undefined { if(/^[0-9]+$/.test(target)) { return '\\d+' } // Numbers with thousands separators / decimals, for example, "1,908" or // "12,345.6". if(/^[0-9][0-9,.]*[0-9]$/.test(target)) { return '[0-9,.]+' } if(/^[A-Za-z0-9_-]+$/.test(target)) { return '[A-Za-z0-9_-]+' } if(/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(target)) { return '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}' } return undefined } /** True when `target` has a character class safe to OPRF-hash. Free-form values * (names with spaces, arbitrary unicode/punctuation) return false — the caller * must NOT default them to a hash (it would throw in synthesizeMatcher). */ export function isOprfHashable(target: string): boolean { return inferValueClass(target) !== undefined }