Skip to content

Validate UTF8String contents when decoding - #129

Open
d-date wants to merge 1 commit into
apple:mainfrom
d-date:utf8string-validation
Open

d-date wants to merge 1 commit into
apple:mainfrom
d-date:utf8string-validation

Conversation

@d-date

@d-date d-date commented Sep 4, 2026

Copy link
Copy Markdown

Validate UTF8String contents when decoding

Motivation

ASN1UTF8String accepts any bytes. Decoding 0C 02 C3 28 succeeds, even
though C3 must be followed by one continuation byte and 28 is not one — so
the resulting bytes are not UTF-8.

X.690 §8.23.10 defines a UTF8String's contents as the UTF-8 encoding of an
ISO/IEC 10646 string, so those octets are not a value of the type.

This is also inconsistent within the module. ASN1PrintableString,
ASN1VisibleString and ASN1IA5String each call Self._isValid(self.bytes)
from their decoding initializers and throw
ASN1Error.invalidStringRepresentation when it fails. ASN1UTF8String has no
_isValid and assigns ASN1OctetString(derEncoded:).bytes directly.

There is a second-order effect: String(_ utf8String:) builds its String
with String(decoding:as:), which substitutes U+FFFD for invalid sequences. A
caller who goes through String therefore gets characters that were never in
the input, with nothing having reported a problem.

What this changes

ASN1UTF8String.init(derEncoded:withIdentifier:) and
init(berEncoded:withIdentifier:) validate the contents and throw
ASN1Error.invalidStringRepresentation when they are not UTF-8, matching the
three types that already do this. _isValid is added in the same
@inlinable static func shape, decoding through UTF8() so it stays free of
Foundation.

Compatibility

This does not change any API signature — both initializers already throws —
but it intentionally changes decoding behaviour for input that was previously
accepted.

Out of scope here, so the change stays limited to what decoding accepts:

  • init(contentBytes:) is non-throwing, so validating there would change its
    signature. (ASN1PrintableString.init(contentBytes:) does throws, so there
    is a precedent for aligning them, but that seems like a separate decision.)
  • bytes is a public var; a didSet precondition would keep the signature
    but could trap in existing code, which is a different trade-off.
  • Validation of BMPString and UniversalString, which I will raise separately.

Tests

Rejected: an invalid continuation sequence, an overlong encoding, an encoded
surrogate, a scalar above U+10FFFF, a truncated sequence, and a lone
continuation byte.

Accepted: empty, ASCII, and two-, three- and four-byte scalars, so the change
does not narrow what a valid UTF8String may hold.

`ASN1UTF8String` accepted any bytes, so `0C 02 C3 28` decoded even though `C3`
must be followed by one continuation byte and `28` is not one. X.690 §8.23.10
defines the contents as the UTF-8 encoding of an ISO/IEC 10646 string, so those
octets are not a value of the type.

`ASN1PrintableString`, `ASN1VisibleString` and `ASN1IA5String` already validate
from their decoding initializers; this brings UTF8String in line with them.
`init(contentBytes:)` and `bytes` are left alone: validating there would change
a signature or trap in existing code, which is a separate decision from what
decoding accepts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant