hanki

sha2

stdlib/extra/sha2.hk: SHA-256, HMAC-SHA-256, and constant-time comparison.

The cryptographic hash the supply-chain surfaces need. core/hash is a different thing entirely: it is structural hashing for Map keys, unkeyed and cheap by design. A value that must resist an adversary choosing it, a package's content pin or a checksum log entry or anything signed, hashes here instead.

Named for the family and not the algorithm: SHA-512 is the same construction over 64-bit words and belongs in this module when it arrives. digest and checksum were the alternatives; digest reads like a trait this module does not define, and checksum names the non-cryptographic class (fnv1a, CRC) that this exists to replace.

Pure Hanki on both tiers, no seam: SHA-256 needs only u32 arithmetic, whose bare operators wrap, which is the mod-2^32 the spec asks for, and the total bitwise methods. Every entry point is @encapsulated, and the builder it writes into is allocated and finished inside the call with nothing mutable escaping; that leaves all of them callable from meta and from ordinary pure code. Render a digest with hex.encode or base64.encode.

The message is never materialised with its padding attached. _padded_byte answers for any index of the virtual padded message, and hashing a large input allocates nothing beyond the block being read.

Know the throughput before reaching for this on a large input. Measured on the dev box over 64 KiB, hash-only: about 56 KiB/s on the bytecode tier and about 4.8 MiB/s on the AOT tier. The hundredfold gap comes from the round being a few hundred u32 bit operations, where the bytecode tier pays interpreter dispatch on each one and the AOT tier lowers them to one or two machine instructions. A script run with hanki run therefore hashes a megabyte in about twenty seconds, which is long enough to be the wrong tool: hash a few KB there, and compile the program when the input is a file tree. This is a cost and no defect, and no fix is promised. The number has been re-measured across three sessions and lands in the same place, and what would move it is a contiguous unboxed array and nothing in this module.

_h0

_h0: List<u32> = [
  1779033703u32,
  3144134277u32,
  1013904242u32,
  2773480762u32,
  1359893119u32,
  2600822924u32,
  528734635u32,
  1541459225u32
]

Initial hash state: the first 32 bits of the fractional parts of the square roots of the first eight primes (FIPS 180-4 §5.3.3).

_k

_k: List<u32> = [
  1116352408u32,
  1899447441u32,
  3049323471u32,
  3921009573u32,
  961987163u32,
  1508970993u32,
  2453635748u32,
  2870763221u32,
  3624381080u32,
  310598401u32,
  607225278u32,
  1426881987u32,
  1925078388u32,
  2162078206u32,
  2614888103u32,
  3248222580u32,
  3835390401u32,
  4022224774u32,
  264347078u32,
  604807628u32,
  770255983u32,
  1249150122u32,
  1555081692u32,
  1996064986u32,
  2554220882u32,
  2821834349u32,
  2952996808u32,
  3210313671u32,
  3336571891u32,
  3584528711u32,
  113926993u32,
  338241895u32,
  666307205u32,
  773529912u32,
  1294757372u32,
  1396182291u32,
  1695183700u32,
  1986661051u32,
  2177026350u32,
  2456956037u32,
  2730485921u32,
  2820302411u32,
  3259730800u32,
  3345764771u32,
  3516065817u32,
  3600352804u32,
  4094571909u32,
  275423344u32,
  430227734u32,
  506948616u32,
  659060556u32,
  883997877u32,
  958139571u32,
  1322822218u32,
  1537002063u32,
  1747873779u32,
  1955562222u32,
  2024104815u32,
  2227730452u32,
  2361852424u32,
  2428436474u32,
  2756734187u32,
  3204031479u32,
  3329325298u32
]

Round constants: the first 32 bits of the fractional parts of the cube roots of the first sixty-four primes (FIPS 180-4 §4.2.2).

_block

_block: int = 64

The block size the compression function consumes, and what HMAC pads its key to. The digest size is not a constant here: it falls out of the eight-word state that _state_bytes renders.

_rotr

def _rotr(x: u32, n: u32) -> u32

Rotate right. bit_shr/bit_shl take their count mod the word width, so this is total for every n: at n = 0 both halves return x and the or is idempotent, which is the identity rotate and no accident.

_ch

def _ch(x: u32, y: u32, z: u32) -> u32

_maj

def _maj(x: u32, y: u32, z: u32) -> u32

bigsigma0

def _big_sigma0(x: u32) -> u32

bigsigma1

def _big_sigma1(x: u32) -> u32

smallsigma0

def _small_sigma0(x: u32) -> u32

smallsigma1

def _small_sigma1(x: u32) -> u32

_at

def _at(xs: List<u32>, i: int) -> u32

A List<u32> read that cannot fail into a wrong answer: every index this module passes is in range already, and zero is the identity for the xor/add the callers do, which lets an impossible miss degrade in place of needing a Crash row on a pure function.

paddedbyte

def _padded_byte(input: bytes, i: int, total: int) -> u8

The byte at i of the virtual padded message: the input, then 0x80, then zeros, then the 64-bit big-endian bit length in the final eight bytes (FIPS 180-4 §5.1.1). total is the padded length, and the length field starts at total - 8.

paddedword

def _padded_word(input: bytes, pos: int, total: int) -> u32

The big-endian u32 at byte offset pos of the virtual padded message.

paddedlength

def _padded_length(n: int) -> int

The padded length for an n-byte message: n, one 0x80, then zeros up to a multiple of the block with eight bytes left for the length field.

compressblock

def _compress_block(acc: List<u32>, input: bytes, off: int, total: int) -> List<u32>

One block: the message schedule (FIPS 180-4 §6.2.2 step 1) and the sixty-four compression rounds (steps 2-4) together, then the result added into the incoming state. Every + here is u32 addition, which wraps; that is the specified mod-2^32.

The schedule is a rolling window of sixteen locals and no 64-word List, and that is what makes these two steps share a function. w0 is W[t] at the top of round t and w15 is W[t+15], which leaves the recurrence for W[t+16] reading w14, w9, w1 and w0 alone. The window is shifted down by one at the end of each round and the new word enters at w15. A List schedule instead costs a trie walk per read and an allocation per append, and the loop does four reads and one append per word: on the bytecode tier that is a dispatched intrinsic each time, and on AOT it defeats the register allocation the bit ops already get.

The last sixteen rounds compute no new word: W[63] is the final one needed, and past t = 47 the recurrence would be building words nobody reads.

statebytes

def _state_bytes(acc: List<u32>) -> bytes

The eight-word state rendered big-endian into the 32-byte digest.

digest

def digest(input: bytes) -> bytes

The SHA-256 digest of input: 32 bytes, big-endian.

Render it for display with hex.encode or base64.encode: the digest itself is bytes and no text.

digest("abc".to_bytes()).length => 32
hex.encode(digest("".to_bytes())).slice(0, 16) => "e3b0c44298fc1c14"

blockkey

def _block_key(key: bytes) -> bytes

key reduced to one block, as HMAC specifies (RFC 2104 §2): a key longer than the block is replaced by its own digest, and any key shorter than the block is zero-padded up to it.

xorpad

def _xor_pad(block_key: bytes, pad: u8) -> bytes

key xored with a repeated pad byte: the ipad/opad halves of HMAC.

hmac

def hmac(key: bytes, message: bytes) -> bytes

HMAC-SHA-256 of message under key (RFC 2104): 32 bytes.

Verify a received tag with constant_time_eq? and never ==; see its note.

hmac("key".to_bytes(), "msg".to_bytes()).length => 32
hex.encode(hmac("".to_bytes(), "".to_bytes())).slice(0, 16) => "b613679a0814d9ec"

constanttimeeq?

def constant_time_eq?(a: bytes, b: bytes) -> bool

Whether a and b are equal, in time that does not depend on where they first differ.

The comparison every verification path must use. An ordinary == on bytes stops at the first differing byte, and how long it takes tells an attacker how much of a guessed tag or digest was right, and a tag can be recovered byte by byte from that alone. This reads every byte of both, always.

Length is not secret and is not hidden: a length mismatch answers false immediately: a digest's or tag's length is a public property of the algorithm and no property of the secret.

constant_time_eq?("abc".to_bytes(), "abc".to_bytes()) => true
constant_time_eq?("abc".to_bytes(), "abd".to_bytes()) => false
constant_time_eq?("abc".to_bytes(), "ab".to_bytes()) => false