iyi

Standard library, 0.18.0

std/otp

One-time passwords, written in iyi: HOTP (RFC 4226), TOTP (RFC 6238), and the otpauth:// key URI an authenticator app reads from a QR code (Google Authenticator's Key Uri Format, which is the de facto spec: https://github.com/google/google-authenticator/wiki/Key-Uri-Format).

import std/otp to use it. Source: src/std/otp.iyi at v0.18.0.

  import std/otp::{HOTP, TOTP, OTPAuth, OTPAlgorithm, OTPError}
  HOTP.code(secret, 1_i64)                       # "287082"
  TOTP.code(secret, 59_i64, 30, 8)               # "94287082"
  TOTP.now(secret)                               # by the clock
  TOTP.verify(secret, "287082", unix_time)       # true or false
  OTPAuth.totp(secret, "[email protected]", "Example").to_s
  OTPAuth.parse(uri)                             # an OTPAuth or an OTPError

A secret is an Array(UInt8), never a String: a secret is handed around as base32 text, and a String here could be read as that text or as its bytes, so a key typed into the wrong one would make codes that match nothing and say nothing. Base32.decode (std/base32) turns the text into the bytes, and OTPAuth.parse does it for a URI's secret.

HOTP is RFC 4226 section 5.3: HMAC of the counter's eight bytes, big-endian, under the secret; four bytes at the offset the last byte's low nibble names ("dynamic truncation"), their top bit cleared; that number modulo 10^digits, written with its leading zeros. TOTP is HOTP with the counter the Unix time divided by the step, T0 = 0 (RFC 6238 section 4). Both take SHA-1, SHA-256 or SHA-512 (OTPAlgorithm), the three RFC 6238 names, on std/crypto's HMAC. Digits run from 6, RFC 4226's floor (R-4), to 10, past which the 31-bit number has no more to give; authenticator apps read 6 and 8.

TOTP.verify takes the code from the steps window either side of unix_time as well, RFC 6238 section 5.2's allowance for a clock that is off and a user who is slow; the default is one step each way, the most that section recommends. Every candidate in the window is computed and compared with std/crypto's Subtle.constant_time_compare, all of them, so the time a guess takes does not say whether it was near. What it does not do is remember: section 5.2 says a code once accepted must not be accepted again, and that needs the last step a user was let in at, which is the caller's to keep.

The clock, and why this module imports std/time: TOTP.now is what a server calls, every time, and a TOTP module that cannot say what the code is now is half of one. So std/otp reaches std/time, and through it the wall clock and the environment (TZ, ZONEINFO), as std/cookie does for the same reason; iyi mod reach counts that for every program importing this. Everything else is a pure function of its arguments: HOTP.code of a counter, TOTP.code and TOTP.verify of an explicit Unix time, which is how the RFCs' vectors are checked and how a program with a clock of its own (a test, a replay) calls them; TOTP.now is the one call that reads the clock. Splitting now into a module of its own would make a module and a gate for a line. std/base32 is the split that matters: it is a codec other code needs without any of this, and it reaches nothing.

OTPAuth builds and reads otpauth://totp/Issuer:account?secret=.... Built: the label is the issuer, a colon and the account, each percent-encoded (everything but RFC 3986's unreserved characters), and the parameters are secret (base32, upper case, no padding, as the format asks), issuer, counter for HOTP, then algorithm, digits and period only when they are not the defaults SHA1, 6 and 30, so the common URI is the short one Google's own example shows. Read: the scheme and type in any case; %XX escapes in the label and values, + as a space in values; the account after the label's first colon (written or %3A), leading spaces dropped; parameters the format does not name (image) passed over. An issuer in the label and one in the parameters must agree.

Refused with a sentence, as misuse: an empty secret, digits outside 6 to 10, a step or period below one, a negative counter or Unix time, a negative window, a colon in an issuer or account being built (the format gives a colon no escape). Refused as an OTPError, because a URI is data: anything else parse cannot read, each naming what.

HOTPstruct

std/otpiyi doc src/std/otp.iyi
pub struct HOTP
  def self.code(secret : Array(UInt8), counter : Int64, digits : Int32 = 6, algorithm : OTPAlgorithm = OTPAlgorithm::SHA1) : String
  def initialize
end

OTPAlgorithmenum

The HMAC hash, by the name RFC 6238 and the key URI format give it.

std/otpiyi doc src/std/otp.iyi
pub enum OTPAlgorithm : Int32
  SHA1 = 0
  SHA256 = 1
  SHA512 = 2
end

OTPAuthstruct

A key URI: what an authenticator app needs to make the codes a server will check. counter is nil for TOTP and the next counter for HOTP; period is TOTP's step and 30, unused, for HOTP.

std/otpiyi doc src/std/otp.iyi
pub struct OTPAuth
  def account : String
  def algorithm : OTPAlgorithm
  def counter : Int64 | ::Nil
  def digits : Int32
  def self.hotp(secret : Array(UInt8), account : String, counter : Int64, issuer : String = "", algorithm : OTPAlgorithm = OTPAlgorithm::SHA1, digits : Int32 = 6) : OTPAuth
  def hotp? : Bool
  def initialize(secret : Array(UInt8), account : String, issuer : String, algorithm : OTPAlgorithm, digits : Int32, period : Int32, counter : Int64 | ::Nil)
  def issuer : String
  def self.parse(uri : String) : OTPAuth | OTPError
  def period : Int32
  def secret : Array(UInt8)
  def to_s : String
  # An empty *issuer* is none: the label is the account alone and no
  # `issuer` parameter is written.
  def self.totp(secret : Array(UInt8), account : String, issuer : String = "", algorithm : OTPAlgorithm = OTPAlgorithm::SHA1, digits : Int32 = 6, period : Int32 = 30) : OTPAuth
end

OTPErrorstruct

What OTPAuth.parse answers for a URI it cannot read.

std/otpiyi doc src/std/otp.iyi
pub struct OTPError
  def initialize(message : String)
  def to_s : String
end

TOTPstruct

std/otpiyi doc src/std/otp.iyi
pub struct TOTP
  def self.code(secret : Array(UInt8), unix_time : Int64, step : Int32 = 30, digits : Int32 = 6, algorithm : OTPAlgorithm = OTPAlgorithm::SHA1) : String
  def initialize
  # The code for this second by the wall clock.
  def self.now(secret : Array(UInt8), step : Int32 = 30, digits : Int32 = 6, algorithm : OTPAlgorithm = OTPAlgorithm::SHA1) : String
  # Whether *code* is the code for *unix_time*'s step or one of the
  # *window* steps either side of it. A code of the wrong length is false
  # at once; the length of the right one is not a secret.
  def self.verify(secret : Array(UInt8), code : String, unix_time : Int64, window : Int32 = 1, step : Int32 = 30, digits : Int32 = 6, algorithm : OTPAlgorithm = OTPAlgorithm::SHA1) : Bool
end

Error for OTPErrorimpl

std/otpiyi doc src/std/otp.iyi
impl Error for OTPError
  def message : String
end