Docs
Open the splitter X Buy $PALLIUM
Docs · v1.0

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>
NOTEThe split happens wherever the module runs. In the browser that is your tab. Nothing in 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.

ConditionWhat the node seesRecovered
Raw hopReal activations~100%
ShuffledRotated activations, open weights~100% after basis recovery
One thread setUniform bytes0.39% (chance)
Warp and weftTwo colluding nodes100%

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.

WARNINGThese are properties of the splitting step. They do not cover what you do with the model's answer, your own device, or two holders who decide to compare notes.

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.

split.js
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.

join.js
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.

attack.js
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.0039

receipt() 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.

receipt.json
{
  "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.

PartValueMeaning
prefixpl1Format version 1
halfwarp or weftWhich set of threads
bodylowercase hexTwo characters per byte, same length as the prompt in UTF-8
example
pl1:warp:da42777a4699d18ec971e5a4
pl1:weft:792df5e72ed49ce5ab03fbc9

Constants

NameValueNotes
VERSION1.0.0Module version
SCHEMEadditive-2pc-mod256Written into every receipt
PREFIXpl1Share string prefix
MAX_BYTES65536Largest prompt per split
CHANCE1/256 ≈ 0.0039Blind guess per byte
THRESHOLD0.10Default pass line for a receipt

Errors

Every throw is a PalliumError with a code.

CodeWhen
E_INPUTInput is not a string, bytes or share
E_EMPTYNothing to split
E_SIZEPrompt is over MAX_BYTES
E_HEXShare body is not valid hex
E_FORMATShare string is not pl1:<warp|weft>:<hex>
E_HALFHalf label is not warp or weft
E_LENGTHWarp 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.
WARNINGVery short prompts can fail the 10% receipt threshold by plain luck: on a 9-byte prompt, a single lucky byte is 11%. The score is still chance, the threshold just has fewer bytes to average over.

$PALLIUM

The coin of the network. Contract and links are pulled from the home page.

Ticker$PALLIUM
ChainSolana
Contractsoon

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.

pallium.js v1.0 · runs entirely on your machineHome · Splitter · X