diff --git a/refimpl/IMPROVEMENTS.md b/refimpl/IMPROVEMENTS.md index d6753ba..7beee0f 100644 --- a/refimpl/IMPROVEMENTS.md +++ b/refimpl/IMPROVEMENTS.md @@ -50,7 +50,7 @@ Suggestions that could make the implementations more robust, spec-strict, or pro ## 5. **Nice-to-have** ✅ - **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** **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. - **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. -- **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. diff --git a/refimpl/go-lang/ect/errors.go b/refimpl/go-lang/ect/errors.go index 5001baa..78c8526 100644 --- a/refimpl/go-lang/ect/errors.go +++ b/refimpl/go-lang/ect/errors.go @@ -23,5 +23,5 @@ var ( ErrInvalidJTI = errors.New("ect: jti must be UUID format") ErrInvalidWID = errors.New("ect: wid must be UUID format when set") 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)") ) diff --git a/refimpl/go-lang/ect/validate.go b/refimpl/go-lang/ect/validate.go index e29aa9b..95a8352 100644 --- a/refimpl/go-lang/ect/validate.go +++ b/refimpl/go-lang/ect/validate.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "encoding/json" "regexp" - "strings" ) // 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. 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. -var allowedHashAlgs = map[string]bool{"sha-256": true, "sha-384": true, "sha-512": true} +// base64urlRegex matches a non-empty base64url string without padding. +var base64urlRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth. func ValidateExt(ext map[string]interface{}) error { @@ -61,23 +60,18 @@ func ValidUUID(s string) bool { 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 { if s == "" { return nil } - idx := strings.Index(s, ":") - if idx <= 0 { + if !base64urlRegex.MatchString(s) { return ErrHashFormat } - alg := strings.ToLower(s[:idx]) - if !allowedHashAlgs[alg] { + _, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { return ErrHashFormat } - encoded := s[idx+1:] - if encoded == "" { - return ErrHashFormat - } - _, err := base64.RawURLEncoding.DecodeString(encoded) - return err + return nil } diff --git a/refimpl/python/ect/validate.py b/refimpl/python/ect/validate.py index e07fc71..d0c2441 100644 --- a/refimpl/python/ect/validate.py +++ b/refimpl/python/ect/validate.py @@ -14,9 +14,6 @@ DEFAULT_MAX_PRED_LENGTH = 100 _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}$" ) -_ALLOWED_HASH_ALGS = frozenset(("sha-256", "sha-384", "sha-512")) - - def _json_depth(obj: Any, depth: int = 0) -> int: if depth > EXT_MAX_DEPTH: return depth @@ -44,22 +41,22 @@ def valid_uuid(s: str) -> bool: 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: return - idx = s.find(":") - if idx <= 0: - raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") - alg = s[:idx].lower() - if alg not in _ALLOWED_HASH_ALGS: - raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") - encoded = s[idx + 1:] - 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 + # Reject strings containing non-base64url characters. + # base64url alphabet: A-Z a-z 0-9 - _ (no padding '=' expected) + if not re.fullmatch(r"[A-Za-z0-9_-]+", s): + raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)") + # Verify it actually decodes. + pad = 4 - len(s) % 4 + padded = s + "=" * pad if pad != 4 else s try: - base64.urlsafe_b64decode(encoded) + base64.urlsafe_b64decode(padded) 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 diff --git a/refimpl/python/tests/test_validate.py b/refimpl/python/tests/test_validate.py index 691b6bd..a560d5b 100644 --- a/refimpl/python/tests/test_validate.py +++ b/refimpl/python/tests/test_validate.py @@ -47,17 +47,18 @@ def test_validate_hash_format_empty(): def test_validate_hash_format_ok(): - # sha-256:base64url (minimal valid) - validate_hash_format("sha-256:YQ") - validate_hash_format("sha-384:YQ") - validate_hash_format("sha-512:YQ") + # Plain base64url per RFC 9449 / ECT spec (no algorithm prefix) + validate_hash_format("YQ") + validate_hash_format("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + validate_hash_format("abc123-_XYZ") def test_validate_hash_format_bad(): - with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): - validate_hash_format("md5:abc") - with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): - validate_hash_format("no-colon") - # Invalid base64 that triggers decode error (e.g. binary) - with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): - validate_hash_format("sha-256:YQ\x00") # null in payload + # Colon is not valid base64url — rejects old prefixed format + with pytest.raises(ValueError, match="plain base64url"): + validate_hash_format("sha-256:YQ") + with pytest.raises(ValueError, match="plain base64url"): + validate_hash_format("not valid!!") + # Null byte in payload + with pytest.raises(ValueError, match="plain base64url"): + validate_hash_format("YQ\x00")