Skip to content

Coefficient-uniqueness constraint in __shamirFn leaks ~0.27 bits of secret entropy (indirect via q.indexOf) #64

Description

@KOFACY

[Bug Report] Coefficient-uniqueness constraint in __shamirFn leaks ~0.27 bits of secret entropy (indirect via q.indexOf)

Target: Bitaps "1 BTC Challenge" (https://bitaps.com/mnemonic/challenge)
Affected repository: bitaps-com/jsbtc
Affected file: src/functions/shamir_secret_sharing.js
Related but NOT affected: bitaps-com/pybtc, pybtc/functions/shamir.py (see comparison below)
Related prior reports: #23 (pybtc, 2021, onvej-sl), #73 (pybtc, 2026)
Disclosure timeline: Discovered and reported 2026-07-03. No prior private disclosure made.

Summary

The JavaScript implementation of Shamir's Secret Sharing used by the Bitaps
mnemonic-splitting tool (jsbtc) generates polynomial coefficients under an
implicit pairwise-uniqueness constraint that is absent from the reference
Python implementation (pybtc). This constraint is a side effect of how the
polynomial-evaluation function resolves exponents, and it causes the set of
coefficients q = [secret_byte, a_1, ..., a_{t-1}] for every byte of the
secret to always consist of mutually distinct values.

This is a deviation from the security model Shamir's Secret Sharing is
supposed to provide: with a properly uniform random polynomial, any set of
shares below the threshold leaks strictly zero information
about the
secret (information-theoretic, not computational, security). Because the
JS implementation silently forbids duplicate coefficient values, an attacker
holding threshold - 1 shares can reject candidate secret values that would
require a duplicate among q, which is a small but real, non-zero,
reproducible information leak.

This directly relates to the security claim stated on the challenge page:

"This means that any three shares are sufficient to restore the original
mnemonic code" / "any fewer are mathematically impossible"

That claim is not strictly true for shares generated by the JS
implementation.

Root cause

1. The exponent-by-lookup bug that forces the constraint

src/functions/shamir_secret_sharing.js, polynomial evaluation:

S.__shamirFn = (x, q) => {
    let r = 0;
    for (let a of q) r = S.__GF256_add(r, S.__GF256_mul(a, S.__GF256_pow(x, q.indexOf(a))));
    return r;
};

The exponent used for each coefficient a is computed as q.indexOf(a)
the index of the first occurrence of that value in q — instead of the
loop index. If q ever contained two equal values (e.g. a_1 === a_2, or
secret_byte === a_1), the second occurrence would silently be evaluated
with the wrong exponent, corrupting the polynomial and making the shares
unrecoverable.

2. The workaround that introduces the leak

To avoid ever triggering the bug above, coefficient generation explicitly
rejects any candidate value already present in q:

for (let i = 0; i < threshold - 1; i++) {
    do {
        if (ePointer >= e.length) {
            ePointer = 0;
            e = S.generateEntropy({hex:false});
        }
        w = e[ePointer++];
    } while (q.includes(w));   // <-- rejects duplicates, including vs. secret_byte
    q.push(w);
}

Every element of q — including the secret byte itself — is therefore
guaranteed to be pairwise distinct within each byte's polynomial. This
guarantee is public knowledge about the algorithm (it's in the published
source), so it is available to an attacker.

3. Comparison — the Python reference implementation does NOT have this issue

pybtc/functions/shamir.py:

for i in range(threshold - 1):
    if e_i < len(e):
        a = e[e_i]
        e_i += 1
    else:
        e = generate_entropy(hex=False)
        a = e[0]
        e_i = 1
    q.append(a)

No includes/rejection check exists here — coefficients (and the secret
byte) can coincide freely, and _fn() uses the actual loop index
(enumerate(q)), not a value lookup. We verified empirically that shares
generated by this code leak zero information below threshold (see PoC 1).

Since the challenge references both repositories, and the actual web tool
at tbtc.bitaps.com / bitaps.com/mnemonic/challenge is a browser-based
tool (i.e. necessarily built on the jsbtc code path, not pybtc), the
leaking implementation is the one plausibly used to generate the published
challenge shares.

Proof of Concept

PoC 1 — Python (pybtc) reference: zero information leak (baseline, correct behavior)

from shamir_standalone import split_secret, _gf256_add, _gf256_mul, _gf256_div

secret = bytes([0x42])
shares = split_secret(threshold=3, total=5, secret=secret)
z1, z2 = list(shares.keys())[:2]
y1, y2 = shares[z1][0], shares[z2][0]

consistent = []
for candidate_secret in range(256):
    for a2_guess in range(256):
        z1_sq, z2_sq = _gf256_mul(z1, z1), _gf256_mul(z2, z2)
        rhs1 = _gf256_add(_gf256_add(y1, candidate_secret), _gf256_mul(a2_guess, z1_sq))
        a1_1 = _gf256_div(rhs1, z1)
        rhs2 = _gf256_add(_gf256_add(y2, candidate_secret), _gf256_mul(a2_guess, z2_sq))
        a1_2 = _gf256_div(rhs2, z2)
        if a1_1 == a1_2:
            consistent.append(candidate_secret)
            break

print(len(set(consistent)))  # -> 256 / 256 : correct, zero leak

Result: 256/256 candidate secret bytes remain consistent with 2 of 3
required shares — matches Shamir's information-theoretic guarantee exactly.

PoC 2 — JS-style coefficient generation (jsbtc logic reimplemented for testing): measurable leak

def split_secret_js_style(threshold, secret_byte, share_indices):
    q = [secret_byte]
    for i in range(threshold - 1):
        while True:
            w = random.SystemRandom().randint(0, 255)
            if w not in q:          # <-- reproduces jsbtc's q.includes(w) rejection
                q.append(w)
                break
    return {z: _fn(z, q) for z in share_indices}, q

Running the same "how many candidate secret bytes remain consistent with 2
known shares" analysis, but additionally rejecting any candidate
(secret, a1, a2) triple containing a duplicate value (since real triples
are guaranteed duplicate-free), over 500 trials with random secret bytes:

Metric Value
Mean candidates remaining (of 256) 253.01
Min / Max 253 / 255
Mean reduction vs. correct baseline 1.17% per byte
Equivalent bits leaked per byte ≈ 0.017 bits

Quantified impact

For a 16-byte (128-bit) BIP39 entropy secret, assuming independence across
byte positions:

remaining search space fraction ≈ (253/256)^16 ≈ 0.828
total information leaked ≈ 16 × log2(256/253) ≈ 0.27 bits (out of 128)

This does not come close to making brute-force feasible — effective
security drops from 128 bits to roughly 127.7 bits, which is cryptographically
insignificant (still ~10^38 operations). It does not provide a practical
path to the 1 BTC "break the scheme" reward.

It does, however, constitute a genuine, reproducible violation of the
stated security property ("any fewer [than threshold] shares are
mathematically impossible" — false, as shown above), and a real
divergence between the two "official" reference implementations linked
from the same bounty page, only one of which (pybtc) actually satisfies
the scheme's design guarantee.

Suggested severity / bounty category

Per the categories listed on the challenge page, this appears to fit:

"0.05 BTC – Any other significant implementation bug"

rather than the top-tier "break the scheme completely" reward, given the
negligible practical exploitability but genuine, demonstrable correctness
violation.

Suggested fix

Fix the root cause (__shamirFn's use of q.indexOf(a)) by using the
actual coefficient index instead of a value lookup:

S.__shamirFn = (x, q) => {
    let r = 0;
    for (let i = 0; i < q.length; i++) {
        r = S.__GF256_add(r, S.__GF256_mul(q[i], S.__GF256_pow(x, i)));
    }
    return r;
};

This removes the need for the q.includes(w) rejection loop during
coefficient generation, restoring true independent-uniform coefficient
sampling and the information-theoretic zero-leak guarantee that the Python
implementation already has.

Notes / open questions

  • We were unable to fetch the exact live JS bundle served at
    tbtc.bitaps.com (blocked by bot detection), so this report is based on
    the master branch of the linked jsbtc repository, which the challenge
    page explicitly references as "the exact software implementation."
  • We did not have access to the true x-coordinates (share indices) of the
    two published shares for the live challenge, so this report demonstrates
    the vulnerability mechanism and its quantified impact on synthetic data,
    not a break of the live challenge itself.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions