Shamir Secret Sharing in Go: Eliminating Single Points of Failure

Shamir Secret Sharing in Go: Eliminating Single Points of Failure

GF(2^8) Shamir implementation from AxisCorePay in production Go: MPC-TSS vs Shamir 2-of-2, three pitfalls, and crypto settlement architecture.

When we were designing AxisCorePay — a custodial crypto payment platform handling Ethereum ERC-20 and Tron TRC-20 transactions — the hardest question wasn’t about blockchain integration or REST API design. It was this:

How do you authorize a settlement when you can’t fully trust any single actor in your own system, including your own infrastructure?

We weren’t being paranoid. We had read the post-mortems. Every major custodial wallet breach follows the same script: attacker reaches a private key stored in one place. The playbook of “encrypt it and put it in Vault” doesn’t solve the root problem — the key is assembled somewhere. At some point, one process holds it in memory, and that process can be compromised.

This article covers the specific solution we shipped: Shamir’s Secret Sharing (SSS) implemented in Go over GF(28)\text{GF}(2^8), integrated into a bilateral settlement authorization flow. We’ll cover the math, the production code, and the three implementation mistakes that don’t show up in tutorials but will break your security guarantee in the field.


Why standard key management isn’t enough for settlements

We use MPC-TSS (Multi-Party Computation — Threshold Signature Scheme) for the daily transaction signing path. With MPC-TSS, the private key is mathematically never assembled: signing happens across distributed nodes through a multi-round protocol, and no single node ever holds enough information to reconstruct the key. This is the right answer for high-frequency transaction signing.

But for merchant settlements — periodic payouts where both the platform and the merchant must explicitly authorize a transfer — MPC-TSS felt like the wrong tool. Settlements are infrequent, involve human confirmation from both sides, and need to be auditable as a two-party agreement. The complexity of a multi-round cryptographic protocol adds latency and opacity where you actually want simplicity and transparency.

We prototyped a simpler approach first: store the settlement payload in Vault, require two separate service calls to release it. Our security review found the obvious gap: “What if the Vault token is compromised? What if someone with admin access approves both sides?” We didn’t have a mathematical answer — just process controls. Process controls fail.

Shamir’s Secret Sharing gave us a mathematical answer: split the settlement payload such that neither side alone can reconstruct it, with a guarantee rooted in information theory, not operational discipline.


Choosing the right scheme: the comparison we actually ran

Before writing a line of code, we evaluated every viable option:

ApproachKey propertyWhy it didn’t fit our settlement case
Multisig (blockchain-level)Native chain enforcementChain-specific — we support ETH and Tron, no single solution
MPC-TSSKey never assemblesComplex multi-round protocol; adds latency where human confirmation is the bottleneck anyway
Shamir SSSInformation-theoretic security, simpleSecret assembles briefly in memory — acceptable for payload verification
XOR split (2-party)Trivial to implementNo threshold: lose one share, secret is gone permanently
Dual-key AES wrappingFamiliar patternHalving an AES key search space is not information-theoretic security

Shamir was the right fit for settlements specifically because:


The math: Shamir SSS over GF(28)\text{GF}(2^8)

Shamir’s algorithm in plain language

Shamir’s Secret Sharing is a way to “split” a secret — a password, a key, or a safe code — into several pieces and give those pieces to different people. The secret can only be recovered when at least a certain number of pieces are combined. If an attacker has fewer pieces than that threshold, they learn nothing about the secret.

The simple idea. Think of the secret as a point on the coordinate plane. To define a line you need two points; with only one point, infinitely many lines pass through it. Here, the secret is hidden in the coefficients of a polynomial, and the pieces (shares) are points on that polynomial’s curve. The interactive chart and diagram below bring this idea to life.

The math without the jargon.

  1. Choose the threshold — how many people must come together to recover the secret. Denote that number by tt. For example, t=3t = 3 (any three).

  2. Build a polynomial of degree t1t-1. If t=3t = 3, the polynomial has degree 2: y=a0+a1x+a2x2y = a_0 + a_1 x + a_2 x^2 The constant term a0a_0 is the secret. The other coefficients a1,a2a_1, a_2 are chosen at random (from a cryptographically secure source).

  3. Hand out the shares. For each participant pick a distinct xx (e.g. 1, 2, 3, …) and compute the corresponding yy from the polynomial. The pair (x,y)(x, y) is one share. You can create as many shares as you want, even more than tt.

  4. Reconstructing the secret. With any tt shares — i.e. tt points (x,y)(x, y) — there is exactly one polynomial of degree t1t-1 passing through them. Its constant term a0a_0 is the secret. This is done with Lagrange interpolation: a formula that recovers the polynomial from the points. It works because a polynomial of degree mm is uniquely determined by m+1m+1 points. With fewer points, there are infinitely many such polynomials, and every possible secret is equally likely.

Concrete example. Suppose the secret is S=1234S = 1234 and we want any three of five people to recover it.

If participants 2, 3 and 5 get together, they have (2,1942)(2,1942), (3,2578)(3,2578), (5,4414)(5,4414). Lagrange interpolation recovers the polynomial and thus the constant term 12341234. If an attacker only has two points — e.g. (2,1942)(2,1942) and (5,4414)(5,4414) — there are infinitely many degree-2 polynomials through those two points, each with a different constant term. The real secret 12341234 is one of them, but guessing it is impossible; all possibilities are equally likely. That’s the information-theoretic guarantee.

Bytes in the machine: text and images are just numbers. A computer doesn’t “see” letters or pixels directly. To it, any data — a word, an image, a key — is just a sequence of numbers (bytes). Each character has a numeric code (e.g. 0–255 in ASCII); each pixel is stored as numbers. So applying Shamir to “characters” or to a signing payload is the same idea: treat the secret as a list of bytes and run the scheme once per byte.

Example: the secret is the word “CAT”. In memory that’s three bytes: C = 67, A = 65, T = 84. We want any 3 people to recover it (t=3t = 3), so for each byte we build a separate degree-2 polynomial.

Now compute shares for participants x=1,2,3,4,5x = 1, 2, 3, 4, 5. Participant #1 (x=1x=1) gets: for the first byte y1=67+131+2712=107y_1 = 67 + 13\cdot 1 + 27\cdot 1^2 = 107, for the second byte some y2y_2, for the third some y3y_3. So their share is the triple (107,y2,y3)(107, y_2, y_3) — one value per byte. Participant #2 gets the three values at x=2x=2, and so on.

When any three participants combine, they take their three shares and, for each byte position, reconstruct the unique degree-2 polynomial through three points and read off the constant term. That gives 67, 65, 84 — the original bytes — i.e. the word “CAT”. In AxisCorePay we do exactly this for a multi-byte signing payload: one polynomial per byte, same threshold tt, and each share is a byte array of the same length as the secret.

What about the “modulo” trick? For the scheme to work cleanly in code, we do arithmetic not in the integers but in a finite field — either modulo a large prime (in classic descriptions) or, as we do in AxisCorePay, in GF(28)\text{GF}(2^8): 256 elements, one per byte. That keeps every value in a fixed range, avoids rounding and overflow, and keeps shares as compact, random-looking bytes. The intuition above is unchanged.

Bottom line. Shamir’s scheme lets you choose the threshold flexibly: e.g. 5-out-of-10, 2-of-3, or 10-of-10. The polynomial structure guarantees that with fewer than tt shares you get zero information about the secret — which is why it’s used in key backup, crypto custody, and access control.


Formal notation (our implementation)

In our implementation the secret and coefficients live in GF(28)\text{GF}(2^8). We write:

f(x)=s+a1x+a2x2++at1xt1f(x) = s + a_1 x + a_2 x^2 + \cdots + a_{t-1} x^{t-1}

  • ss — secret byte (the constant term of the polynomial)
  • a1,,at1a_1, \ldots, a_{t-1} — random coefficients in GF(28)\text{GF}(2^8), sampled from crypto/rand
  • tt — threshold: minimum shares required to reconstruct ss
  • xx — evaluation point: distinct non-zero values 1,2,,n1, 2, \ldots, n
What does GF stand for?

GF = Galois Field — a finite algebraic structure named after French mathematician Évariste Galois (1811–1832), who laid the foundations of group theory before dying in a duel at 20.

A Galois Field has a fixed number of elements and defines addition and multiplication such that every result stays within the field — no overflow, no fractions, no elements outside the set.

GF(2⁸) specifically has exactly 256 elements — one per byte value (0x00 to 0xFF). The two operations:

  • Addition = bitwise XOR (e.g., 0xCA ⊕ 0x55 = 0x9F — always one byte)
  • Multiplication = log/exp table lookup using the irreducible polynomial x8+x4+x3+x+1x^8 + x^4 + x^3 + x + 1

This is the same field AES uses internally. The tables are well-studied, constant-time, and need no big-integer arithmetic — just 256-entry byte arrays.

Arithmetic runs in GF(28)\text{GF}(2^8) — a finite field of 256 elements, where each element is exactly one byte. Addition is bitwise XOR. Multiplication uses precomputed log/exp tables derived from the irreducible polynomial x8+x4+x3+x+1x^8 + x^4 + x^3 + x + 1.

Processing is byte-by-byte: for a kk-byte payload, run the split independently for each byte using a freshly sampled polynomial. The result is nn shares each of length kk. Reconstruction applies Lagrange interpolation byte-by-byte.

Visualizing the polynomial

The interactive chart below shows the polynomial f(x)f(x) defined by your chosen threshold tt and secret. Shares are the colored dots at x=1,2,,nx = 1, 2, \ldots, n. The green diamond at x=0x = 0 is the secret.

Click “Show: t−1 shares → infinite secrets” to see the information-theoretic guarantee in action: with one share removed, multiple polynomials are consistent with the remaining data — and the value at x=0x = 0 is completely undetermined.

The Polynomial Behind the Threshold The secret is the polynomial's value at x = 0. Shares are evaluations at x = 1, 2, …, n. Any t shares reconstruct it; fewer than t reveal nothing.
Threshold 3
Total shares 5
Secret value 65

Why information-theoretic security is different

Most cryptographic systems provide computational security: breaking them is infeasible with current hardware, but not impossible in principle. Shamir SSS provides something categorically stronger.

t−1 = 2 shares known∞ polynomials are consistentx=1x=2?x=0x=1x=2t = 3 shares knownUnique polynomial → secret revealedx=1x=2x=3 ✓secret= 65 ✓x=0x=1x=2x=3vs
Left: with t−1 shares, any polynomial is consistent — the secret at x=0 could be any value. Right: add one more share, and exactly one polynomial fits — the secret is uniquely determined.

This property holds regardless of adversary compute. Even an attacker with infinite CPU, t−1 shares are consistent with every possible secret value — all with equal probability. There is literally nothing to compute.


Try it: interactive threshold demo

Before diving into the implementation, play with the visualizer. Enter a secret, set nn and tt, click shares to toggle them in and out of a recovery attempt:

🔒 Select shares to attempt recovery
Click shares to select/deselect them. Any 3 of 5 shares recover the secret. Fewer than 3 reveal zero bits — information-theoretic security.

Notice what happens when you select fewer than tt shares: you recover garbage, not a partial secret. That’s the core guarantee — information-theoretic security means a computationally unbounded adversary with t1t-1 shares learns nothing. This is categorically stronger than computational security (RSA, AES) where an adversary could break it given enough time.


Implementation

Step 1: GF(28)\text{GF}(2^8) arithmetic

// Addition and subtraction in GF(2^8) are identical: bitwise XOR.
func gf256Add(a, b byte) byte {
    return a ^ b
}

// Multiplication via precomputed log/exp tables.
// Tables derived from the irreducible polynomial x^8+x^4+x^3+x+1 (0x11B) —
// the same used by AES, which means these tables are well-studied.
func gf256Mul(a, b byte) byte {
    if a == 0 || b == 0 {
        return 0
    }
    logSum := int(logTable[a]) + int(logTable[b])
    if logSum >= 255 {
        logSum -= 255
    }
    return expTable[logSum]
}

func gf256Div(a, b byte) byte {
    if a == 0 {
        return 0
    }
    if b == 0 {
        panic("division by zero in GF(2^8)")
    }
    logDiff := int(logTable[a]) - int(logTable[b]) + 255
    if logDiff >= 255 {
        logDiff -= 255
    }
    return expTable[logDiff]
}

The 256-entry exp/log tables are a one-time startup cost. Every field operation becomes O(1) byte lookups — no big integers, no arbitrary-precision arithmetic, no math/big.

GF(2⁸) arithmetic — try it live

Move the sliders to see how every operation always stays within a single byte, and why regular integer addition breaks this property:

GF(2⁸) Arithmetic — Interactive Move the sliders. Every result stays within 0–255 — because the field is closed.
Byte A
0xCA 202
1100 1010
Byte B
0x55 85
0101 0101
GF addition (XOR)
✓ in GF(2⁸)
GF multiplication (log/exp table)
✓ in GF(2⁸)
+ Regular integer addition
GF(2⁸) uses the irreducible polynomial x⁸ + x⁴ + x³ + x + 1 (0x11B) — the same as AES. The log/exp tables derived from this polynomial guarantee multiplicative closure and every non-zero element has an exact inverse.

Step 2: Split

type Share struct {
    X    byte   // x-coordinate (1..n, never 0 — f(0) is the secret)
    Data []byte // one byte per secret byte
}

func Split(secret []byte, threshold, totalShares int) ([]Share, error) {
    if threshold < 2 || threshold > totalShares {
        return nil, fmt.Errorf("invalid threshold: t=%d n=%d", threshold, totalShares)
    }
    shares := make([]Share, totalShares)
    for i := range shares {
        shares[i] = Share{X: byte(i + 1), Data: make([]byte, len(secret))}
    }
    for byteIdx, secretByte := range secret {
        poly, err := newRandomPolynomial(secretByte, threshold)
        if err != nil {
            return nil, err
        }
        for shareIdx := range shares {
            shares[shareIdx].Data[byteIdx] = poly.evaluate(shares[shareIdx].X)
        }
    }
    return shares, nil
}
Pitfall #1 — Non-zero leading coefficient

The leading coefficient at1a_{t-1} must not be zero. If it is, the effective polynomial degree drops from t1t-1 to something lower, and the security threshold silently collapses — t2t-2 shares may become sufficient to reconstruct the secret. Always re-sample:

func newRandomPolynomial(secret byte, threshold int) (*polynomial, error) {
    p := &polynomial{coefficients: make([]byte, threshold)}
    p.coefficients[0] = secret

    randomBytes := make([]byte, threshold-1)
    if _, err := rand.Read(randomBytes); err != nil {
        return nil, err
    }
    // Re-sample until leading coefficient is non-zero.
    // Expected attempts: 256/255 ≈ 1.004 — virtually always one pass.
    for randomBytes[len(randomBytes)-1] == 0 {
        if _, err := rand.Read(randomBytes[len(randomBytes)-1:]); err != nil {
            return nil, err
        }
    }
    copy(p.coefficients[1:], randomBytes)
    return p, nil
}

Use crypto/rand. Not math/rand. The entire security model depends on the coefficients being cryptographically unpredictable.

Step 3: Combine (Lagrange interpolation at x=0x = 0)

s=i=0t1yijixjxixjs = \sum_{i=0}^{t-1} y_i \cdot \prod_{j \neq i} \frac{x_j}{x_i \oplus x_j}

  • yiy_i — the share value (polynomial evaluation at point xix_i)
  • xi,xjx_i, x_j — x-coordinates of the selected shares
  • All arithmetic in GF(28)\text{GF}(2^8): subtraction == XOR == addition
func Combine(shares []Share) ([]byte, error) {
    if len(shares) < 2 {
        return nil, fmt.Errorf("need at least 2 shares")
    }
    // Duplicate x-coordinates cause division by zero in Lagrange interpolation.
    seen := make(map[byte]bool)
    for _, s := range shares {
        if seen[s.X] {
            return nil, fmt.Errorf("duplicate share x-coordinate: %d", s.X)
        }
        seen[s.X] = true
    }
    result := make([]byte, len(shares[0].Data))
    for b := range result {
        result[b] = interpolateAtZero(shares, b)
    }
    return result, nil
}

func interpolateAtZero(shares []Share, byteIdx int) byte {
    result := byte(0)
    for i, si := range shares {
        basis := byte(1)
        for j, sj := range shares {
            if i == j {
                continue
            }
            // L_i(0) = ∏(j≠i) (0 - x_j) / (x_i - x_j)
            // In GF(2^8): subtraction == XOR
            num := sj.X
            den := gf256Add(si.X, sj.X)
            basis = gf256Mul(basis, gf256Div(num, den))
        }
        result = gf256Add(result, gf256Mul(si.Data[byteIdx], basis))
    }
    return result
}

Step 4: Settlement integration — 2-of-2 bilateral authorization

This is where the cryptographic primitive meets the product requirement. The full settlement flow:

Generatesigning payloadSplit 2-of-2Shamir SSSShare 1 → AdminAES-256-GCMShare 2 → MerchantAES-256-GCMCombineverify integrityconstant-time cmpBroadcastETH / TronBoth parties confirm independently — one share reveals 0 bits
2-of-2 settlement: neither platform nor merchant can unilaterally authorize a payout.
func (s *SettlementService) CreateSettlement(
    ctx context.Context,
    params CreateSettlementParams,
) (*Settlement, error) {
    payload := generateSigningPayload(params)

    shares, err := shamir.Split(payload, 2, 2)
    if err != nil {
        return nil, fmt.Errorf("shamir split: %w", err)
    }

    // Each share encrypted with its recipient's dedicated AES-256-GCM key.
    // Defense-in-depth: even if a share leaks from the database,
    // it's ciphertext without the corresponding key.
    adminKey, _    := keystore.GenerateKey()
    merchantKey, _ := keystore.GenerateKey()

    adminShareEnc, _    := keystore.EncryptWithKey(shares[0].Data, adminKey)
    merchantShareEnc, _ := keystore.EncryptWithKey(shares[1].Data, merchantKey)

    s.balances.DebitBalance(ctx, params.MerchantOrgID, params.Amount)

    return s.repo.CreateSettlement(ctx, &Settlement{
        AdminShareEncrypted:    adminShareEnc,
        MerchantShareEncrypted: merchantShareEnc,
        AdminShareKey:          adminKey,
        MerchantShareKey:       merchantKey,
        AdminShareX:            shares[0].X,
        MerchantShareX:         shares[1].X,
        SigningPayload:         payload,
        Status:                 StatusPendingConfirmation,
    })
}

When both parties confirm, the system reconstructs and verifies before broadcasting:

func (s *SettlementService) executeSettlement(ctx context.Context, stl *Settlement) {
    adminShare, err := keystore.DecryptWithKey(stl.AdminShareEncrypted, stl.AdminShareKey)
    if err != nil {
        s.failSettlement(ctx, stl, "admin share decryption failed")
        return
    }
    merchantShare, err := keystore.DecryptWithKey(stl.MerchantShareEncrypted, stl.MerchantShareKey)
    if err != nil {
        s.failSettlement(ctx, stl, "merchant share decryption failed")
        return
    }

    reconstructed, err := shamir.Combine([]shamir.Share{
        {X: stl.AdminShareX,    Data: adminShare},
        {X: stl.MerchantShareX, Data: merchantShare},
    })
    if err != nil {
        s.failSettlement(ctx, stl, "shamir combine failed")
        return
    }

    if !constantTimeEqual(reconstructed, stl.SigningPayload) {
        s.failSettlement(ctx, stl, "payload integrity check failed")
        return
    }

    s.broadcast(ctx, stl, reconstructed)
}
Pitfall #2 — Constant-time comparison is not optional

An early-exit byte comparison leaks timing information about where the reconstructed payload first differs from the original. Over a sufficient number of measurements, this leaks partial information about the secret. Use subtle.ConstantTimeCompare from the Go standard library, or implement the XOR-accumulator pattern:

func constantTimeEqual(a, b []byte) bool {
    if len(a) != len(b) {
        return false
    }
    result := byte(0)
    for i := range a {
        result |= a[i] ^ b[i]
    }
    return result == 0
}

The Go compiler will not optimise away this loop. bytes.Equal makes no such guarantee.

Pitfall #3 — Encrypt shares at rest

Shamir’s information-theoretic guarantee holds in isolation. In production, shares sit in a PostgreSQL row alongside metadata that may hint at their context — settlement amounts, merchant IDs, timestamps. AES-256-GCM encryption per-party adds two things: (a) confidentiality if the database is exfiltrated, and (b) authentication — you know each share hasn’t been tampered with before combining. The overhead is negligible. The risk reduction is not.


Production results from AxisCorePay

What we measured in production

  • Split + Combine latency: < 1 ms for payloads up to 10 KB — negligible compared to human confirmation round-trips
  • SSS core: ~200 lines of Go, test coverage 100% — round-trip, threshold edge cases, duplicate x-coordinates, empty input
  • Settlement service integration: ~400 additional lines including key management and audit logging
  • Risk profiles: 2-of-2 for standard withdrawals; 2-of-3 (platform + merchant + compliance) for large settlements above a configurable threshold
  • Zero incidents: No settlement has been executed without both authorized signatures in production

Security posture change:

MetricBefore (Vault-stored key)After (Shamir 2-of-2)
Compromises to steal1 — Vault access2+ independent parties
Info from one shareFull decryption key0 bits (proven)
Insider threatRequires admin onlyRequires collusion
Audit trailServer logsLogs + cryptographic commitment

Key takeaways

Five things we’d tell ourselves before starting

  • GF(2⁸) is the right field. One element per byte, addition is XOR, multiplication is lookup tables. No big integers, no encoding overhead. The same field AES uses — the exp/log tables are battle-tested.
  • Check the leading coefficient. A silently-zeroed leading coefficient drops polynomial degree and makes the security threshold a lie. Always re-sample. The expected cost is 256/255 ≈ 1 iteration.
  • Constant-time comparison is not a nice-to-have. Financial systems are timing-attack targets. Use subtle.ConstantTimeCompare.
  • Encrypt shares at rest, unconditionally. AES-256-GCM per-party adds authentication and defense-in-depth at near-zero cost.
  • Shamir is for data, MPC-TSS is for keys. SSS assembles the secret in memory — fine for transaction payload verification with immediate zeroing. For private keys that must never assemble, use MPC-TSS. Use both in the same system where appropriate.

FAQ

What is Shamir’s Secret Sharing and how does it differ from encryption?

Encryption protects a secret using computational hardness — breaking it is infeasible but theoretically possible given enough computation. Shamir SSS provides information-theoretic security: t1t-1 shares reveal literally zero bits of information, regardless of computational power. There is nothing to “break.”

When should I use Shamir SSS vs MPC-TSS for key management in Go?

Use Shamir when the secret can briefly assemble in memory for verification — e.g., a settlement payload that is immediately zeroed after use. Use MPC-TSS when the key must never assemble anywhere, such as for long-lived private keys controlling blockchain wallets.

Are the GF(28)\text{GF}(2^8) table-lookup operations constant-time?

Array lookups with computed indices are constant-time in Go (no branch prediction, no cache side-channels for small 256-element tables on modern CPUs). The explicit constant-time requirement applies to the final equality check, not the field arithmetic.

What threshold should I choose for a two-party payment authorization?

2-of-2 enforces bilateral confirmation with no unilateral override — use it when both parties must always be present. 2-of-3 (add a compliance or recovery key as the third share holder) provides fault tolerance if one party is temporarily unavailable, while still requiring two confirmations. Avoid 2-of-2 without a key recovery ceremony if permanent loss of one party would lock funds.

Is there a production-ready Go library for Shamir SSS?

HashiCorp Vault uses Shamir internally for Vault unseal key splitting, and the package is importable. The implementation above is written for transparency — for production use, prefer hashicorp/vault/shamir or a well-audited alternative rather than rolling your own field arithmetic.


We built AxisCorePay — a production multi-chain crypto payment platform using exactly these patterns. See the full case study →


Ready to build production AI systems?

We help teams ship AI that works in the real world. Let's discuss your project.

Related posts

Related reading