Argon2 Hash & Verify — C# source
Hash passwords with Argon2id — the winner of the Password Hashing Competition. Configure memory, iterations, and parallelism. WASM-powered, client-side.
This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Argon2 — Argon2id password hashing (PHC parse/format logic in pure C#;
// the KDF itself via the reference argon2(1) CLI).
// C# 12 / .NET 8 — ported from src/lib/argon2.ts (the canonical TypeScript
// implementation). Display source for CosmoDev's polyglot pages.
//
// The TS build drives the reference C library compiled to WASM
// (argon2-browser). .NET has no Argon2 in System.Security.Cryptography or
// anywhere else in the BCL, 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 Hash / Verify. The PHC parser, parameter validator and
// Base64 codec are pure C# 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.
//
// The CLI takes its salt as one argv value, and argv travels through the
// platform's native encoding - raw binary salt bytes >= 0x80 do not arrive
// byte-for-byte. Random salts are therefore drawn as 128 bits rendered in 32
// lowercase hex chars (always ASCII-safe), and Hash reads the effective salt
// and digest straight back out of the PHC string the CLI prints: the returned
// Hash / Salt / Encoded always describe the salt that was actually used.
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
/// <summary>Optional Argon2 hashing parameters (defaults follow ARGON2_DEFAULTS).</summary>
public sealed class Argon2Options
{
/// <summary>Memory cost in KiB (default 65536 = 64 MiB). Must be >= 1024.</summary>
public int? Memory { get; init; }
/// <summary>Time cost - passes over memory (default 3). Must be >= 1.</summary>
public int? Iterations { get; init; }
/// <summary>Parallelism - lanes (default 1). Must be >= 1.</summary>
public int? Parallelism { get; init; }
/// <summary>Digest length in bytes (default 32). Must be 16..64.</summary>
public int? HashLength { get; init; }
}
/// <summary>Parameters extracted from a PHC string (Parse's return type).</summary>
public sealed record Argon2Params(
/// <summary>"argon2d" | "argon2i" | "argon2id".</summary>
string Type,
int Version,
int Memory,
int Iterations,
int Parallelism,
/// <summary>Salt, decoded from the embedded Base64 into lowercase hex.</summary>
string Salt,
/// <summary>Digest, decoded from the embedded Base64 into lowercase hex ("" if absent).</summary>
string Hash);
/// <summary>The outcome of a Hash call.</summary>
public sealed record Argon2Result(string Hash, string Encoded, string Salt);
public static class Argon2
{
/// <summary>Defaults follow the OWASP-recommended Argon2id profile (64 MiB, 3 passes).</summary>
public const int DefaultMemory = 65_536;
public const int DefaultIterations = 3;
public const int DefaultParallelism = 1;
public const int DefaultHashLength = 32;
/// <summary>Random salt size, in hex characters (128 bits, drawn as ASCII-safe hex).</summary>
public const int SaltChars = 32;
private static readonly Regex PhcRegex = new(
@"^\$(argon2(?:d|i|id))\$v=(\d+)\$m=(\d+),t=(\d+),p=(\d+)\$([A-Za-z0-9+/]+)(?:\$([A-Za-z0-9+/]+))?$",
RegexOptions.Compiled);
private static string BytesToHex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
/// <summary>char -> 6-bit value for the standard Base64 alphabet.</summary>
private static int B64Value(char c) => c switch
{
>= 'A' and <= 'Z' => c - 'A',
>= 'a' and <= 'z' => c - 'a' + 26,
>= '0' and <= '9' => c - '0' + 52,
'+' => 62,
'/' => 63,
_ => -1,
};
/// <summary>
/// Unpadded standard Base64 (the PHC encoding) -> bytes. Throws on any
/// non-alphabet character or an impossible length (1 mod 4).
/// </summary>
private static byte[] PhcBase64ToBytes(string b64)
{
if (b64.Length == 0) throw new FormatException("Invalid Argon2 string: empty Base64 field");
foreach (var c in b64)
{
if (B64Value(c) < 0) throw new FormatException("Invalid Argon2 string: non-Base64 characters");
}
if (b64.Length % 4 == 1) throw new FormatException("Invalid Argon2 string: impossible Base64 length");
var outputLength = b64.Length / 4 * 3;
if (b64.Length % 4 == 2) outputLength = (b64.Length - 2) / 4 * 3 + 1;
if (b64.Length % 4 == 3) outputLength = (b64.Length - 3) / 4 * 3 + 2;
var bytes = new byte[outputLength];
var p = 0;
for (var i = 0; i < b64.Length; i += 4)
{
// Length was validated above (not 1 mod 4), so every group has >= 2 chars.
var d0 = B64Value(b64[i]);
var d1 = B64Value(b64[i + 1]);
if (p < outputLength) bytes[p++] = (byte)((d0 << 2) | (d1 >> 4));
if (i + 2 < b64.Length)
{
var d2 = B64Value(b64[i + 2]);
if (p < outputLength) bytes[p++] = (byte)(((d1 & 0x0f) << 4) | (d2 >> 2));
if (i + 3 < b64.Length)
{
var d3 = B64Value(b64[i + 3]);
if (p < outputLength) bytes[p++] = (byte)(((d2 & 0x03) << 6) | d3);
}
}
}
return bytes;
}
/// <summary>
/// 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. Throws on any malformed input.
/// </summary>
public static Argon2Params Parse(string encoded)
{
var m = PhcRegex.Match(encoded.Trim());
if (!m.Success)
{
throw new FormatException(
"Invalid Argon2 string: expected $argon2id$v=19$m=…,t=…,p=…$salt$hash");
}
return new Argon2Params(
m.Groups[1].Value,
int.Parse(m.Groups[2].Value),
int.Parse(m.Groups[3].Value),
int.Parse(m.Groups[4].Value),
int.Parse(m.Groups[5].Value),
BytesToHex(PhcBase64ToBytes(m.Groups[6].Value)),
m.Groups[7].Success ? BytesToHex(PhcBase64ToBytes(m.Groups[7].Value)) : "");
}
/// <summary>Validate + normalise hashing parameters, throwing with a clear message.</summary>
private static (int Memory, int Iterations, int Parallelism, int HashLength) Normalize(Argon2Options? options)
{
var memory = options?.Memory ?? DefaultMemory;
var iterations = options?.Iterations ?? DefaultIterations;
var parallelism = options?.Parallelism ?? DefaultParallelism;
var hashLength = options?.HashLength ?? DefaultHashLength;
if (memory < 1024) throw new ArgumentOutOfRangeException(nameof(options), "Memory must be at least 1024 KiB");
if (iterations < 1) throw new ArgumentOutOfRangeException(nameof(options), "Iterations must be at least 1");
if (parallelism < 1) throw new ArgumentOutOfRangeException(nameof(options), "Parallelism must be at least 1");
if (hashLength < 16 || hashLength > 64)
{
throw new ArgumentOutOfRangeException(nameof(options), "Hash length must be between 16 and 64 bytes");
}
return (memory, iterations, parallelism, hashLength);
}
/// <summary>The CLI's type flag for an Argon2 variant name.</summary>
private static string TypeFlag(string type) => type switch
{
"argon2d" => "-d",
"argon2i" => "-i",
"argon2id" => "-id",
_ => throw new FormatException($"Unsupported Argon2 type: {type}"),
};
/// <summary>
/// Run the reference argon2(1) CLI. The password arrives via stdin, the
/// salt and parameters as an argument ARRAY (never a command string), so
/// no shell ever parses either. Returns the CLI's stdout.
/// </summary>
private static string RunArgon2(IReadOnlyList<string> arguments, string password)
{
var start = new ProcessStartInfo("argon2")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
};
foreach (var argument in arguments)
{
start.ArgumentList.Add(argument);
}
using var process = Process.Start(start)
?? throw new InvalidOperationException(
"argon2 CLI not found on PATH (https://github.com/P-H-C/phc-winner-argon2).");
process.StandardInput.Write(password);
process.StandardInput.Close();
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
string.IsNullOrWhiteSpace(stderr) ? $"argon2 exited with {process.ExitCode}." : stderr.Trim());
}
return stdout;
}
private static string StdoutField(string stdout, string label)
{
var line = stdout.Split('\n').FirstOrDefault(l => l.TrimStart().StartsWith(label, StringComparison.Ordinal));
if (line == null) throw new InvalidOperationException($"argon2 output carried no {label} line.");
return line.Split(':', 2)[1].Trim();
}
/// <summary>
/// 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 salt is generated per call.
/// </summary>
public static Argon2Result Hash(string password, Argon2Options? options = null)
{
var (memory, iterations, parallelism, hashLength) = Normalize(options);
// 128 bits drawn as 32 lowercase hex chars: every encoding passes the
// salt through unchanged, and the CLI reports the effective value back.
var saltArgument = BytesToHex(RandomNumberGenerator.GetBytes(16));
var stdout = RunArgon2(
[
saltArgument, "-id",
"-t", iterations.ToString(),
"-m", memory.ToString(),
"-p", parallelism.ToString(),
"-l", hashLength.ToString(),
"-v", "13", // Argon2 version 1.3 (v=19)
], password);
var hash = StdoutField(stdout, "Hash");
var encoded = StdoutField(stdout, "Encoded");
// The effective salt, read back from the PHC string the CLI printed.
var salt = Parse(encoded).Salt;
return new Argon2Result(hash.ToLowerInvariant(), encoded, salt);
}
/// <summary>
/// Verify a password against a PHC-format encoded hash (as produced by
/// Hash). Returns true on match, false on mismatch; throws 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.
/// </summary>
public static bool Verify(string encoded, string password)
{
var parameters = Parse(encoded); // validate format up front
if (parameters.Hash.Length == 0)
{
throw new FormatException("Invalid Argon2 string: no digest to verify against.");
}
var expected = Convert.FromHexString(parameters.Hash);
// The stored salt decodes to the exact ASCII bytes that seeded the CLI
// run that produced this hash - pass them back through unchanged.
var saltBytes = PhcBase64ToBytes(encoded.Split('$', StringSplitOptions.RemoveEmptyEntries)[3]);
var saltArgument = System.Text.Encoding.ASCII.GetString(saltBytes);
var stdout = RunArgon2(
[
saltArgument, TypeFlag(parameters.Type),
"-t", parameters.Iterations.ToString(),
"-m", parameters.Memory.ToString(),
"-p", parameters.Parallelism.ToString(),
"-l", expected.Length.ToString(),
"-v", parameters.Version == 16 ? "10" : "13",
], password);
var recomputed = StdoutField(stdout, "Hash");
return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(recomputed), expected);
}
}
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 →