TAIA Confidential Screening: protocol specification and threat model

TAIA's own protocol, not a standard, no outside review. This document describes how TAIA Confidential Screening works and what it does and does not protect. It is published under CC BY 4.0; the test-vector script is under the MIT licence, with the Microsoft SEAL and node-seal notices kept.

Version 0.1, 01.11.2026. Legal checked the wording (note 16). Nothing here is an outside review: every security statement below is TAIA's own reading of its own code and of public tables.

Protocol idtaia-confidential-screening-1
Normalisation idcscr-norm-2
Parameter classn4096-t20-d9
Code readorigin/develop at d14dd147d (02.11.2026), which includes the shape gate (#2613, 6c39683d4); services/he-svc/src/screening-protocol.mjs sha256 05eb3a037627596ef4f5b9bb30304e294ed0773b292074c708c9cb5401c8c1f6; services/he-svc/src/seal-shape.mjs sha256 94f8002fea93e71f6e1c93438cb4783b056cae00c42dde37b0f1107acbbe54c0
Live parameters readGET https://he.taia.ch/v1/screen/params, once, 2026-11-01T21:27:29Z; they equal the code's (section 9). Which commit the live image was built from was not checked for this draft.
Test vectorsscreening-test-vectors/ beside this file (section 11)

Labels: [code] read in the code named above; [live] read in the live parameters; [measured] produced by the test-vector script on 01.11.2026; [repo] stated in a repo README or manifest, not read from the running system; [estimate] reasoning, not a measurement; [open] not settled.

---

1. What the protocol does, in one paragraph

The visitor's browser makes a fresh BFV key for each check, turns the name into one number x and one bin b, encrypts the powers x¹ … x⁹ under that key, and sends the nine ciphertexts to he-svc. he-svc holds the three public sanctions lists as polynomials whose roots are the listed names' numbers. It evaluates every polynomial on the encrypted powers, multiplies each result by a fresh random mask, and returns one ciphertext per polynomial row. Only the browser's key opens them. In the browser, a slot that decrypts to zero at bin b in a list's row means "possible match on that list". The service evaluates every row of every list for every check, the same way whatever the name. [code]

What it does not do [code]: fuzzy matching, transliteration, partial names, dates of birth or other identifiers; the UN list; hiding the list size, the number or time of checks, or the caller's network address; noise flooding (circuit privacy); any check that the answer was computed honestly; an OPRF step. It has had no outside cryptography review.

2. Message flow

Browser (taia.ch/screening/check/)                        he-svc (he.taia.ch, via traefik)
 1. GET /v1/screen/params  ─────────────────────────────▶  public JSON (no name, no key)
 2. GET /v1/screen/quota   ─────────────────────────────▶  {plan, limit, remaining, resets_at}
    [header X-Taia-Access-Key only if the visitor gave one]
 3. normalise(name) → SHA-256 → (bin b, x)                 
 4. fresh key: KeyGenerator → secret key sk
 5. encrypt x^1 … x^9 (same value in all 4096 slots),
    symmetric, seeded; zstd; frame
 6. POST /v1/screen/query  ─────────────────────────────▶  rate bucket → access check → gate slot
    application/octet-stream, ~418 KB                       → read body → size guard → evaluate
                           ◀─────────────────────────────  7 ciphertexts, ~620 KB, + quota headers
 7. decrypt each answer; read slot b;
    list L "possible match" ⇔ some row of L is 0 at b
 8. dispose the key; show the result                       (nothing about the result reaches he-svc)

Step 8 [code]: after decoding, screening-check.js makes no request that depends on the result. Links to the publishers' own search pages are shown for a possible match; following one is the visitor's own request to that publisher.

3. Routes

RouteMethodRequestResponseLimits on the way [code] / [repo]
/v1/screen/paramsGETnoneJSON (section 9 lists the fields)traefik: 120/min, burst 60, per address
/v1/screen/quotaGEToptional X-Taia-Access-Key{plan, limit, remaining, resets_at}; 401 unknown_keysame
/v1/screen/queryPOSTapplication/octet-stream, ≤ 1,048,576 bytes, optional X-Taia-Access-Keyapplication/octet-stream; headers x-he-server-ms, x-screen-plan, x-screen-limit, x-screen-remaining, x-screen-resets-atsee below

/v1/screen/query, in order [code]:

  1. Free check: one token from the address's bucket (30/min, burst 10; 429 rate_limited + Retry-After).
  2. Access check before the body is read: free quota (10 per address per UTC day; 429 free_quota_used), or key (401 unknown_key, 429 key_daily_cap).
  3. Keyed check: one token from the key's own bucket (30/min, burst 10; 429 rate_limited).
  4. A slot of the pod's gate: 4 running, 8 waiting, free traffic at most 3 + 6 (503 busy, Retry-After: 2).
  5. Content type (415), body size (413), framing and ciphertext checks (400 bad_request), evaluation, then the count is taken.

traefik in front [repo, k8s/he-svc/ingressroute.yaml]: 30/min burst 10 per address; free route only: 20 checks per address per 24 h (backstop for he-svc's own 10); at most 4 checks in flight for all clients; 1 MiB body limit. A query string on any route is refused unread (400).

4. Byte layout

4.1 Framing (both directions) [code: pack/unpack]

u32be  count
repeat count times:
  u32be  len      (len ≥ 1)
  len bytes       one SEAL-serialised ciphertext

Nothing may follow the last part. The service accepts count = 9 exactly in a query; the browser accepts up to 256 parts in an answer and requires exactly the number the parameters announce (7).

Request parts are checked against the service's own ciphertext layout before the library reads them (since 2 November 2026).

4.2 One part: a SEAL 4.1 serialisation [code: seal-size.mjs] [measured]

offset size  field
0      2     magic 0xA15E (little-endian bytes 5E A1)
2      1     header size = 16
3      1     SEAL version major = 4
4      1     SEAL version minor = 1
5      1     compression mode = 2 (zstd)
6      2     reserved = 0
8      8     total size of this part, u64 little-endian
16     …     exactly one zstd frame that declares its content size, nothing after it

The service reads the declared inflated size before SEAL sees the part and refuses anything other than this shape, or a declared size above 131,072 bytes (400 with the public body {"error":"bad_request"}; the service's own log records bad_size). SEAL's zstd refuses a frame that inflates past its declared size, so the declared size bounds memory.

partswire size per part [measured]declared inflated sizecontent
Query9~46.4 KB65,714 bytesseeded symmetric BFV ciphertext at the first data level (2 primes, 72 bits): one polynomial plus the seed of the second; size 2, not transparent
Answer7~88.5 KB131,169 bytesBFV ciphertext of size 2 at the same level
Whole query417,961–418,272 bytes in the vectors
Whole answer619,636–619,747 bytes in the vectors

The compressed sizes vary by a few hundred bytes from check to check. [estimate] The variation follows the pseudo-random ciphertext coefficients, not the name or the result; an outside reviewer should confirm this.

5. Normalisation cscr-norm-2 [code: normalise()]

Applied identically by the browser and by the list builder, in this order:

  1. remove apostrophes ' ’ ‘ ´ ʼ ʻ`;
  2. Unicode NFKD;
  3. remove combining marks (\p{M});
  4. lower case;
  5. fold ß→ss æ→ae œ→oe ø→o ł→l đ→d ð→d þ→th ı→i ħ→h ŧ→t;
  6. remove apostrophes again (compatibility forms such as ');
  7. every run of characters that are neither letters (\p{L}) nor digits (\p{N}) becomes one space;
  8. split on spaces, drop empty words, sort the words (JavaScript default sort, UTF-16 code-unit order), join with one space.

Consequences: case, accents, punctuation and word order do not change the result; any other difference does (a missing or extra word, another transliteration, a typo). Names in non-Latin scripts are compared as written. An input that is empty after normalisation is refused by the page before any request. Worked examples are in vectors.json → normalisation.

6. Item derivation and binning [code: itemOf()]

D   = UTF-8("taia-confidential-screening-1\n")
h   = SHA-256( D || UTF-8(normalise(name)) )
bin = u32be(h[0..4]) mod B                       B = 4096
x   = ( u32be(h[4..8]) · 2^32 + u32be(h[8..12]) ) mod t      t = 1,032,193
if x = 0 then x = 1

One hash function, no cuckoo hashing. B equals the number of BFV slots (N = 4096), so slot b of every plaintext belongs to bin b. The bias of a 64-bit value reduced mod a 20-bit t is about 2⁻⁴⁴ [estimate].

7. The service's index [code: buildScreeningDB()] [live]

8. Evaluation [code: answerQuery()]

For each list in order, for each row j:

  1. Draw a fresh mask vector r_j[b], uniform in [1, t) for every bin, from crypto.getRandomValues (32-bit draws, rejection sampling).
  2. For m = 9 … 1: if coefficient m of row j is non-zero in at least one bin, add Enc(x^m) ⊙ encode(r_j[b] · a_jm[b]). Coefficient 9 is 1 in every bin, so the sum always starts.
  3. If coefficient 0 is non-zero in some bin, add the plaintext encode(r_j[b] · a_j0[b]).
  4. Serialise with zstd.

Only plaintext × ciphertext products and additions; no relinearisation, no Galois or evaluation keys, no modulus switching. Which terms are skipped depends on the lists only, not on the query. In slot b, the answer decrypts to r_j[b] · P_jb(x). t is prime and r ≠ 0, so the slot is 0 exactly when x is a root of that row, and otherwise uniform over [1, t), independent of P_jb(x).

Noise budget left after this work: 21 bits in every answer of the test vectors [measured].

9. Decoding: the match bit and the per-list result [code: clientDecode()]

for each list L in params.answers order:
    hit[L] = false
    for each of its rows:
        ct = next answer part
        if invariantNoiseBudget(ct) ≤ 0: error (nothing is shown as a result)
        slots = decode(decrypt(ct))
        if slots[bin] == 0: hit[L] = true
possible_match_any = OR of hit[L]

The page reads the list set and the row counts from params.lists and params.answers, not from its own constants.

Chance match (a name on none of the lists shows "possible match"): a bin holds at most L distinct roots and x is uniform over t − 1 values, so at most one in ⌊(t − 1)/L⌋ such checks. [live]

worst bin Lat most one in
OFAC SDN2443,008
EU consolidated list1857,344
SECO1379,399
Any list (max_bin_load_any, false_match_one_in_any)3827,162

Missed match: a listed person or entity whose name the visitor spells differently from every spelling on the list (beyond section 5) gives "no match". "No match" means "this exact normalised spelling is not on these list snapshots", nothing more. This is a property of exact matching, not of the encryption.

10. Parameters

ValueSource
SchemeBFV[code] [live]
LibraryMicrosoft SEAL 4.1.2 through node-seal 7.0.0 (WebAssembly; MIT); the same build in the browser and in he-svc[code] [live]
wasm sha256 (seal_throws.wasm)b30d3776265a56373a2001d2cd48baa8d72ddd571e04ddfaefd4c231271170e4[measured]
Ring dimension N4096[code] [live]
Plain modulus t1,032,193 (20 bits; a batching prime, t ≡ 1 mod 2N)[code] [live]
Coefficient modulusSEAL BFVDefault(4096, tc128): primes 68,719,403,009 (36 bits), 68,719,230,977 (36 bits), 137,438,822,401 (37 bits)[measured]
Total, key level109 bits (36 + 36 + 37)[measured]
Data level (what ciphertexts on the wire carry)72 bits (36 + 36); the third prime is SEAL's special prime[measured] [live: coeff_modulus_bits: 72]
SEAL security checkSEALContext(params, true, SecLevelType.tc128): SEAL refuses a total above its 128-bit table[code]
Secret key distributionternary (SEAL default)[estimate: SEAL default, not inspected in this build]
Error distributionSEAL default, standard deviation ≈ 3.2[estimate: SEAL default, not inspected in this build]
Bins B / query degree D4096 / 9[code] [live]
Modulus switches0[code]
Key lifetimeone check; clientDispose() frees it after decoding; the public key is generated in the browser and not sent[code]

Check of the figures given in the brief (109 total, 72 data level): both match the code. The live endpoint reports only the data level (72) as coeff_modulus_bits. One correction to the wording in R4: the full parameter set (109 bits) is at the HomomorphicEncryption.org Security Standard v1.1, Table 1 bound for N = 4096 with a ternary secret at 128-bit classical security (109 bits), not below it with a margin. The 72-bit figure is the modulus of the ciphertexts he-svc receives; the 109-bit total includes the special prime that only the key-level objects (secret key, public key) use, and they stay in the browser.

Wording proposed for outward text (legal to check): "Our encryption parameters use ring dimension 4096 and a total coefficient modulus of 109 bits, which equals the upper limit of the 128-bit classical security row of the HomomorphicEncryption.org Security Standard v1.1, Table 1, for this ring dimension; the ciphertexts we receive use 72 bits of it. This is our own check against a public table, not an outside review."

Not post-quantum. TAIA does not claim post-quantum security for this protocol. The full 109-bit modulus is above the same standard's post-quantum bound for N = 4096 with a ternary secret (Table 2: 101 bits). Whether the 72-bit level alone supports any post-quantum statement is a question for the outside review; this draft makes no such statement.

Live parameter fields (GET /v1/screen/params, stable names) [live]: protocol, scheme, library, param_class, poly_modulus_degree, plain_modulus, plain_modulus_bits, coeff_modulus_bits, bins, degree, answers[] {list, rows, max_bin_load, names, false_match_one_in}, max_bin_load_any, false_match_one_in_any, normalisation, lists[] {key, label, title, publisher_date, retrieved_at, records, names, source_sha256, attribution, max_bin_load, false_match_one_in}, snapshot_sha256, snapshot_names, free_daily_limit, max_query_bytes.

11. Threat model

The statements below assume the browser runs the page code exactly as TAIA serves it, unless the row says otherwise. "Learns" means: can read or compute from what it receives or stores.

11.1 he-svc (TAIA's service process) and anyone who controls it

What it receives or holdsDetail
9 query ciphertexts per checkunder a key it does not receive; reading them without that key rests on the hardness of RLWE at N = 4096 with a 72-bit modulus (section 10), not on anything TAIA controls
Query size and answer sizenearly constant (section 4.2)
Time of each check and its own processing timex-he-server-ms is sent back to the browser
The caller's network addressread from X-Real-Ip (set by traefik from the TCP peer) for the length of the request; used to compute the free-quota tag; not written to its log [code]
An HMAC tag of the addressHMAC-SHA-256 under a 32-byte secret drawn at start-up and again at every 00:00 UTC; tag → count table and per-minute buckets kept in memory, cleared at 00:00 UTC and on restart [code]
The access key, if sentcompared as SHA-256 against hashes from a mounted Secret; the log carries TAIA's key id (e.g. k-2026-001), not the key [code]
Its log line per requestroute, method, status, error, plan, key_id (keyed checks), ms; no body, no address, no headers, no exception text [code]
The resultnot sent back by the page [code]

Limits of these statements:

11.2 A dishonest or compromised service: result integrity

11.3 The page code: delivery by TAIA

11.4 The network and the edge

11.5 AMD SEV on he-svc's node

11.6 Summary table

ActorName in clearResultAddressTime / count / sizeCan alter the result
he-svc as servedno (ciphertext only)noin memory per request; HMAC tag until 00:00 UTCyesyes, undetected
traefik / TAIA operator with log accessnonoyes, in access logyesyes (inside the cluster, after TLS)
On-path network observer, no TLS keysnonoyesyes (shape identifies a check)no, while TLS holds
Google Cloud as hostnonoyes (platform level)yesas platform operator, by tampering [estimate]
TAIA serving modified page codeyesyesyesyesyes
Modified clientits own nameits ownn/an/an/a; can ask up to 4,096 names per check

12. Test vectors

Folder: screening-test-vectors/ beside this file.

FileContent
make-vectors.mjsthe script: generate writes the vectors; verify re-checks them
vectors.jsonparameters (incl. the three primes), environment, normalisation examples, three cases with name, normalised form, bin, x, result, sizes, noise budget
case-N-*.secret-key.binthe test secret key (SEAL SecretKey, zstd); a key made only for the vector
case-N-*.query.binthe exact POST body
case-N-*.answer.binthe exact response body
SHA256SUMSsha256 of every file except the script

Determinism. node-seal 7.0.0 has no seed parameter. Its only entropy source is the Emscripten randomFill, which calls crypto.getRandomValues; the service's masks use the same function. The script replaces globalThis.crypto.getRandomValues with SHA-256 in counter mode over (seed, stream label, counter), with separate streams client/<case> and server/<case>. Seed (hex): 7461696120636f6e666964656e7469616c2073637265656e696e672074762031. The client stream drew 4,816–4,944 bytes per case (key generation and encryption), the server stream 114,688 bytes (7 rows × 4,096 slots × 4 bytes). The live page and service do not do this; it exists only in the test script.

Cases [measured]

CaseInputNormalisedbinxOFACEUSECOnoise left
1SAHA AIRLINES (a listed airline entity)airlines saha1114770756matchmatchmatch21 bits
2Bank Melli (a listed bank entity)bank melli144982390matchmatchno21 bits
3Testname Nomatch Example (invented)example nomatch testname3446753275nonono21 bits

Case 2 shows the per-list result: SECO lists the name as "Bank Melli PLC", whose normalised form differs. The vectors use entities only, no natural persons.

Reproducibility [measured, 01.11.2026, Node v22.13.1, node-seal 7.0.0]: generate was run twice into separate folders; diff -r found no difference, and the published folder equals the first run. verify then passed 26 of 26 checks: all sha256 sums; the parameters; for each case, the stored key opens the stored answer with the stored result (this check does not depend on the seed), the service's answer recomputed from the stored query is byte-identical, and the key, query and full run regenerated from the seed are byte-identical.

54f86b2de5ecc88a1029d26fb2198271dfce2bd34fbdde8649240398f29a6063  case-1-match-all.answer.bin
1584ccd9d25afb6d7711a0da8d3f2ea0f714b6f28647ede442d640a0b1f847f8  case-1-match-all.query.bin
6c0a222aac482770cfd0015ed38aa0e9ca3692a433f2233496af528d04e3d07c  case-1-match-all.secret-key.bin
416e5412faa4f1a790ab362d2c0d9e4d1481396405cdfc9d8600d6a50a0375b0  case-2-match-some.answer.bin
24eeb333e4f7720e303c6cfd517c2e8495211440bb5adacd3ff646c3b33d35c1  case-2-match-some.query.bin
9ad17ea932043f34809e69b01f14c568a60827c859ad75229ca1d4766334eb58  case-2-match-some.secret-key.bin
0745c59300ff7fa315c6e30f56ccdff1f25869e5db6760fd76f549870b7f787b  case-3-no-match.answer.bin
a55741f06d9d0f84bffc21ac5639e40367ed44af86cc4d54d0e98d3cda85c3f8  case-3-no-match.query.bin
7cc4dc2cbb2929518e25bf5b6ace9162b268fc361afc92fda9a094c75af31bc0  case-3-no-match.secret-key.bin
1aac0ddeeae3a8a0b86b2fbaf2906f2e30cbef94d00ed5352039732b5f3651d4  vectors.json
make-vectors.mjs: 4384cb835a01455fa107394e757c6cf0860f90237303353d556d59c069e73985

Re-run:

git archive <commit> services/he-svc | tar -x -C <dir>
cd <dir>/services/he-svc && npm ci
node --experimental-wasm-exnref make-vectors.mjs generate --he-svc <dir>/services/he-svc --out <out>
node --experimental-wasm-exnref make-vectors.mjs verify   --he-svc <dir>/services/he-svc --vectors <folder>

The vectors depend on the list snapshot (snapshot_sha256 in vectors.json): a new weekly list build changes answer.bin and may change the rows; a protocol, normalisation or parameter change also changes query.bin. Byte equality across other Node or node-seal versions has not been tested [open]. A query file is a valid request to the live service with the same snapshot; the answer would differ in bytes (fresh masks) but open to the same result with the stored key. That was not done for this draft (one live call was allowed: the parameters).

13. Versioning, changelog, contact

Version rules (proposal).

Changelog.

VersionDateChange
0.1 (draft)01.11.2026First draft: spec, parameters, threat model, test vectors. Not reviewed by legal or by an outside cryptographer.

Contact.

  1. Integrity (11.2): the page shows "no match" without any proof the answer was computed honestly. How the terms and the page describe the result, and who carries the risk of a false "no match".
  2. Page delivery (11.3): "the service does not receive the name" holds only for the page code as TAIA serves it. The current page and terms text should say so in the same sentence as the privacy statement.
  3. Address and logs (11.1, 11.4): traefik logs the client address with time and route; he-svc logs key id with time; these can be joined. The 30-day retention and the existing 396-day key-id bucket (time, key id, plan; no address) must match the privacy notice.
  4. HMAC wording: "pseudonymised", not "anonymised"; reversible by enumeration while the day's secret exists.
  5. Many names per check (11.1): a modified client can screen up to 4,096 names in one paid check. Terms and pricing question, not a privacy one.
  6. SEV (11.5): "protected from Google's host", not "protected from TAIA"; the badge relies on a node label, not attestation.
  7. Parameters (10): the 109-bit total is at the table's bound, not below it; no post-quantum statement.
  8. "No match" meaning (9): exact normalised spelling against dated list snapshots; the chance-match figure; missed matches on spelling variants.

15. Open questions

  1. Live state not read for this draft: the live image commit, traefik's live access-log fields and log retention, and whether the SEV node class is applied.
  2. Ternary secret and error width in this exact SEAL build (defaults assumed, not inspected).
  3. Whether the compressed-size variation (4.2) carries any information; for the outside reviewer.
  4. Byte reproducibility of the vectors on other Node, node-seal or CPU combinations.
  5. Whether the canary-slot idea (11.2) should be built before the outside review, so the review covers it.
  6. Where the spec, script and vectors are to be published (repository, taia.ch path) and under which licence.

Markdown