fix: update hash format validation to -01 spec (plain base64url, no prefix)

Go ValidateHashFormat was still validating the old -00 format
(algorithm:base64url with sha-256/sha-384/sha-512 prefix). Updated to
validate plain base64url without prefix per -01 spec and RFC 9449.
Python was already updated but uncommitted. Both refimpls now match.
This commit is contained in:
2026-04-11 17:51:29 +02:00
parent 884d2dc836
commit ba38569319
5 changed files with 38 additions and 46 deletions

View File

@@ -50,7 +50,7 @@ Suggestions that could make the implementations more robust, spec-strict, or pro
## 5. **Nice-to-have** ✅ ## 5. **Nice-to-have** ✅
- **inp_hash / out_hash format** - **inp_hash / out_hash format**
**Done.** Optional check in create and verify: `algorithm:base64url` with algorithm in allowlist (sha-256, sha-384, sha-512). Helpers: `ValidateHashFormat` / `validate_hash_format`. **Done.** Optional check in create and verify: plain base64url without algorithm prefix, per -01 spec and RFC 9449. Helpers: `ValidateHashFormat` / `validate_hash_format`.
- **Constant-time comparison** - **Constant-time comparison**
**Done.** **Go:** `crypto/subtle.ConstantTimeCompare` for `typ` in verify. **Python:** `hmac.compare_digest` for `typ`. **Done.** **Go:** `crypto/subtle.ConstantTimeCompare` for `typ` in verify. **Python:** `hmac.compare_digest` for `typ`.
@@ -72,4 +72,4 @@ The refimpl was built against draft-nennemann-wimse-ect-00. The -01 draft introd
- **Update `MaxParLength` naming**: ✅ **Done.** Renamed to `MaxPredLength` / `max_pred_length` everywhere. - **Update `MaxParLength` naming**: ✅ **Done.** Renamed to `MaxPredLength` / `max_pred_length` everywhere.
- **Add L1 support**: The -01 draft introduces unsigned JSON ECTs (Level 1). The refimpl currently only supports L2 (signed JWS). - **Add L1 support**: The -01 draft introduces unsigned JSON ECTs (Level 1). The refimpl currently only supports L2 (signed JWS).
- **Add L3 support**: The -01 draft introduces audit ledger requirements for Level 3. The existing in-memory ledger needs hash chain and receipt support. - **Add L3 support**: The -01 draft introduces audit ledger requirements for Level 3. The existing in-memory ledger needs hash chain and receipt support.
- **Update hash format**: The -01 draft specifies SHA-256 base64url without algorithm prefix (no `sha-256:` prefix), consistent with RFC 9449. - **Update hash format**: **Done.** Both Go and Python validate plain base64url without algorithm prefix, consistent with -01 spec and RFC 9449.

View File

@@ -23,5 +23,5 @@ var (
ErrInvalidJTI = errors.New("ect: jti must be UUID format") ErrInvalidJTI = errors.New("ect: jti must be UUID format")
ErrInvalidWID = errors.New("ect: wid must be UUID format when set") ErrInvalidWID = errors.New("ect: wid must be UUID format when set")
ErrPredLength = errors.New("ect: pred exceeds max length") ErrPredLength = errors.New("ect: pred exceeds max length")
ErrHashFormat = errors.New("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") ErrHashFormat = errors.New("ect: inp_hash/out_hash must be plain base64url (no prefix)")
) )

View File

@@ -4,7 +4,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"regexp" "regexp"
"strings"
) )
// ExtMaxSize is the recommended max serialized size of ext (Section 4.2.7). // ExtMaxSize is the recommended max serialized size of ext (Section 4.2.7).
@@ -19,8 +18,8 @@ const DefaultMaxPredLength = 100
// uuidRegex matches RFC 9562 UUID: 8-4-4-4-12 hex. // uuidRegex matches RFC 9562 UUID: 8-4-4-4-12 hex.
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// allowedHashAlgs are the spec-recommended hash algorithm prefixes for inp_hash/out_hash. // base64urlRegex matches a non-empty base64url string without padding.
var allowedHashAlgs = map[string]bool{"sha-256": true, "sha-384": true, "sha-512": true} var base64urlRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth. // ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth.
func ValidateExt(ext map[string]interface{}) error { func ValidateExt(ext map[string]interface{}) error {
@@ -61,23 +60,18 @@ func ValidUUID(s string) bool {
return uuidRegex.MatchString(s) return uuidRegex.MatchString(s)
} }
// ValidateHashFormat returns nil if s is empty or matches "algorithm:base64url" (sha-256, sha-384, sha-512). // ValidateHashFormat returns nil if s is empty or is plain base64url (no padding)
// per draft-nennemann-wimse-ect-01 and RFC 9449 (no algorithm prefix).
func ValidateHashFormat(s string) error { func ValidateHashFormat(s string) error {
if s == "" { if s == "" {
return nil return nil
} }
idx := strings.Index(s, ":") if !base64urlRegex.MatchString(s) {
if idx <= 0 {
return ErrHashFormat return ErrHashFormat
} }
alg := strings.ToLower(s[:idx]) _, err := base64.RawURLEncoding.DecodeString(s)
if !allowedHashAlgs[alg] { if err != nil {
return ErrHashFormat return ErrHashFormat
} }
encoded := s[idx+1:] return nil
if encoded == "" {
return ErrHashFormat
}
_, err := base64.RawURLEncoding.DecodeString(encoded)
return err
} }

View File

@@ -14,9 +14,6 @@ DEFAULT_MAX_PRED_LENGTH = 100
_UUID_RE = re.compile( _UUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
) )
_ALLOWED_HASH_ALGS = frozenset(("sha-256", "sha-384", "sha-512"))
def _json_depth(obj: Any, depth: int = 0) -> int: def _json_depth(obj: Any, depth: int = 0) -> int:
if depth > EXT_MAX_DEPTH: if depth > EXT_MAX_DEPTH:
return depth return depth
@@ -44,22 +41,22 @@ def valid_uuid(s: str) -> bool:
def validate_hash_format(s: str) -> None: def validate_hash_format(s: str) -> None:
"""Raise ValueError if s is non-empty and not algorithm:base64url (sha-256, sha-384, sha-512).""" """Raise ValueError if s is non-empty and not plain base64url per RFC 9449 / ECT spec.
The ECT spec (draft-nennemann-wimse-ect-01) and RFC 9449 specify
``base64url(SHA-256(data))`` — a plain base64url string without any
algorithm prefix. This matches how ACT handles hashes.
"""
if not s: if not s:
return return
idx = s.find(":") # Reject strings containing non-base64url characters.
if idx <= 0: # base64url alphabet: A-Z a-z 0-9 - _ (no padding '=' expected)
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") if not re.fullmatch(r"[A-Za-z0-9_-]+", s):
alg = s[:idx].lower() raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)")
if alg not in _ALLOWED_HASH_ALGS: # Verify it actually decodes.
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") pad = 4 - len(s) % 4
encoded = s[idx + 1:] padded = s + "=" * pad if pad != 4 else s
if not encoded:
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
pad = 4 - len(encoded) % 4
if pad != 4:
encoded += "=" * pad
try: try:
base64.urlsafe_b64decode(encoded) base64.urlsafe_b64decode(padded)
except Exception: except Exception:
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") from None raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)") from None

View File

@@ -47,17 +47,18 @@ def test_validate_hash_format_empty():
def test_validate_hash_format_ok(): def test_validate_hash_format_ok():
# sha-256:base64url (minimal valid) # Plain base64url per RFC 9449 / ECT spec (no algorithm prefix)
validate_hash_format("sha-256:YQ") validate_hash_format("YQ")
validate_hash_format("sha-384:YQ") validate_hash_format("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
validate_hash_format("sha-512:YQ") validate_hash_format("abc123-_XYZ")
def test_validate_hash_format_bad(): def test_validate_hash_format_bad():
with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): # Colon is not valid base64url — rejects old prefixed format
validate_hash_format("md5:abc") with pytest.raises(ValueError, match="plain base64url"):
with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): validate_hash_format("sha-256:YQ")
validate_hash_format("no-colon") with pytest.raises(ValueError, match="plain base64url"):
# Invalid base64 that triggers decode error (e.g. binary) validate_hash_format("not valid!!")
with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): # Null byte in payload
validate_hash_format("sha-256:YQ\x00") # null in payload with pytest.raises(ValueError, match="plain base64url"):
validate_hash_format("YQ\x00")