Skip to content
Open
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
67 changes: 67 additions & 0 deletions Sources/SwiftASN1/Basic ASN1 Types/PEMDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ struct LazyPEMDocument {
throw ASN1Error.invalidPEMDocument(reason: "discriminator is not valid UTF-8")
}

try self.base64EncodedDERString.utf8.validateCanonicalBase64()

guard let data = Data(base64Encoded: self.base64EncodedDERString) else {
throw ASN1Error.invalidPEMDocument(reason: "PEMDocument not correctly base64 encoded")
}
Expand All @@ -219,6 +221,71 @@ struct LazyPEMDocument {
}
}

extension String.UTF8View {
/// Checks that `self` is canonical base64 as described in RFC 4648 section 3.5: padding appears only at the end,
/// there are at most two padding characters, and the bits that do not form part of a decoded byte are all zero.
///
/// `Data(base64Encoded:)` accepts excess padding and non-zero trailing bits, which allows several distinct
/// strings to decode to the same bytes. PEM carries signed material, so we insist on the one canonical form.
fileprivate func validateCanonicalBase64() throws {
guard self.count % 4 == 0 else {
throw ASN1Error.invalidPEMDocument(reason: "PEMDocument base64 body length is not a multiple of four")
}

// Walk back over up to two padding characters. Afterwards `endOfData` is the index just past the last
// data character.
var paddingCount = 0
var endOfData = self.endIndex
while paddingCount < 2, endOfData != self.startIndex {
let previous = self.index(before: endOfData)
guard self[previous] == UInt8(ascii: "=") else {
break
}
paddingCount += 1
endOfData = previous
}

guard !self[..<endOfData].contains(UInt8(ascii: "=")) else {
throw ASN1Error.invalidPEMDocument(reason: "PEMDocument base64 body has excess or misplaced padding")
}

guard paddingCount > 0 else {
return
}

guard let lastSextet = self[self.index(before: endOfData)].base64Sextet else {
throw ASN1Error.invalidPEMDocument(reason: "PEMDocument not correctly base64 encoded")
}

// With one padding character the final data character contributes four bits, so its low two bits must be
// zero. With two padding characters it contributes two bits, so its low four bits must be zero.
let unusedBitsMask: UInt8 = paddingCount == 1 ? 0b11 : 0b1111
guard lastSextet & unusedBitsMask == 0 else {
throw ASN1Error.invalidPEMDocument(reason: "PEMDocument base64 body has non-zero trailing bits")
}
}
}

extension UInt8 {
/// The six-bit value this byte represents in the standard base64 alphabet, or `nil` if it is not in the alphabet.
fileprivate var base64Sextet: UInt8? {
switch self {
case UInt8(ascii: "A")...UInt8(ascii: "Z"):
return self - UInt8(ascii: "A")
case UInt8(ascii: "a")...UInt8(ascii: "z"):
return self - UInt8(ascii: "a") + 26
case UInt8(ascii: "0")...UInt8(ascii: "9"):
return self - UInt8(ascii: "0") + 52
case UInt8(ascii: "+"):
return 62
case UInt8(ascii: "/"):
return 63
default:
return nil
}
}
}

extension Substring.UTF8View {
/// An optional `Index` value that is offset from `self.startIndex` by the new line character sequence.
/// - Returns: The `Index` denoting the position immediately after the new line character sequence.
Expand Down
30 changes: 30 additions & 0 deletions Tests/SwiftASN1Tests/ASN1Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,36 @@ class ASN1Tests: XCTestCase {
}
}

func testNonCanonicalBase64IsForbidden() throws {
func pem(_ body: String) -> String {
"""
-----BEGIN TEST-----
\(body)
-----END TEST-----
"""
}

// The canonical encodings of "ABC", "AB" and "A" are accepted.
XCTAssertEqual(try PEMDocument(pemString: pem("QUJD")).derBytes, [0x41, 0x42, 0x43])
XCTAssertEqual(try PEMDocument(pemString: pem("QUI=")).derBytes, [0x41, 0x42])
XCTAssertEqual(try PEMDocument(pemString: pem("QQ==")).derBytes, [0x41])

// Other encodings that would decode to the same bytes are rejected.
let nonCanonicalBodies = [
"QUJ=", // Non-zero trailing bits with one padding character.
"QR==", // Non-zero trailing bits with two padding characters.
"QUJD====", // Excess padding.
"QUJD=", // Length is not a multiple of four.
"QQ==QUJD", // Padding in the middle.
"====", // Only padding.
]
for body in nonCanonicalBodies {
XCTAssertThrowsError(try PEMDocument(pemString: pem(body)), body) { error in
XCTAssertEqual((error as? ASN1Error)?.code, .invalidPEMDocument, body)
}
}
}

func testNonASCIICharacterIsForbidden() throws {
// A character which isn't even part of the ASCII range is not a valid base64 character.
let simplePEM = """
Expand Down