Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- **`yabase/base16.decode_strict/1`**: canonical-form check for hex
digests, mirroring the existing `decode_strict` on `base32/rfc4648`
and `base64/standard`. Returns `Error(NonCanonical)` for input
that decodes successfully but is not byte-equal to `encode/1`'s
output — i.e. lowercase (`encode_lowercase/1`'s output), mixed
case, or any other deviation from the RFC 4648 §8 canonical
uppercase form. Useful for HMAC/TOTP/WebAuthn/content-addressable
storage workflows where the encoded string itself is part of the
contract. Other failure modes (`InvalidCharacter`,
`InvalidLength`) surface unchanged from `decode/1`. Closes #87.

### Documentation

- **`yabase/intid` negative-input handling**: every `encode_int_*`
Expand Down
28 changes: 27 additions & 1 deletion src/yabase/base16.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import gleam/bit_array
import gleam/int
import gleam/list
import gleam/string
import yabase/core/error.{type CodecError, InvalidCharacter, InvalidLength}
import yabase/core/error.{
type CodecError, InvalidCharacter, InvalidLength, NonCanonical,
}
import yabase/core/guard

/// Encode a BitArray to an uppercase hexadecimal string per
Expand Down Expand Up @@ -61,6 +63,30 @@ pub fn decode(input: String) -> Result(BitArray, CodecError) {
}
}

/// Decode `input` and additionally reject non-canonical encodings
/// per RFC 4648 §8: the canonical Base 16 form is uppercase
/// (`0-9 A-F`). Useful for HMAC / TOTP / WebAuthn / content-
/// addressable storage and any signature-verification context where
/// the encoded string itself is part of the contract — strict mode
/// rejects the lowercase form (`encode_lowercase/1`'s output) as
/// non-canonical even though it decodes to the same bytes through
/// the lenient `decode/1`.
///
/// Returns `Error(NonCanonical)` when the input is not byte-equal
/// to `encode(decode(input))`. Other failure modes
/// (`InvalidCharacter`, `InvalidLength`) are surfaced unchanged from
/// `decode/1`.
pub fn decode_strict(input: String) -> Result(BitArray, CodecError) {
case decode(input) {
Error(e) -> Error(e)
Ok(bytes) ->
case encode(bytes) == input {
True -> Ok(bytes)
False -> Error(NonCanonical)
}
}
}

fn decode_pairs(
input: String,
acc: BitArray,
Expand Down
42 changes: 41 additions & 1 deletion test/base16_test.gleam
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import yabase/base16
import yabase/core/error.{InvalidCharacter, InvalidLength}
import yabase/core/error.{InvalidCharacter, InvalidLength, NonCanonical}

// --- Fixed vectors ---

Expand Down Expand Up @@ -113,3 +113,43 @@ pub fn roundtrip_high_bits_test() -> Nil {
let data = <<0xff, 0xfe, 0xfd, 0x80, 0x00>>
assert base16.decode(base16.encode(data)) == Ok(data)
}

// ===== decode_strict (RFC 4648 §8 canonical-uppercase check) =====

pub fn decode_strict_canonical_uppercase_passes_test() -> Nil {
// Canonical RFC 4648 §8 form is uppercase A-F.
assert base16.decode_strict("DEADBEEF") == Ok(<<0xde, 0xad, 0xbe, 0xef>>)
}

pub fn decode_strict_lowercase_rejected_test() -> Nil {
// Lowercase is the opt-in non-canonical form (encode_lowercase).
// The strict path rejects it even though decode/1 accepts it.
assert base16.decode_strict("deadbeef") == Error(NonCanonical)
}

pub fn decode_strict_mixed_case_rejected_test() -> Nil {
// Mixed case is non-canonical.
assert base16.decode_strict("DeAdBeEf") == Error(NonCanonical)
}

pub fn decode_strict_propagates_invalid_character_test() -> Nil {
// Non-hex characters surface unchanged from decode/1 — strict
// does not mask other failure modes.
assert case base16.decode_strict("Z0") {
Error(InvalidCharacter(_, _)) -> True
_ -> False
}
}

pub fn decode_strict_propagates_invalid_length_test() -> Nil {
// Odd-length input surfaces InvalidLength from decode/1.
assert case base16.decode_strict("ABC") {
Error(InvalidLength(_)) -> True
_ -> False
}
}

pub fn decode_strict_empty_passes_test() -> Nil {
// Empty input encodes to empty string — that is canonical.
assert base16.decode_strict("") == Ok(<<>>)
}