Why 3-of-3 instead of 2-of-3 or HSM-only
Shamir's Secret Sharing (SSS) — introduced by Adi Shamir in "How to Share a Secret" (Communications of the ACM, 1979) — splits a secret into n shares such that any k shares suffice to reconstruct it, and any k-1 shares reveal nothing about the secret. The pair (k, n) is the threshold scheme.
The desktop app defaults to (3, 3): threshold 3, total shares 3 — all three shares are required to reconstruct the key. The three shares live in three different places: s1 in your operating-system keychain, s2 in an encrypted recovery vault, and s3 behind a passphrase you set. The alternatives, and where they land:
- (2, 3): Any two of the three shares can recover the key. More forgiving — if you lose one location you can still recover — but weaker, because compromising any two locations is enough. The app exposes this as a toggle with an explicit warning; 3-of-3 is the default because it is the strongest privacy posture.
- HSM-only without SSS: An HSM prevents software extraction of key material, but it is a single point of failure and it is not something a user has on their own device. SSS instead distributes the risk across factors the user already controls — a device keychain, a vault, and a passphrase — with no special hardware.
- (2, 5) or larger schemes: Larger schemes add operational complexity without a clear gain for this threat model. Three factors map cleanly onto the three places a desktop client can hold key material.
The honest limitation of (3, 3): if all three share locations are compromised simultaneously, the key is exposed — and, conversely, if you lose both your device and your vault, 3-of-3 is unrecoverable (which is exactly why the 2-of-3 toggle exists). The point of the split is that no single place — and specifically not CloakAPI, which holds none of the shares — can reconstruct your key alone.
The library landscape and where it falls short
Before writing anything, we evaluated the ecosystem:
- PHP (tightenco/shamir, dsprenkels/tss): PHP Shamir implementations typically use GF(256) but differ in polynomial representation, byte ordering, and how they handle multi-byte secrets (whether they process byte-by-byte or word-by-word). tightenco/shamir has not been updated since 2019 and uses a non-standard prime field implementation incompatible with the standard GF(2^8) with the AES polynomial.
- Rust (sharks, secret-sharing): sharks uses GF(2^8) with the correct AES-256 irreducible polynomial (0x11b), produces correct output, and has a published test vector suite. Our desktop tokeniser core is Rust, so this was the natural starting point — but the shares it produces must be re-assembled byte-for-byte by the recovery layer (a separate code path, and potentially a re-imported vault share), which requires the encoding to be identical everywhere. No off-the-shelf pairing gave us that guarantee across both sides.
- JavaScript (@privy-io/shamir-secret-sharing, secrets.js): secrets.js uses a configurable field size and does not default to GF(2^8). @privy-io's implementation targets GF(2^8) but encodes shares as hex strings with a custom envelope; incompatible with raw byte-level reconstruction in PHP.
The problem is not that these libraries are wrong — most are correct implementations of SSS. The problem is that "Shamir's Secret Sharing" underspecifies the encoding. Two correct implementations can produce shares that are incompatible with each other's reconstruction if they differ in: the choice of irreducible polynomial, byte ordering, how multi-byte secrets are chunked, how the x-coordinate of each share is encoded, and whether share indices start at 0 or 1.
We needed the share-splitting path and the share-reassembly path to produce byte-for-byte compatible output — every time, on every platform, entirely on the user's device. The safest path was to write both to the same explicit specification.
GF(2^8) arithmetic: a walkthrough
Shamir's Secret Sharing requires arithmetic in a finite field. The standard choice for byte-level SSS is GF(2^8) — the field of 256 elements, where each element is one byte.
In GF(2^8), addition is XOR (carry-free addition modulo 2, applied bitwise). Multiplication is polynomial multiplication modulo an irreducible polynomial of degree 8 over GF(2). We use the same polynomial as AES:
p(x) = x^8 + x^4 + x^3 + x + 1 — binary: 0x11b (decimal: 283)
Why this polynomial? It is well-studied, widely implemented, and choosing it means our test vectors can be cross-checked against AES GF(2^8) implementations.
Multiplication in GF(2^8)
Multiplying two elements a and b in GF(2^8) using the "Russian peasant" algorithm:
def gf_mul(a: int, b: int) -> int:
"""Multiply a and b in GF(2^8) with polynomial 0x11b."""
result = 0
while b:
if b & 1:
result ^= a # add current a to result (XOR = addition in GF(2))
a <<= 1 # multiply a by x
if a & 0x100:
a ^= 0x11b # reduce modulo p(x)
b >>= 1
return result & 0xff
Division is multiplication by the multiplicative inverse. We precompute a 256-element log/antilog table using a generator element (0x03 is a generator of the multiplicative group of GF(2^8) under the AES polynomial) so that division becomes two table lookups and a subtraction modulo 255.
Secret splitting
To split a single-byte secret s into 3 shares with threshold 3:
- Choose two random coefficients
a1, a2from GF(2^8) uniformly at random (using a CSPRNG). - Define the polynomial
f(x) = s + a1*x + a2*x^2over GF(2^8). - Evaluate at x = 1, 2, 3: the shares are (1, f(1)), (2, f(2)), (3, f(3)).
For a multi-byte secret (like a 32-byte AES-256 key), repeat independently for each byte. The x-coordinates are the same for all bytes; each byte gets its own independent polynomial. The result is three shares, each 32 bytes, plus their x-coordinates.
Polynomial degree 2 means exactly 3 coefficients (degree-0 = secret, degree-1 = a1, degree-2 = a2). A polynomial of degree k-1 over GF(2^8) requires k evaluation points to reconstruct — consistent with a threshold of k=3.
Reconstruction via Lagrange interpolation
Given any 3 shares (x1,y1), (x2,y2), (x3,y3), the secret f(0) is recovered by Lagrange interpolation:
f(0) = Σ yi * Π (0 - xj)/(xi - xj) for j ≠ i
All arithmetic is in GF(2^8). Subtraction equals addition equals XOR. Division is via the precomputed inverse table. The interpolation is O(k^2) per byte, which is negligible for any practical secret size.
Test vectors and cross-validation
We validated our implementation against:
- The test vectors published with the dsprenkels/sss C library, which specifies GF(2^8) with the same polynomial.
- An independent Python reference implementation, compared byte-for-byte against the production code on 10,000 randomly sampled secrets.
- Round-trip testing: split many thousands of random secrets across every permutation of the share subsets, and verify all reconstruct to the original.
The share-splitting and reassembly paths produce identical byte sequences on all of these test vectors. Byte-for-byte compatibility was the primary design constraint, and it is checked in our test suite on every change.
How recovery actually works
Recovery is something you do, on your own device — there is no ceremony to schedule and no third party to call:
- Collect the shares. On a working device, share s1 is read from your OS keychain automatically. If you are moving to a new device, you restore s2 from your encrypted recovery vault.
- Unlock the passphrase share. You enter the passphrase you set during onboarding, which unlocks share s3.
- Reconstruct locally. Once the threshold number of shares is present (3 in the default scheme, 2 if you chose 2-of-3), the app reconstructs the key in memory via the Lagrange interpolation described above. The reconstruction happens entirely on-device.
- Nothing leaves the machine. No share and no reconstructed key is ever transmitted to CloakAPI — the gateway is a pure relay and holds none of your key material. There is nothing on our side to subpoena, breach, or lose.
Honest limitations
Shamir's Secret Sharing is information-theoretically secure — knowing k-1 shares reveals exactly zero bits about the secret. But the threat model has practical constraints our scheme does not fully address:
- Simultaneous compromise: If all the share locations required by your threshold are compromised at once by the same attacker, the key is exposed. Keeping the vault and passphrase separate from the device reduces but does not eliminate this risk.
- Passphrase loss: The passphrase-locked share cannot be recovered if you forget the passphrase — we do not hold it and cannot reset it. In 3-of-3 that means the passphrase is load-bearing; in 2-of-3 the device keychain plus the vault can still recover.
- Lost factors in 3-of-3: If you lose both your device and your recovery vault under 3-of-3, the key is unrecoverable by design. The 2-of-3 toggle trades a little of that strictness for resilience — choose deliberately.
- Your storage practices: A vault backup or passphrase stored carelessly weakens the whole scheme. The app guides you, but the security of the factors you hold is ultimately yours — which is the price of us never holding them.