Skua.Auth.OTP (Skua v0.3.0)

Copy Markdown View Source

Cryptographically secure numeric one-time-password primitive for the mix skua.gen.auth OTP flows.

The generated auth code calls into this so the security-critical bits live in one tested place rather than copy-pasted (and possibly mangled) into every app:

  • generate/1 — an unbiased N-digit numeric code via CSPRNG rejection sampling, returned as a fixed-width string with leading zeros preserved.
  • hash/1 — SHA-256 of the code string. Store the hash, never the code.
  • normalize/2 — coerce a submitted code to a canonical digits-only string and validate its length. Pins the wire format: rejects short/long or integer-coerced input rather than zero-padding it (which would otherwise collapse the code space or silently fail leading-zero codes).
  • valid?/3 — constant-time comparison of a submitted code against a stored hash, after normalization.

Security properties (each is covered by a test)

  • No modulo bias. Codes are drawn with :crypto.strong_rand_bytes/1 and rejection sampling, so every value in 0..(10^digits - 1) is equally likely.
  • Leading zeros are significant. A 6-digit code is always a 6-character string; "004217" hashes and compares as "004217", never 4217.
  • Constant-time compare. Verification uses Plug.Crypto.secure_compare/2 to avoid timing leaks.
  • Configurable length. 6 digits by default; 8 is recommended for high-value deployments (generate(8)), with the same guarantees.

Summary

Functions

Generate an unbiased numeric code of digits length (default 6), zero-padded to a fixed-width string.

SHA-256 of a code string. Store this; never persist the plaintext code.

Normalize a submitted code to a canonical digits-only string of exactly digits length. Returns {:ok, code} or :error.

Constant-time check of a submitted code against a stored SHA-256 hash. The submitted code is normalized to digits first; malformed input returns false.

Types

digits()

@type digits() :: 1..10

Functions

generate(digits \\ 6)

@spec generate(digits()) :: String.t()

Generate an unbiased numeric code of digits length (default 6), zero-padded to a fixed-width string.

hash(code)

@spec hash(String.t()) :: binary()

SHA-256 of a code string. Store this; never persist the plaintext code.

normalize(submitted, digits \\ 6)

@spec normalize(term(), digits()) :: {:ok, String.t()} | :error

Normalize a submitted code to a canonical digits-only string of exactly digits length. Returns {:ok, code} or :error.

Never zero-pads or integer-coerces: "4217" for a 6-digit flow is rejected, not turned into "004217", and 4217 (integer) is rejected too.

valid?(submitted, stored_hash, digits \\ 6)

@spec valid?(term(), binary(), digits()) :: boolean()

Constant-time check of a submitted code against a stored SHA-256 hash. The submitted code is normalized to digits first; malformed input returns false.