hardware-rust-crypto 1.0.1

Hardware-only AES-256-GCM and AES-256-GCM-SIV AEAD plus AES-256-CTR key/nonce generation (AES-NI/PCLMULQDQ, ARMv8 AES/PMULL), with compact, caller-placeable, zeroized key state.
Documentation
#!/usr/bin/env python3
"""Proof: the AEAD composition glue matches SP 800-38D / RFC 8452, for ALL inputs.

The field-arithmetic proofs (prove_multiply / prove_aggregation /
prove_ghash_identity / prove_ghash_polyval_mapping) establish that the AES-GCM
authenticator computes GHASH/POLYVAL correctly. This file proves the *other*
half of the construction - the byte plumbing that wires AES and the
authenticator into an AEAD: the J0 / counter-block construction, the CTR counter
increments, the SIV key-derivation and tag layout, and that decryption inverts
encryption and accepts genuine ciphertexts.

That plumbing is intrinsic-free (plain byte/integer code), so unlike the
arithmetic backends it can be reasoned about directly by an SMT solver. AES and
POLYVAL are modeled as *uninterpreted functions*: the proof shows the wiring is
exactly the specification GIVEN a correct block cipher and a correct
authenticator, which the other proofs and the FIPS-197 / CAVP / RFC known-answer
tests supply. Each model below mirrors, line for line, the named function in
src/aes_gcm/{mod,siv}.rs (cited inline); the real code additionally passes the
NIST CAVP (GCM) and RFC 8452 Appendix C.2 (SIV) end-to-end known-answer vectors,
which anchors these models to the shipped bytes.

Blocks are 128-bit values with byte 0 the most-significant byte (natural hex
order), so byte i is bits [127-8i : 120-8i].

Run: python3 proofs/prove_composition.py   (requires z3-solver)
"""

import sys

try:
    from z3 import (
        BitVec, BitVecSort, BitVecVal, Concat, Extract, Function, If,
        Solver, prove, sat, unsat,
    )
except ImportError:
    print("z3 not installed: pip install z3-solver", file=sys.stderr)
    sys.exit(2)

B = BitVecSort(128)


def byte(b, i):
    """Byte i of a 128-bit block (byte 0 = most significant)."""
    return Extract(127 - 8 * i, 120 - 8 * i, b)


def bytes_be_u32(b3, b2, b1, b0):
    """u32 from four bytes, most-significant first (from_be_bytes order)."""
    return Concat(b3, b2, b1, b0)


def check(name, lhs, rhs):
    """Assert lhs == rhs is valid (true for all free variables)."""
    s = Solver()
    s.add(lhs != rhs)
    r = s.check()
    if r == unsat:
        print(f"  PROVED  {name}")
        return True
    print(f"  FAILED  {name}: counterexample {s.model() if r == sat else r}")
    return False


def check_bool(name, claim):
    s = Solver()
    s.add(claim == False)  # noqa: E712  (z3 BoolRef)
    r = s.check()
    if r == unsat:
        print(f"  PROVED  {name}")
        return True
    print(f"  FAILED  {name}: {s.model() if r == sat else r}")
    return False


# ---------------------------------------------------------------------------
# 1. GCM counter increment (src/aes_gcm/mod.rs::increment_counter).
#    Code: low = u32::from_be_bytes(b[12..16]); low+=1; b[12..16] = low.to_be_bytes().
#    Spec (SP 800-38D inc_32): increment the rightmost 32 bits as a big-endian
#    integer, mod 2^32; the leading 96 bits are unchanged.
# ---------------------------------------------------------------------------

def gcm_increment_counter(b):
    # Faithful model of the byte ops: read bytes 12..15 big-endian, +1, write back.
    low = bytes_be_u32(byte(b, 12), byte(b, 13), byte(b, 14), byte(b, 15))
    low = low + BitVecVal(1, 32)
    new = bytes_be_u32(
        Extract(31, 24, low), Extract(23, 16, low),
        Extract(15, 8, low), Extract(7, 0, low),
    )
    return Concat(Extract(127, 32, b), new)


def gcm_inc32_spec(b):
    # SP 800-38D inc_32: low 32 bits += 1 (big-endian == the integer Extract(31,0)).
    return Concat(Extract(127, 32, b), Extract(31, 0, b) + BitVecVal(1, 32))


# ---------------------------------------------------------------------------
# 2. SIV counter increment (src/aes_gcm/siv.rs::increment_siv_counter).
#    Code: low = u32::from_le_bytes(b[0..4]); next = low+1; b[0..4] = next.to_le_bytes().
#    Spec (RFC 8452 s4 CTR): the first 32 bits are a little-endian counter,
#    incremented mod 2^32; bytes 4..16 are unchanged.
# ---------------------------------------------------------------------------

def siv_increment_counter(b):
    # from_le_bytes(b0,b1,b2,b3) = b0 | b1<<8 | b2<<16 | b3<<24.
    low = bytes_be_u32(byte(b, 3), byte(b, 2), byte(b, 1), byte(b, 0))
    low = low + BitVecVal(1, 32)
    # to_le_bytes writes the LE bytes back into positions 0,1,2,3.
    nb0 = Extract(7, 0, low)
    nb1 = Extract(15, 8, low)
    nb2 = Extract(23, 16, low)
    nb3 = Extract(31, 24, low)
    return Concat(nb0, nb1, nb2, nb3, Extract(95, 0, b))


def siv_inc_spec(b):
    # Little-endian 32-bit increment of bytes 0..4; bytes 4..16 fixed.
    le = bytes_be_u32(byte(b, 3), byte(b, 2), byte(b, 1), byte(b, 0)) + BitVecVal(1, 32)
    nb0 = Extract(7, 0, le)
    nb1 = Extract(15, 8, le)
    nb2 = Extract(23, 16, le)
    nb3 = Extract(31, 24, le)
    return Concat(nb0, nb1, nb2, nb3, Extract(95, 0, b))


# ---------------------------------------------------------------------------
# 3. GCM J0 (src/aes_gcm/mod.rs::j0).  Code: out[..12]=nonce; out[15]=1; rest 0.
#    Spec (SP 800-38D, 96-bit IV): J0 = IV || 0^31 || 1.
# ---------------------------------------------------------------------------

def gcm_j0(nonce96):
    # nonce96 is a 96-bit value; J0 = nonce || 0x00000001.
    return Concat(nonce96, BitVecVal(1, 32))


# ---------------------------------------------------------------------------
# 4. SIV key-derivation input blocks (src/aes_gcm/siv.rs::derive_keys).
#    Code: input[4..]=nonce; input[..4]=counter.to_le_bytes(); block=E(input);
#    take block[..8]. Counters 0,1 -> auth key; 2,3,4,5 -> enc key.
#    Spec (RFC 8452 s4 DeriveKeys): AES(K, LE32(i) || nonce), take the low 8 bytes,
#    i = 0,1 for the message-auth key and 2..5 for the message-encryption key.
# ---------------------------------------------------------------------------

def siv_derive_input(counter_u32_le_bytes, nonce96):
    # counter_u32_le_bytes: the 32-bit LE encoding placed in bytes 0..4.
    return Concat(counter_u32_le_bytes, nonce96)


def le32(i):
    v = BitVecVal(i, 32)
    # little-endian byte order placed in bytes 0..3 (byte 0 most significant slot)
    return Concat(Extract(7, 0, v), Extract(15, 8, v), Extract(23, 16, v), Extract(31, 24, v))


# ---------------------------------------------------------------------------
# 5/6. SIV tag + CTR counter init (src/aes_gcm/siv.rs::siv_tag, siv_seal).
#    tag = E_enc( (digest with low 12 bytes XOR nonce) with bit 0x80 of byte 15
#    cleared ).  CTR counter init = tag with bit 0x80 of byte 15 set.
#    Spec (RFC 8452 s4): the same construction.
# ---------------------------------------------------------------------------

E_enc = Function("AES_enc", B, B)        # message-encryption cipher (uninterpreted)


def siv_tag_model(nonce96, digest):
    # XOR nonce into the low 12 bytes (bytes 0..12 == bits 127..32).
    hi = Extract(127, 32, digest) ^ nonce96
    masked = Concat(hi, Extract(31, 0, digest))
    # clear bit 0x80 of the last byte (byte 15 == bits 7..0): AND byte15 with 0x7f.
    cleared = Concat(Extract(127, 8, masked), Extract(7, 0, masked) & BitVecVal(0x7F, 8))
    return E_enc(cleared)


def siv_counter_init(tag):
    # set bit 0x80 of the last byte.
    return Concat(Extract(127, 8, tag), Extract(7, 0, tag) | BitVecVal(0x80, 8))


def main():
    ok = True
    b = BitVec("b", 128)
    nonce96 = BitVec("nonce", 96)
    digest = BitVec("digest", 128)

    print("1. Counter increments match their specs (all 2^128 blocks) ...")
    ok &= check("GCM increment_counter == SP 800-38D inc_32",
                gcm_increment_counter(b), gcm_inc32_spec(b))
    ok &= check("SIV increment_siv_counter == RFC 8452 LE32 increment",
                siv_increment_counter(b), siv_inc_spec(b))
    # The increments touch ONLY their 4 counter bytes; the rest is invariant.
    ok &= check("GCM increment leaves the leading 96 bits unchanged",
                Extract(127, 32, gcm_increment_counter(b)), Extract(127, 32, b))
    ok &= check("SIV increment leaves the trailing 96 bits unchanged",
                Extract(95, 0, siv_increment_counter(b)), Extract(95, 0, b))

    print("\n2. Block-construction layouts match the specs (all inputs) ...")
    ok &= check("GCM J0 == IV || 0^31 || 1",
                gcm_j0(nonce96), Concat(nonce96, BitVecVal(1, 32)))
    # The SIV derive input for counter i is LE32(i) || nonce, low 4 bytes the LE
    # counter and the rest the nonce.
    for i in (0, 1, 2, 3, 4, 5):
        blk = siv_derive_input(le32(i), nonce96)
        ok &= check(f"SIV derive input[{i}] low 4 bytes == LE32({i})",
                    Extract(127, 96, blk), le32(i))
        ok &= check(f"SIV derive input[{i}] high 12 bytes == nonce",
                    Extract(95, 0, blk), nonce96)

    print("\n3. SIV tag construction and CTR init (RFC 8452 s4) ...")
    # The tag's pre-image clears bit 0x80 of byte 15; the CTR init sets it. So the
    # CTR counter and the AES tag pre-image agree on every bit except that flag,
    # and the flag is 0 in the pre-image and 1 in the counter - exactly RFC 8452.
    tag = siv_tag_model(nonce96, digest)
    init = siv_counter_init(tag)
    ok &= check("SIV CTR init sets bit 0x80 of the last byte of the tag",
                Extract(7, 0, init), Extract(7, 0, tag) | BitVecVal(0x80, 8))
    ok &= check("SIV CTR init leaves all other bytes equal to the tag",
                Extract(127, 8, init), Extract(127, 8, tag))

    print("\n4. Decryption inverts encryption and accepts genuine ciphertext ...")
    # Each path's keystream and tag are derived INDEPENDENTLY from the public
    # inputs via the modeled byte functions - so a wiring divergence (e.g. open
    # forgetting the counter increment, or masking with the wrong block) would
    # show up as a failed proof, not be assumed away. One symbolic message block
    # is modeled; CTR blocks are independent, so this captures the per-block
    # round-trip (multi-block is covered by prove_ghash_identity's Horner lift and
    # the dense differential KATs).
    GHASH = Function("GHASH", B, B)          # authenticator over the ciphertext
    E_gcm = Function("AES_gcm", B, B)        # GCM block cipher (uninterpreted)
    p = BitVec("p", 128)

    # --- GCM: seal then open, each modeled from src/aes_gcm/mod.rs ---
    # seal (mod.rs:291-320): counter=j0(nonce); increment; ks=E(counter);
    #                        c = p ^ ks; tag = GHASH(c) ^ E(j0).
    j0_seal = gcm_j0(nonce96)
    ks_seal = E_gcm(gcm_increment_counter(j0_seal))
    c_gcm = p ^ ks_seal
    tag_seal_gcm = GHASH(c_gcm) ^ E_gcm(j0_seal)
    # open (mod.rs:457-485): counter=j0(nonce); increment; p'=c ^ E(counter);
    #                        tag'=GHASH(c) ^ E(j0). Derived independently.
    j0_open = gcm_j0(nonce96)
    ks_open = E_gcm(gcm_increment_counter(j0_open))
    p_rec_gcm = c_gcm ^ ks_open
    tag_open_gcm = GHASH(c_gcm) ^ E_gcm(j0_open)
    ok &= check("GCM open recovers the plaintext block", p_rec_gcm, p)
    ok &= check("GCM open re-derives the sealed tag (genuine ciphertext accepts)",
                tag_open_gcm, tag_seal_gcm)

    # --- SIV: seal then open, each modeled from src/aes_gcm/siv.rs ---
    # The digest is POLYVAL over the message; the tag is siv_tag(nonce, digest);
    # the CTR counter init is siv_counter_init(tag). seal (siv.rs:211-235) and
    # open (siv.rs:241-268) derive both independently from the (genuine) tag.
    POLY = Function("POLYVAL_digest", B, B)
    tag_siv = siv_tag_model(nonce96, POLY(p))
    ks_siv_seal = E_enc(siv_counter_init(tag_siv))
    c_siv = p ^ ks_siv_seal
    # open: same received tag -> same counter init -> same keystream -> recover p,
    # then recompute the tag over the recovered plaintext.
    ks_siv_open = E_enc(siv_counter_init(tag_siv))
    p_rec_siv = c_siv ^ ks_siv_open
    tag_recomputed_siv = siv_tag_model(nonce96, POLY(p_rec_siv))
    ok &= check("SIV open recovers the plaintext block", p_rec_siv, p)
    ok &= check("SIV open re-derives the sealed tag (genuine ciphertext accepts)",
                tag_recomputed_siv, tag_siv)

    print("\n5. Non-vacuity: the harness rejects a deliberately broken wiring ...")
    # If `open` skipped the counter increment, its keystream would be E(j0) rather
    # than E(inc(j0)), and decryption would NOT recover the plaintext. The solver
    # must FIND a counterexample (the property is not valid) - confirming the
    # round-trip proofs above have teeth and are not vacuously true.
    broken_ks = E_gcm(j0_open)                 # bug: forgot increment_counter
    broken_rec = c_gcm ^ broken_ks
    s = Solver()
    s.add(broken_rec != p)
    found = s.check() == sat
    if found:
        print("  PROVED  a missing-counter-increment open is detected (counterexample exists)")
    else:
        print("  FAILED  broken wiring was NOT detected - the harness is vacuous")
    ok &= found

    if ok:
        print("\nPROVED for all inputs: the GCM and SIV composition glue (J0/counter")
        print("construction, counter increments, SIV key-derivation and tag layout,")
        print("and CTR-inverts-CTR round-trip) matches SP 800-38D / RFC 8452, given a")
        print("correct AES and authenticator (supplied by the field proofs and the")
        print("FIPS-197 / CAVP / RFC 8452 known-answer suites).")
        return 0
    print("\nFAILED")
    return 1


if __name__ == "__main__":
    sys.exit(main())