One thread is noise.
Pallium weaves a prompt into two sets of threads before it leaves your machine: the warp and the weft. Each set goes to a different stranger. Each set on its own is uniformly random bytes, so neither stranger can read a single word you typed.
The rule that does it is one line of arithmetic: pick fresh random bytes a, set b = x − a (mod 256). Add the two back together and you get the prompt. Keep them apart and you get static. The whole rulebook ships as one small module, pallium.js, that you import straight from this site.
Quick start
Import the module from this site. No package manager, no key, no account.
import { split, join } from "SITE/lib/pallium.js";
const { A, B } = split("Sell everything before the dev wakes up.");
// A is the warp → node 2, B is the weft → node 3. Each one alone is noise.
console.log(join(A, B));
// Sell everything before the dev wakes up.# Node 19+ has WebCrypto built in. Save the module as .mjs and import it.
curl -s SITE/lib/pallium.js -o pallium.mjs
node --input-type=module -e "import { split, join } from './pallium.mjs'; const s = split('hello'); console.log(s.A, join(s.A, s.B))"<script type="module">
import { split } from "SITE/lib/pallium.js";
document.body.textContent = split("my secret").A;
</script>pallium.js makes a network request.Try it
This box imports the real module and prints a privacy receipt for whatever you type.
// press the button
Why prompts leak
Splitting a model across machines fixes size. It does nothing for secrecy.
A big model gets cut by layers: one machine runs layers 0–15, the next runs 16–31, and so on. Every machine has to see the activations coming out of the previous one, because that is the input to its own math. Those activations are not encrypted while it computes on them, and published inversion attacks rebuild a large share of the original tokens from them.
Sealing the wire is not enough
TLS between nodes stops eavesdroppers on the cable. It does not stop the node itself, which decrypts to compute. The leak is at the endpoint, and the endpoint is a stranger.
Shuffling is not enough either
Rotating activations with a secret matrix looks clever and is undone in one line when the weights are public: the node holds W and the rotated W·R, so R = W⁻¹·(W·R). With open models, a secret basis is not a secret for long.
Warp and weft
Additive secret sharing over bytes, named after a loom. Two holders, neither one learns anything.
For every byte x of the prompt, Pallium draws a uniformly random byte a and computes b = (x − a) mod 256. The warp is all the a bytes, the weft is all the b bytes. Like a real cloth, neither set of threads shows the pattern until they are woven together.
- The warp alone is fresh randomness. It does not depend on the prompt at all.
- The weft alone is the prompt shifted by a random amount at every position, which is also uniformly random.
- Both together give the prompt back:
x = (a + b) mod 256.
This is a property of the arithmetic, not of anyone's good behavior. A node holding one thread set has the same information as a node holding nothing, plus the length.
Linear layers are free
Matrix multiplications distribute over addition, so each holder can run a public linear layer on its own threads and the results still add up. Non-linear steps need the two holders to talk, which costs bandwidth and round trips. That is the honest price of the scheme.
The adversary
Privacy without an attacker is a slogan. Ours is written down and measured.
The single-set adversary gets exactly what one node gets: one thread set. Its best move is to read that set as if it were the prompt. We measure the fraction of bytes it gets right. Guessing blind gets one in 256, which is 0.39%. Pallium's measured score sits at chance because there is nothing better to do.
| Condition | What the node sees | Recovered |
|---|---|---|
| Raw hop | Real activations | ~100% |
| Shuffled | Rotated activations, open weights | ~100% after basis recovery |
| One thread set | Uniform bytes | 0.39% (chance) |
| Warp and weft | Two colluding nodes | 100% |
Paper: the claim
One page, no promises we cannot measure.
Claim. A node that serves one thread set of a Pallium request cannot recover the prompt better than chance, and every run can prove it with a receipt a skeptic can re-check.
Pillar 1, a concrete adversary. The single-set readback above. Its score is the number we publish.
Pillar 2, leakage reduced by construction. Edges stay home: tokenizing and detokenizing happen in your tab, so raw token ids never cross the wire. The middle runs on two non-colluding holders with one thread set each.
Pillar 3, the receipt. Each run records the scheme, the hash of each thread set, and the measured score of the attack. Anyone with the thread sets can recompute the hashes and the score.
split()
split(input) → { a, b, warp, weft, A, B, length }
Takes a string (encoded as UTF-8) or a Uint8Array. Returns the two thread sets as bytes (a or warp, b or weft) and as share strings (A, B). Randomness comes from crypto.getRandomValues, fresh on every call.
import { split } from "SITE/lib/pallium.js";
const s = split("meet me at the usual place");
console.log(s.length); // 26
console.log(s.A); // pl1:warp:3f9c… (different every time)join()
join(a, b, { bytes }) → string | Uint8Array
Accepts thread sets as bytes, hex or pl1: strings, in any mix. Returns the prompt as text, or as bytes with { bytes: true }. Throws E_LENGTH if the thread sets don't match.
import { split, join } from "SITE/lib/pallium.js";
const { A, B } = split("gm");
join(A, B); // "gm"
join(A, B, { bytes: true }); // Uint8Array [103, 109]attack()
attack(threads, original?) → { guess, recovered, chance }
Runs the single-set adversary. guess is the thread set read as printable text. If you pass the original, recovered is the fraction of bytes the attack got right. Only the party that already holds the prompt can measure this, which is why it lives on the trusted side.
import { split, attack } from "SITE/lib/pallium.js";
const p = "my password is hunter2";
const r = attack(split(p).a, p);
console.log(r.guess); // ·k·$··Q··w··a·····9·
console.log(r.recovered); // 0 or close to 0.0039receipt() and verify()
await receipt(input, opts) → { receipt, a, b, A, B } · await verify(receipt, a, b, original?) → { ok, pass, reasons }
receipt() splits, attacks both thread sets, hashes them with SHA-256 and writes it all down. verify() recomputes the hashes and, if you hand it the original, re-runs the attack and checks the join.
{
"run_id": "9c1e0f4a7b22d310",
"utc": "2026-10-29T12:00:00.000Z",
"scheme": "additive-2pc-mod256",
"bytes": 40,
"halves": [
{ "half": "warp", "holder": "volunteer", "sha256": "…" },
{ "half": "weft", "holder": "volunteer", "sha256": "…" }
],
"privacy_eval": {
"adversary": "single-set readback",
"warp_recovery": 0,
"weft_recovery": 0.025,
"collusion_recovery": 1,
"chance": 0.0039,
"threshold": 0.1,
"pass": true
}
}Options: threshold (default 0.10), holderA, holderB (labels written into the receipt).
Share format
One line per thread set. Easy to log, easy to diff, useless to read.
| Part | Value | Meaning |
|---|---|---|
| prefix | pl1 | Format version 1 |
| half | warp or weft | Which set of threads |
| body | lowercase hex | Two characters per byte, same length as the prompt in UTF-8 |
pl1:warp:da42777a4699d18ec971e5a4
pl1:weft:792df5e72ed49ce5ab03fbc9Constants
| Name | Value | Notes |
|---|---|---|
| VERSION | 1.0.0 | Module version |
| SCHEME | additive-2pc-mod256 | Written into every receipt |
| PREFIX | pl1 | Share string prefix |
| MAX_BYTES | 65536 | Largest prompt per split |
| CHANCE | 1/256 ≈ 0.0039 | Blind guess per byte |
| THRESHOLD | 0.10 | Default pass line for a receipt |
Errors
Every throw is a PalliumError with a code.
| Code | When |
|---|---|
| E_INPUT | Input is not a string, bytes or share |
| E_EMPTY | Nothing to split |
| E_SIZE | Prompt is over MAX_BYTES |
| E_HEX | Share body is not valid hex |
| E_FORMAT | Share string is not pl1:<warp|weft>:<hex> |
| E_HALF | Half label is not warp or weft |
| E_LENGTH | Warp and weft have different lengths |
verify() does not throw on a bad receipt. It returns reasons: E_SCHEME, E_LENGTH, E_HASH_WARP, E_HASH_WEFT, E_SCORE, E_JOIN.
Threat model
What Pallium stops, and what it doesn't.
Stops
- A curious node logging everything it legitimately sees.
- A malicious node running the best readback it has on its threads.
- Anyone reading the wire between holders.
Does not stop
- Collusion. If both holders share their thread sets, the prompt is whole again. Pallium assumes two strangers who never compare notes.
- Your own device. If your tab is compromised, the prompt was never private to begin with.
- Length. Each half has the same length as the prompt. Pad your prompt if length matters.
$PALLIUM
The coin of the network. Contract and links are pulled from the home page.
| Ticker | $PALLIUM |
| Chain | Solana |
| Contract | soon |
FAQ
Is this real cryptography?
The splitting is. Additive secret sharing is decades old and the module is short enough to read in one sitting. Open lib/pallium.js and check.
Why not split into ten thread sets?
Against a single node, two thread sets already hide everything. More holders cost more bandwidth and more lag, and only help if you fear several holders teaming up.
Does the demo send my prompt anywhere?
No. The splitter and the Try it box both run in your browser. There is no backend to send it to.
Where do I report a bug?
On X. Bring a receipt that fails verify() and we will buy you a coffee in $PALLIUM.