# 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 id | `taia-confidential-screening-1` |
| Normalisation id | `cscr-norm-2` |
| Parameter class | `n4096-t20-d9` |
| Code read | `origin/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 read | `GET 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 vectors | `screening-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

| Route | Method | Request | Response | Limits on the way **[code] / [repo]** |
|---|---|---|---|---|
| `/v1/screen/params` | GET | none | JSON (section 9 lists the fields) | traefik: 120/min, burst 60, per address |
| `/v1/screen/quota` | GET | optional `X-Taia-Access-Key` | `{plan, limit, remaining, resets_at}`; 401 `unknown_key` | same |
| `/v1/screen/query` | POST | `application/octet-stream`, ≤ 1,048,576 bytes, optional `X-Taia-Access-Key` | `application/octet-stream`; headers `x-he-server-ms`, `x-screen-plan`, `x-screen-limit`, `x-screen-remaining`, `x-screen-resets-at` | see 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.

| | parts | wire size per part **[measured]** | declared inflated size | content |
|---|---|---|---|---|
| Query | 9 | ~46.4 KB | 65,714 bytes | seeded symmetric BFV ciphertext at the first data level (2 primes, 72 bits): one polynomial plus the seed of the second; size 2, not transparent |
| Answer | 7 | ~88.5 KB | 131,169 bytes | BFV ciphertext of size 2 at the same level |
| Whole query | | 417,961–418,272 bytes in the vectors | | |
| Whole answer | | 619,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]**

- **Lists and order:** `ofac_sdn` (OFAC SDN), `eu_fsf` (EU consolidated financial-sanctions list), `seco` (SECO, Switzerland). Each list's primary names and aliases are normalised and stored in `data/screening-list/names.tsv` with the codes of their lists (`O`, `E`, `S`). he-svc checks the file's sha256 and line count against `manifest.json` at start-up and stops on a difference. Snapshot at the live read: 74,757 normalised names, sha256 `1447ab07d53c32ab7918dafd3965beece3d4bf945f2fae6685ecbb61129d5779`; file dates OFAC 28.10.2026, EU 23.10.2026, SECO 29.10.2026.
- **Roots:** for each list, each name gives `(bin, x)`; equal `(bin, x)` pairs within one list are one root.
- **Rows:** a list's worst bin holds L roots; the list gets `rows = ceil(L / D)` polynomial rows, D = 9. Live: OFAC L = 24 → 3 rows; EU L = 18 → 2; SECO L = 13 → 2; 7 rows in all.
- **Polynomials:** in row `j` of bin `b`, the roots `j·D … j·D + D − 1` of that bin, padded to D with the root 0:
  `P_jb(X) = ∏ (X − root) mod t`, monic, degree 9.
  The padding root 0 does not create a match, because `x ≠ 0` for every query.

## 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 L | at most one in |
|---|---|---|
| OFAC SDN | 24 | 43,008 |
| EU consolidated list | 18 | 57,344 |
| SECO | 13 | 79,399 |
| Any list (`max_bin_load_any`, `false_match_one_in_any`) | 38 | 27,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

| | Value | Source |
|---|---|---|
| Scheme | BFV | [code] [live] |
| Library | Microsoft 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 N | 4096 | [code] [live] |
| Plain modulus t | 1,032,193 (20 bits; a batching prime, t ≡ 1 mod 2N) | [code] [live] |
| Coefficient modulus | SEAL `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 level | **109 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 check | `SEALContext(params, true, SecLevelType.tc128)`: SEAL refuses a total above its 128-bit table | [code] |
| Secret key distribution | ternary (SEAL default) | [estimate: SEAL default, not inspected in this build] |
| Error distribution | SEAL default, standard deviation ≈ 3.2 | [estimate: SEAL default, not inspected in this build] |
| Bins B / query degree D | 4096 / 9 | [code] [live] |
| Modulus switches | 0 | [code] |
| Key lifetime | one 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 holds | Detail |
|---|---|
| 9 query ciphertexts per check | under 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 size | nearly constant (section 4.2) |
| Time of each check and its own processing time | `x-he-server-ms` is sent back to the browser |
| The caller's network address | read 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 address | HMAC-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 sent | compared 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 request | `route`, `method`, `status`, `error`, `plan`, `key_id` (keyed checks), `ms`; no body, no address, no headers, no exception text **[code]** |
| The result | not sent back by the page **[code]** |

Limits of these statements:
- **The HMAC tag is a pseudonym, not anonymisation.** The IPv4 space is small. Anyone holding the day's secret (the running process, a memory dump of it, or modified code) can recover the address from a tag by enumeration. The tag keeps addresses out of the service's log and metrics; it does not protect them from TAIA's own running code.
- **Number of checks** per address tag or per key id, per day, is known to the service.
- **One check can carry many names.** A modified client can place a different name in each of the 4,096 slots and get up to one name per bin answered in one check. The lists are public, so this reveals nothing unpublished, but it counts as one check against the quota (see 11.6, legal).

### 11.2 A dishonest or compromised service: result integrity

- **The page has no way to detect a wrong answer.** It checks only the answer count and that each answer still has noise budget. A service that returns `Enc(0)` in every slot makes every check show "possible match"; one that returns non-zero values makes every check show "no match", including for a listed name. Anyone who can modify the answer in transit after TLS ends (section 11.4) can do the same.
- For a sanctions check, a false "no match" is the material risk. The protocol gives confidentiality of the name, not integrity of the result. This is the same position Microsoft states for APSI ("a confidentiality protocol, not an integrity protocol").
- **Chosen answers as a probe.** A service can return answers that are zero in a chosen subset of bins and watch for any reaction it can observe. The page sends nothing that depends on the result, and the key is used once, which limits key-recovery-by-reaction attacks to one observation per key. What remains is the person's behaviour after a shown result (checking again with variations, asking for support, buying a plan), which can leak about one bit about the name's bin per check **[estimate]**.
- **Decryption failure** (a crafted answer with no noise budget left) shows an error to the visitor; the page sends nothing about it.
- **Idea for the outside review, not implemented:** the client could fill the other 4,095 slots with names whose list status it knows from the public lists (some listed, some not) and check those slots too. A service that does not know which slot is the real one would then be detected when it alters answers in any slot other than the real one. This would turn the "many names per check" property into an integrity check at no extra bandwidth **[estimate]**.

### 11.3 The page code: delivery by TAIA

- The JavaScript and WebAssembly that do the encryption are served by TAIA (taia.ch, a Google Cloud Storage backend behind Google's load balancer). **A modified page could read the name before encryption and send it anywhere**, including to TAIA. Homomorphic encryption gives no protection against this.
- At v0.1 there is no Subresource Integrity, no published hash manifest of the page bundle and no reproducible-build record **[code: none found in the site's build configuration]**. A visitor who wants to check today can read the unbundled source in the repository (once published) and watch the network tab: the only requests are the two GETs and the POST of section 3, and the POST body is binary SEAL data.
- Mitigations that R4 lists for Tier 1 (bundle hash manifest outside TAIA's server, readable code, a "what leaves your browser" panel) are not in place at v0.1 **[open]**.

### 11.4 The network and the edge

- **Between browser and traefik:** TLS. An on-path observer without the TLS keys sees the destination host, the time, and the encrypted sizes (~418 KB up, ~620 KB down, which identifies a screening check by its shape).
- **traefik** terminates TLS for he.taia.ch on normal (non-SEV) nodes and forwards plain HTTP inside the cluster to he-svc. It therefore sees what he-svc sees: ciphertexts, sizes, times, the client's address, the route used (free or keyed) and the request headers. **[repo, `k8s/traefik/helm-values.yaml`]** traefik's access log is on (JSON, default fields kept: client address, path, router, sizes, status, duration, start time); request headers are not kept by traefik's default, and `Authorization` is set to redact. Read live on 01.11.2026 21:42Z: traefik runs with `--accesslog=true --accesslog.format=json --accesslog.fields.defaultmode=keep --accesslog.fields.headers.defaultmode=drop --accesslog.fields.headers.names.Authorization=redact` **[live]**.
- **Linkability:** the traefik log (address, time, router) and he-svc's log (time, key id) can be joined by time. For keyed checks, TAIA can therefore link a key id to an address for as long as both logs are kept.
- **Retention:** container logs go to Google Cloud Logging; the `_Default` sink writes to the bucket `zh-default` in europe-west6, which keeps them 30 days **[live, 01.11.2026]**. A second bucket, `he-key-usage` (europe-west6), keeps he-svc's keyed-check lines (time, key id, plan; no address) for 396 days for plan accounting **[live, 01.11.2026]**.
- **Google Cloud,** as the platform operator, can in principle see what traefik and he-svc see. Google's certifications cover Google's infrastructure, not TAIA's code.

### 11.5 AMD SEV on he-svc's node

- **[repo]** `k8s/he-svc` places he-svc on AMD SEV Confidential VM nodes (ComputeClass `he-confidential-sev`, europe-west6). Plain SEV encrypts the VM's memory against the host (Google's hypervisor and host operators). It does **not** protect against TAIA's own code in the pod, a new image TAIA deploys, anyone with cluster administration rights (`kubectl exec`, a debug container), or traefik, which runs on normal nodes.
- The badge on the page says "per GKE's node label, checked HH:MM UTC": it relies on the node label, not on an attestation the browser checks. The pod has no attestation token and no egress.
- For this protocol, SEV adds protection for the transient address, the HMAC secret, the masks and the index in memory against the host. It does not change any statement in 11.1–11.3.
- The SEV node class is applied: he-svc runs on a node labelled `cloud.google.com/gke-confidential-nodes-instance-type=SEV` (ComputeClass `he-confidential-sev`), image develop 6c39683d4 @ sha256:ad33e3fd… on node …bdcm **[live, 02.11.2026 06:40Z]**. The label is GKE's statement, not a hardware attestation.

### 11.6 Summary table

| Actor | Name in clear | Result | Address | Time / count / size | Can alter the result |
|---|---|---|---|---|---|
| he-svc as served | no (ciphertext only) | no | in memory per request; HMAC tag until 00:00 UTC | yes | yes, undetected |
| traefik / TAIA operator with log access | no | no | yes, in access log | yes | yes (inside the cluster, after TLS) |
| On-path network observer, no TLS keys | no | no | yes | yes (shape identifies a check) | no, while TLS holds |
| Google Cloud as host | no | no | yes (platform level) | yes | as platform operator, by tampering **[estimate]** |
| TAIA serving modified page code | **yes** | **yes** | yes | yes | yes |
| Modified client | its own name | its own | n/a | n/a | n/a; can ask up to 4,096 names per check |

## 12. Test vectors

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

| File | Content |
|---|---|
| `make-vectors.mjs` | the script: `generate` writes the vectors; `verify` re-checks them |
| `vectors.json` | parameters (incl. the three primes), environment, normalisation examples, three cases with name, normalised form, bin, x, result, sizes, noise budget |
| `case-N-*.secret-key.bin` | the test secret key (SEAL `SecretKey`, zstd); a key made only for the vector |
| `case-N-*.query.bin` | the exact POST body |
| `case-N-*.answer.bin` | the exact response body |
| `SHA256SUMS` | sha256 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]**

| Case | Input | Normalised | bin | x | OFAC | EU | SECO | noise left |
|---|---|---|---|---|---|---|---|---|
| 1 | `SAHA AIRLINES` (a listed airline entity) | `airlines saha` | 1114 | 770756 | match | match | match | 21 bits |
| 2 | `Bank Melli` (a listed bank entity) | `bank melli` | 1449 | 82390 | match | match | no | 21 bits |
| 3 | `Testname Nomatch Example` (invented) | `example nomatch testname` | 3446 | 753275 | no | no | no | 21 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).**
- `protocol` id changes (`…-2`) when the item derivation, the framing, the evaluation or the answer layout rule changes.
- `normalisation` id changes (`cscr-norm-3`) when any step in section 5 changes; the list is rebuilt.
- `param_class` changes when N, t, the coefficient modulus or D changes.
- A list refresh changes only `snapshot_sha256` and the per-list fields; it is not a protocol version.
- This document's version follows `MAJOR.MINOR`; each published version names the code commit and the live-read time it describes.

**Changelog.**

| Version | Date | Change |
|---|---|---|
| 0.1 (draft) | 01.11.2026 | First draft: spec, parameters, threat model, test vectors. Not reviewed by legal or by an outside cryptographer. |

**Contact.**
- Security reports: `[security contact address: to be set; security.txt not yet published]`
- Questions about this document: `[contact: to be set]`
- Outside review: `[reviewer, date and report link: none yet]`

## 14. Points for the legal seat

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.
