Skip to content

Argon2 Hash & Verify — Ruby source

Hash passwords with Argon2id — the winner of the Password Hashing Competition. Configure memory, iterations, and parallelism. WASM-powered, client-side.

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

# Argon2 — Argon2id password hashing (parse/PHC logic in pure Ruby; the KDF
# itself via the reference argon2(1) CLI).
#
# Language: Ruby (3.1+, standard library only)
# Source:   CosmoDev polyglot showcase port of the Argon2 tool, ported from
#           src/lib/argon2.ts (the canonical TypeScript implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# The TS build drives the reference C library compiled to WASM
# (argon2-browser). Ruby has no Argon2 in its standard library or in the
# openssl gem, so this port drives the same reference C library through its
# CLI: `argon2` (https://github.com/P-H-C/phc-winner-argon2) must be on PATH
# for #argon2_hash / #argon2_verify. The PHC parser, validator, and Base64
# codec are pure Ruby and dependency-free.
#
# PHC string format (what `encoded` holds - the string you store in a DB):
#   $argon2id$v=19$m=65536,t=3,p=1$<b64 salt>$<b64 digest>
# Salt and digest are unpadded standard Base64.

require 'open3'
require 'securerandom'

module Argon2
  Argon2Result = Struct.new(:hash, :encoded, :salt, keyword_init: true)
  # Parameters extracted from a PHC string (#parse_argon2's return type).
  Argon2Params = Struct.new(:type, :version, :memory, :iterations,
                            :parallelism, :salt, :hash, keyword_init: true)

  # Defaults follow the OWASP-recommended Argon2id profile (64 MiB, 3 passes).
  ARGON2_DEFAULTS = {
    memory: 65_536,
    iterations: 3,
    parallelism: 1,
    hash_length: 32
  }.freeze

  # Random salt size in bytes (128 bits - the PHC recommendation).
  SALT_BYTES = 16

  # Argon2 variant ids as the C library encodes them.
  TYPE_BY_NAME = { 'argon2d' => 0, 'argon2i' => 1, 'argon2id' => 2 }.freeze

  class << self
    # Parse a PHC-format Argon2 string
    # (`$argon2id$v=19$m=65536,t=3,p=1$salt$hash`) into its typed parameters.
    # Accepts argon2d / argon2i / argon2id. The digest segment is optional
    # (some encoders omit it); salt and hash are returned as lowercase hex.
    # Raises on any malformed input.
    def parse_argon2(encoded)
      m = /\A\$(argon2(?:d|i|id))\$v=(\d+)\$m=(\d+),t=(\d+),p=(\d+)\$([A-Za-z0-9+/]+)(?:\$([A-Za-z0-9+/]+))?\z/
          .match(encoded.strip)
      raise ArgumentError,
            'Invalid Argon2 string: expected $argon2id$v=19$m=…,t=…,p=…$salt$hash' unless m

      Argon2Params.new(
        type: m[1],
        version: Integer(m[2], 10),
        memory: Integer(m[3], 10),
        iterations: Integer(m[4], 10),
        parallelism: Integer(m[5], 10),
        salt: bytes_to_hex(phc_base64_to_bytes(m[6])),
        hash: m[7] ? bytes_to_hex(phc_base64_to_bytes(m[7])) : ''
      )
    end

    # Hash a password with Argon2id (hybrid of Argon2i's side-channel
    # resistance and Argon2d's GPU resistance - the Password Hashing
    # Competition winner and the recommended mode for password storage).
    # Returns the digest (hex), the salt used (hex), and the self-contained
    # PHC string. A fresh random 16-byte salt is generated per call unless
    # options[:salt] is given.
    #
    # options (all optional): memory (KiB), iterations, parallelism,
    # hash_length, salt (binary String).
    def argon2_hash(password, options = {})
      opts = normalize_options(options)
      salt = options[:salt] || SecureRandom.random_bytes(SALT_BYTES)

      # The CLI's -m flag is log2(memory in KiB); memory is always a power of
      # two here (default 65536 = -m 16).
      out, err, status = Open3.capture3(
        'argon2', salt,
        '-id', '-v', '13', '-e',
        '-m', Math.log2(opts[:memory]).round.to_s,
        '-t', opts[:iterations].to_s,
        '-p', opts[:parallelism].to_s,
        '-l', opts[:hash_length].to_s,
        stdin_data: password
      )
      raise "argon2 failed: #{err.strip}" unless status.success?

      encoded = out.strip
      digest = encoded.split('$') # digest is the final PHC segment
      Argon2Result.new(
        hash: bytes_to_hex(phc_base64_to_bytes(digest.last)),
        encoded: encoded,
        salt: bytes_to_hex(salt)
      )
    end

    # Verify a password against a PHC-format encoded hash (as produced by
    # #argon2_hash). Returns true on match, false on mismatch; raises only on
    # a malformed encoded string or a runtime error. Any Argon2 type (d/i/id)
    # is accepted - the type is read from the string itself.
    #
    # The CLI has no verify mode, so this recomputes the hash with the
    # embedded parameters + salt and compares digests (what the C library's
    # argon2_verify does internally).
    def argon2_verify(encoded, password)
      params = parse_argon2(encoded) # validate format up front
      salt = [params.salt].pack('H*')
      recomputed = argon2_hash(
        password,
        memory: params.memory,
        iterations: params.iterations,
        parallelism: params.parallelism,
        # The CLI emits a fixed-length digest; keep the embedded length when
        # present so the comparison is byte-for-byte.
        hash_length: params.hash.empty? ? ARGON2_DEFAULTS[:hash_length] : params.hash.length / 2,
        salt: salt
      )
      return recomputed.encoded == encoded.strip if params.hash.empty?

      recomputed.hash == params.hash
    end

    private

    # Lowercase hex of a binary String.
    def bytes_to_hex(bytes)
      bytes.unpack1('H*')
    end

    # Unpadded standard Base64 (the PHC encoding) -> bytes. Raises on any
    # non-alphabet character or an impossible length (1 mod 4).
    def phc_base64_to_bytes(b64)
      if b64.empty?
        raise ArgumentError, 'Invalid Argon2 string: empty Base64 field'
      end
      unless b64.match?(/\A[A-Za-z0-9+\/]+\z/)
        raise ArgumentError, 'Invalid Argon2 string: non-Base64 characters'
      end
      if b64.length % 4 == 1
        raise ArgumentError, 'Invalid Argon2 string: impossible Base64 length'
      end

      # Re-pad to a multiple of 4 and let unpack do the 6-bit arithmetic;
      # the length checks above already mirror the TS byte counting.
      b64.ljust((b64.length + 3) / 4 * 4, '=').unpack1('m0')
    end

    # Validate + normalise hashing parameters, raising with a clear message.
    def normalize_options(options = {})
      memory = options[:memory] || ARGON2_DEFAULTS[:memory]
      iterations = options[:iterations] || ARGON2_DEFAULTS[:iterations]
      parallelism = options[:parallelism] || ARGON2_DEFAULTS[:parallelism]
      hash_length = options[:hash_length] || ARGON2_DEFAULTS[:hash_length]
      if !memory.is_a?(Numeric) || memory < 1024
        raise ArgumentError, 'Memory must be at least 1024 KiB'
      end
      raise ArgumentError, 'Iterations must be at least 1' if !iterations.is_a?(Numeric) || iterations < 1
      raise ArgumentError, 'Parallelism must be at least 1' if !parallelism.is_a?(Numeric) || parallelism < 1
      if !hash_length.is_a?(Numeric) || hash_length < 16 || hash_length > 64
        raise ArgumentError, 'Hash length must be between 16 and 64 bytes'
      end

      { memory: memory.to_i, iterations: iterations.to_i,
        parallelism: parallelism.to_i, hash_length: hash_length.to_i }
    end
  end
end

Also available in 9 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 →