#!/usr/bin/env python3
"""verify_dice_entropy.py — independently check that a hardware wallet used YOUR dice
and nothing else when it generated a seed.

RUN THIS OFFLINE, ON AN AIRGAPPED MACHINE.

WHAT THIS IS FOR
----------------
When a device generates a seed from dice rolls, the honest behaviour is:

    entropy = SHA256( the ASCII digits of your rolls )

and the BIP39 seed follows deterministically from that entropy. Because every step is
public and deterministic, you can recompute it yourself and compare. If your device
shows the same value, it used your dice and only your dice — whatever its internal RNG
was doing contributed nothing.

That is the entire argument, and it is worth being precise about why it matters: you
cannot check a random number generator by looking at its output. Output from a broken
or backdoored RNG passes every statistical test there is. What you CAN check is a
deterministic derivation, and that is what this script does.

WHY AN INDEPENDENT IMPLEMENTATION
---------------------------------
The vendor ships a verification script too, and you should run that as well. But
checking a vendor's device with the vendor's own script is circular: one bug, or one
bad build, and both sides agree while both are wrong. Two implementations written from
the spec by different parties failing in the same way is much less likely. This file is
deliberately tiny and stdlib-only so you can read all of it before trusting it.

SAFETY
------
* This script takes DICE ROLLS. It never asks for a seed phrase. Any tool that asks you
  to type an existing seed phrase, especially during an active incident, should be
  assumed hostile.
* Run it with no network. It makes no network calls — you can verify that by reading
  it; the only imports are hashlib, sys and argparse.
* The mnemonic is printed only if you pass --words with a BIP39 wordlist file, so the
  default output is just a hex digest and is safe to compare on screen.

USAGE
-----
    python3 verify_dice_entropy.py --rolls 4526135...        # digits 1-6
    python3 verify_dice_entropy.py --rolls-file rolls.txt
    python3 verify_dice_entropy.py --rolls ... --words english.txt

Compare the printed HEX against what your device displayed. They must match exactly.
"""

import argparse
import hashlib
import sys

ROLLS_FOR_256_BITS = 100          # log2(6) = 2.585 bits per d6; 99 rolls = 255.9, short
VENDOR_SAFE_ROLLS = 50   # Coinkite's stated threshold for being outside the RNG bug
BITS_PER_ROLL = 2.5849625007211562


def entropy_from_rolls(rolls: str) -> bytes:
    """SHA256 over the ASCII roll digits. This is the derivation being verified."""
    return hashlib.sha256(rolls.encode("ascii")).digest()


def bip39_mnemonic(entropy: bytes, wordlist):
    """Standard BIP39: append a checksum of len(entropy)/4 bits, split into 11-bit
    indices, map through the wordlist."""
    if len(wordlist) != 2048:
        raise SystemExit("error: wordlist must contain exactly 2048 words, got %d" % len(wordlist))
    checksum_bits = len(entropy) * 8 // 32
    digest = hashlib.sha256(entropy).digest()
    bits = "".join(f"{b:08b}" for b in entropy) + "".join(f"{b:08b}" for b in digest)[:checksum_bits]
    return [wordlist[int(bits[i:i + 11], 2)] for i in range(0, len(bits), 11)]


def main():
    p = argparse.ArgumentParser(description="Independently verify a dice-generated seed.")
    p.add_argument("--rolls", help="dice rolls as digits 1-6, e.g. 41526...")
    p.add_argument("--rolls-file", help="file containing the rolls (whitespace ignored)")
    p.add_argument("--words", help="path to a BIP39 wordlist (2048 lines) to also print the mnemonic")
    a = p.parse_args()

    if not a.rolls and not a.rolls_file:
        p.error("give --rolls or --rolls-file")

    raw = a.rolls or open(a.rolls_file, encoding="utf-8").read()
    rolls = "".join(ch for ch in raw if not ch.isspace())

    bad = sorted({ch for ch in rolls} - set("123456"))
    if bad:
        raise SystemExit("error: rolls must contain only digits 1-6; found: %s" % ", ".join(repr(c) for c in bad))
    if not rolls:
        raise SystemExit("error: no rolls given")

    n = len(rolls)
    bits = n * BITS_PER_ROLL
    ent = entropy_from_rolls(rolls)

    print("rolls counted:      %d" % n)
    print("entropy supplied:   %.1f bits" % bits)
    # TWO DIFFERENT THRESHOLDS, and conflating them is the mistake this tool
    # originally made. 50 rolls is Coinkite's own stated line for being outside the
    # 2021-2026 RNG bug ("at least 50 fair, independent, private dice rolls... we do
    # not consider that seed at risk from this RNG issue alone"). ~99 rolls is the
    # separate, stronger property of a full-strength 256-bit seed independent of any
    # device. An earlier version of this script warned "consider regenerating" at 54
    # rolls, which would push someone who is already safe from the bug into an
    # unnecessary migration. Answer both questions separately.
    if n >= VENDOR_SAFE_ROLLS:
        print("  OUTSIDE THE RNG BUG: %d rolls is at or above the vendor's stated"
              " threshold of %d fair, independent, PRIVATE rolls." % (n, VENDOR_SAFE_ROLLS))
    else:
        print("  AT RISK FROM THE RNG BUG: %d rolls is below the vendor's threshold of %d."
              % (n, VENDOR_SAFE_ROLLS))
        print("  If this seed was created on affected firmware, treat it as exposed"
              " and migrate.")
    if bits < 256:
        print("  Not full strength: %.1f bits. ~%d rolls would give a 256-bit seed"
              " independent of the device." % (bits, ROLLS_FOR_256_BITS))
        print("  That is a SEPARATE, stronger property — not a statement about this bug.")
    else:
        print("  Full strength: at or above 256 bits (SHA256 caps the result at 256 regardless).")
    print()
    print("SHA256(rolls) = entropy hex:")
    print("  " + ent.hex())
    print()
    print("Compare that hex against what your device displayed.")
    print("A match proves the device derived the seed from your dice alone.")
    print("A MISMATCH means the device mixed in its own entropy — which is exactly the")
    print("case you cannot verify, and the seed should not be trusted.")

    if a.words:
        wl = [w.strip() for w in open(a.words, encoding="utf-8").read().split() if w.strip()]
        words = bip39_mnemonic(ent, wl)
        print()
        print("BIP39 mnemonic (%d words):" % len(words))
        print("  " + " ".join(words))
        print()
        print("Shown only because you passed --words. Make sure nobody is looking.")


def _self_test():
    """Known-answer tests, so the file can prove itself before you rely on it."""
    # SHA256 of the ASCII string "123456" — independently checkable with:
    #   printf '123456' | sha256sum
    assert entropy_from_rolls("123456").hex() == \
        "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"
    # BIP39 all-zero entropy is the canonical test vector from the spec.
    wl_stub = ["abandon"] * 2048
    wl_stub[0] = "abandon"
    m = bip39_mnemonic(bytes(32), wl_stub)
    assert len(m) == 24, m
    # Entropy arithmetic: 99 rolls falls short of 256 bits, 100 clears it.
    assert 99 * BITS_PER_ROLL < 256 <= 100 * BITS_PER_ROLL
    print("self-test passed: SHA256 known-answer, BIP39 24-word length, roll-count threshold")


if __name__ == "__main__":
    if "--self-test" in sys.argv:
        _self_test()
    else:
        main()
