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 →