Skip to content

Shamir's Secret Sharing — Ruby source

Split a secret into N shares where any K shares can reconstruct it — but fewer than K reveal nothing. Based on Shamir's threshold scheme over GF(256).

This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# Secret Sharing — Shamir's Secret Sharing over GF(256), the Galois field of
# 256 elements.
#
# Language: Ruby (3.1+, standard library only)
# Source:   CosmoDev polyglot showcase port of the Secret Sharing tool,
#           ported from src/lib/secret-sharing.ts (the canonical TypeScript
#           implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# Pure math, zero dependencies. Addition in GF(256) is XOR; multiplication
# uses discrete log/exp tables built from the generator 3 (0x03) under the
# same reduction polynomial as AES (x^8 + x^4 + x^3 + x + 1 = 0x11B).
#
# Split: for each byte of the secret, build a random polynomial of degree
# K-1 whose constant term is the secret byte, then evaluate it at x = 1..N.
# Reconstruct: with K or more shares, Lagrange interpolation at x = 0
# recovers each constant term. Fewer than K shares reveal nothing
# (information-theoretic security).

require 'securerandom'

module SecretSharing
  # A parsed share: its x-coordinate and its per-byte polynomial evaluations.
  ParsedShare = Struct.new(:x, :y, keyword_init: true)

  # Exponent table: GF256_EXP[i] = 3^i in GF(256). Index 255 mirrors index 0.
  GF256_EXP = Array.new(256, 0)
  # Discrete log table: GF256_LOG[3^i] = i (GF256_LOG[0] is unused).
  GF256_LOG = Array.new(256, 0)

  class << self
    # Multiply by 2 (x) in GF(256), reducing by 0x11B - the AES "xtime".
    def xtime(a)
      ((a << 1) ^ ((a & 0x80).zero? ? 0 : 0x11b)) & 0xff
    end
  end

  # Build the log/exp tables from the generator 3.
  x = 1
  255.times do |i|
    GF256_EXP[i] = x
    GF256_LOG[x] = i
    # step to the next power of the generator 3: x *= 3 (i.e. x ^ xtime(x))
    x ^= xtime(x)
  end
  # 3 has order 255, so EXP wraps: EXP[255] === EXP[0].
  GF256_EXP[255] = 1

  class << self
    # Addition in GF(256) is bitwise XOR (also serves as subtraction).
    def gf_add(a, b)
      (a ^ b) & 0xff
    end

    # Multiply two field elements via log/exp tables.
    def gf_mul(a, b)
      return 0 if a.zero? || b.zero?

      GF256_EXP[(GF256_LOG[a] + GF256_LOG[b]) % 255]
    end

    # Multiplicative inverse of a non-zero element.
    def gf_inv(a)
      raise ArgumentError, '0 has no multiplicative inverse in GF(256)' if a.zero?

      GF256_EXP[(255 - GF256_LOG[a]) % 255]
    end

    # Divide a by b in GF(256).
    def gf_div(a, b)
      raise ArgumentError, 'Division by zero in GF(256)' if b.zero?
      return 0 if a.zero?

      GF256_EXP[(GF256_LOG[a] + 255 - GF256_LOG[b]) % 255]
    end

    # Evaluate a polynomial (coeffs[0] = constant term) at x, Horner style.
    def eval_poly(coeffs, x)
      y = 0
      (coeffs.length - 1).downto(0) do |i|
        y = gf_add(gf_mul(y, x), coeffs[i])
      end
      y
    end

    # Lagrange interpolation at x = 0 over distinct-x points - recovers the
    # polynomial's constant term. Subtraction is XOR, so (0 - xm) = xm and
    # (xj - xm) = xj ^ xm.
    def interpolate_at_zero(points)
      result = 0
      points.each_with_index do |(xj, yj), j|
        weight = 1
        points.each_with_index do |(_xm, _ym), m|
          next if m == j

          xm = points[m][0]
          weight = gf_mul(weight, gf_div(xm, xj ^ xm))
        end
        result = gf_add(result, gf_mul(yj, weight))
      end
      result
    end

    # Split a secret into total_shares shares (x = 1..N) where any threshold
    # of them reconstruct it. Returns hex share strings like "01-a3b2c1..."
    # - the two-digit hex x-coordinate, a dash, then one hex byte per secret
    # byte. opts[:get_random_bytes] is injectable for deterministic tests.
    def split_secret(secret, total_shares, threshold, opts = {})
      assert_int('Total shares', total_shares)
      assert_int('Threshold', threshold)
      if threshold < 2
        raise ArgumentError,
              'Threshold must be at least 2 (a 1-of-N split is just the secret itself)'
      end
      if total_shares > 255
        raise ArgumentError,
              'Total shares must be at most 255 (share x-coordinates live in 1..255)'
      end
      if threshold > total_shares
        raise ArgumentError,
              "Threshold (#{threshold}) cannot exceed total shares (#{total_shares})"
      end

      bytes = secret.encode('UTF-8').bytes
      rand_fn = opts[:get_random_bytes] || ->(n) { SecureRandom.random_bytes(n).bytes }
      y_parts = Array.new(total_shares) { Array.new(bytes.length, 0) }
      coeffs = Array.new(threshold, 0)

      bytes.each_index do |b|
        coeffs[0] = bytes[b]
        rand_fn.call(threshold - 1).each_with_index { |v, i| coeffs[i + 1] = v } if threshold > 1
        (1..total_shares).each do |i|
          y_parts[i - 1][b] = eval_poly(coeffs, i)
        end
      end

      y_parts.each_with_index.map do |ys, idx|
        format('%02x', idx + 1) + '-' + ys.pack('C*').unpack1('H*')
      end
    end

    # Parse one "xx-hex" share string; raises on any malformed input.
    def parse_share(share)
      s = share.strip
      if s.index('-') != 2 || !s[0, 2].match?(/\A[0-9a-fA-F]{2}\z/)
        raise ArgumentError,
              %(Malformed share "#{s}" - expected the format "xx-hex…" (e.g. "01-a3b2c1"))
      end
      y_hex = s[3..]
      if !y_hex.match?(/\A[0-9a-fA-F]*\z/) || y_hex.length.odd?
        raise ArgumentError,
              %(Malformed share "#{s}" - the payload must be an even-length hex string)
      end
      x = s[0, 2].to_i(16)
      if x.zero?
        raise ArgumentError, 'Share x-coordinate 00 is invalid - shares are numbered from 01'
      end

      ParsedShare.new(x: x, y: [y_hex].pack('H*').bytes)
    end

    # Reconstruct the secret from an arbitrary collection of share strings.
    # Needs at least 2 distinct shares (the threshold of the original split);
    # anything less than the true threshold K yields garbage without warning
    # - that is the security property of the scheme, not a bug.
    def reconstruct_secret(shares)
      raise ArgumentError, 'Need at least 2 shares to reconstruct' if shares.length < 2

      by_x = {}
      shares.each do |share|
        parsed = parse_share(share)
        # The same share pasted twice is harmless (dedupe); a colliding
        # x-coordinate with a different payload cannot belong to one split.
        existing = by_x[parsed.x]
        if existing.nil?
          by_x[parsed.x] = parsed.y
        elsif existing != parsed.y
          raise ArgumentError,
                "Two different shares both claim x=#{format('%02x', parsed.x)}" \
                ' - they cannot come from the same split'
        end
      end

      points = by_x.to_a.sort { |a, b| a[0] <=> b[0] }
      if points.length < 2
        raise ArgumentError, 'Need at least 2 distinct shares to reconstruct'
      end

      len = points[0][1].length
      if points.any? { |(_x, y)| y.length != len }
        raise ArgumentError,
              'All shares must be the same length - they do not come from the same split'
      end

      out = Array.new(len, 0)
      pair = points.map { |(x, _y)| [x, 0] }
      len.times do |b|
        points.each_with_index { |(_x, y), i| pair[i][1] = y[b] }
        out[b] = interpolate_at_zero(pair)
      end
      out.pack('C*').force_encoding(Encoding::UTF_8)
    end

    private

    def assert_int(name, v)
      return if v.is_a?(Integer)

      raise ArgumentError, "#{name} must be an integer (got #{v})"
    end
  end
end

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →