Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for CRLF line endings to PEMDocument #68

Merged
merged 4 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
54 changes: 40 additions & 14 deletions Sources/SwiftASN1/Basic ASN1 Types/PEMDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import Foundation
///
/// Users implementing this type are expected to just provide the ``defaultPEMDiscriminator``
///
/// A PEM `String` can be serialized by constructing a ``PEMDocument`` by calling ``PEMSerializable/serializeAsPEM()`` and then accessing the ``PEMDocument/pemString`` preropty.
/// A PEM `String` can be serialized by constructing a ``PEMDocument`` by calling ``PEMSerializable/serializeAsPEM()`` and then accessing the ``PEMDocument/pemString`` property.
public protocol PEMSerializable: DERSerializable {
/// The PEM discriminator identifying this object type.
///
Expand All @@ -38,7 +38,7 @@ public protocol PEMSerializable: DERSerializable {
///
/// Users implementing this type are expected to just provide the ``defaultPEMDiscriminator``.
///
/// Objects that are ``PEMParseable`` can be construct from a PEM `String` through ``PEMParseable/init(pemEncoded:)``.
/// Objects that are ``PEMParseable`` can be constructed from a PEM `String` through ``PEMParseable/init(pemEncoded:)``.
public protocol PEMParseable: DERParseable {
/// The PEM discriminator identifying this object type.
///
Expand Down Expand Up @@ -69,8 +69,8 @@ extension PEMParseable {
/// This will check that the discriminator matches ``PEMParseable/defaultPEMDiscriminator``, decode the base64 encoded string and
/// then decode the DER encoded bytes using ``DERParseable/init(derEncoded:)-i2rf``.
///
/// - parameters:
/// - pemEncoded: The PEM-encoded string representing this object.
/// - Parameters:
/// - pemString: The PEM-encoded string representing this object.
@inlinable
public init(pemEncoded pemString: String) throws {
try self.init(pemDocument: try PEMDocument(pemString: pemString))
Expand All @@ -80,8 +80,8 @@ extension PEMParseable {
/// This will check that the ``PEMParseable/pemDiscriminator`` matches and
/// forward the DER encoded bytes to ``DERParseable/init(derEncoded:)-i2rf``.
///
/// - parameters:
/// - pemDocument: DER-encoded PEM document
/// - Parameters:
/// - pemDocument: DER-encoded PEM document
@inlinable
public init(pemDocument: PEMDocument) throws {
guard pemDocument.discriminator == Self.defaultPEMDiscriminator else {
Expand Down Expand Up @@ -218,6 +218,25 @@ struct LazyPEMDocument {
}

extension Substring.UTF8View {
/// Checks whether `self` starts with a new line character.
/// - Returns: `true` if `self` starts with a new line character; otherwise, `false`.
fileprivate func startsWithNewLine() -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fileprivate func startsWithNewLine() -> Bool {
fileprivate var startsWithNewLine(): Bool {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed startsWithNewLine entirely since there are no longer any references to it

return self.starts(with: LineEnding.LF.utf8) || self.starts(with: LineEnding.CRLF.utf8)
}

/// 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.
/// `nil` is returned if `self` does not start with a new line.
fileprivate func offsetNewLine() -> Index? {
aryan-25 marked this conversation as resolved.
Show resolved Hide resolved
if self.starts(with: LineEnding.LF.utf8) {
return self.index(after: self.startIndex)
}
if self.starts(with: LineEnding.CRLF.utf8) {
return self.index(self.startIndex, offsetBy: 2)
}
return nil
}

/// A PEM document looks like this:
/// ```
/// -----BEGIN <SOME DISCRIMINATOR>-----
Expand All @@ -235,8 +254,10 @@ extension Substring.UTF8View {
beginDiscriminatorSuffix
) = self.firstRangesOf(
prefix: "-----BEGIN ",
suffix: "-----\n"
)
suffix: "-----"
),
self[beginDiscriminatorSuffix.upperBound...].startsWithNewLine(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is redundant: the guard let below covers it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Removed this and just keeping the call to offsetNewLine in the line below.

let messageStart = self[beginDiscriminatorSuffix.upperBound...].offsetNewLine()
else {
return nil
}
Expand Down Expand Up @@ -270,7 +291,7 @@ extension Substring.UTF8View {
}

/// everything between the BEGIN and END markers is considered the base64 encoded string
let base64EncodedDERString = self[beginDiscriminatorSuffix.upperBound..<endDiscriminatorPrefix.lowerBound]
let base64EncodedDERString = self[messageStart..<endDiscriminatorPrefix.lowerBound]

try base64EncodedDERString.checkLineLengthsOfBase64EncodedString()

Expand Down Expand Up @@ -298,15 +319,14 @@ extension Substring.UTF8View {
let expectedNewLineIndex =
message.index(message.startIndex, offsetBy: 64, limitedBy: lastIndex) ?? lastIndex

guard
let actualNewLineIndex = message.firstIndex(of: UInt8(ascii: "\n")),
actualNewLineIndex == expectedNewLineIndex
// The current line cannot contain any "\n" and the end index must be a new line.
guard message[..<expectedNewLineIndex].firstIndex(of: UInt8(ascii: "\n")) == nil,
message[expectedNewLineIndex...].startsWithNewLine(),
let nextLineStart = message[expectedNewLineIndex...].offsetNewLine()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard let also supersedes the other call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, like above.

else {
throw ASN1Error.invalidPEMDocument(reason: "PEMDocument has incorrect line lengths")
}

let nextLineStart = message.index(after: expectedNewLineIndex)

message = message[nextLineStart...]
}
}
Expand Down Expand Up @@ -384,4 +404,10 @@ extension Substring.UTF8View {
}
}

/// Represents new line delimiters.
public enum LineEnding {
aryan-25 marked this conversation as resolved.
Show resolved Hide resolved
public static let LF = "\n"
public static let CRLF = "\r\n"
}

#endif
146 changes: 145 additions & 1 deletion Tests/SwiftASN1Tests/ASN1Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,134 @@ class ASN1Tests: XCTestCase {
XCTAssertEqual(try reserialized2.serializeAsPEM().pemString, simplePEM)
}

func testStraightforwardPEMLineEndingParsing() throws {
let simplePEMWithLF = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
-----END EC PRIVATE KEY-----
"""

let simplePEMWithCRLF = """
-----BEGIN EC PRIVATE KEY-----\r
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49\r
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG\r
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==\r
-----END EC PRIVATE KEY-----
"""

let pemDocumentWithLF = try PEMDocument(pemString: simplePEMWithLF)
let pemDocumentWithCRLF = try PEMDocument(pemString: simplePEMWithCRLF)

XCTAssertEqual(pemDocumentWithLF.discriminator, "EC PRIVATE KEY")
XCTAssertEqual(pemDocumentWithLF.derBytes.count, 121)

XCTAssertEqual(pemDocumentWithCRLF.discriminator, "EC PRIVATE KEY")
XCTAssertEqual(pemDocumentWithCRLF.derBytes.count, 121)

XCTAssertEqual(pemDocumentWithLF.derBytes, pemDocumentWithCRLF.derBytes)
}

func testPEMInconsistentLineEndingParsing() throws {
let simplePEMWithConsistentLFLineEnding = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
-----END EC PRIVATE KEY-----
"""

// No carriage return \r in line 3.
let simplePEMWithInconsistentLineEnding = """
-----BEGIN EC PRIVATE KEY-----\r
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49\r
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==\r
-----END EC PRIVATE KEY-----
"""

let consistentPEMDocument = try PEMDocument(pemString: simplePEMWithConsistentLFLineEnding)
let inconsistentPEMDocument = try PEMDocument(pemString: simplePEMWithInconsistentLineEnding)

XCTAssertEqual(consistentPEMDocument.discriminator, "EC PRIVATE KEY")
XCTAssertEqual(consistentPEMDocument.derBytes.count, 121)

XCTAssertEqual(inconsistentPEMDocument.discriminator, "EC PRIVATE KEY")
XCTAssertEqual(inconsistentPEMDocument.derBytes.count, 121)

XCTAssertEqual(consistentPEMDocument.derBytes, inconsistentPEMDocument.derBytes)
}

func testStraightforwardPEMAndPrivateKeyLineEndingParsing() throws {
let simplePEMWithLF = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
-----END EC PRIVATE KEY-----
"""

let simplePEMWithCRLF = """
-----BEGIN EC PRIVATE KEY-----\r
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49\r
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG\r
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==\r
-----END EC PRIVATE KEY-----
"""

let pemDocumentWithLF = try PEMDocument(pemString: simplePEMWithLF)
let pemDocumentWithCRLF = try PEMDocument(pemString: simplePEMWithCRLF)

let privateKeyFromPEMWithLF = try SEC1PrivateKey(pemEncoded: simplePEMWithLF)
let privateKeyFromPEMWithCRLF = try SEC1PrivateKey(pemEncoded: simplePEMWithCRLF)

XCTAssertEqual(privateKeyFromPEMWithLF.privateKey, privateKeyFromPEMWithCRLF.privateKey)
XCTAssertEqual(privateKeyFromPEMWithLF.publicKey, privateKeyFromPEMWithCRLF.publicKey)

let pemFromPKWithLF = try privateKeyFromPEMWithLF.serializeAsPEM()
let pemFromPKWithCRLF = try privateKeyFromPEMWithCRLF.serializeAsPEM()

XCTAssertEqual(pemDocumentWithLF.derBytes, pemFromPKWithLF.derBytes)
XCTAssertEqual(pemDocumentWithCRLF.derBytes, pemFromPKWithCRLF.derBytes)

XCTAssertEqual(pemFromPKWithLF.derBytes, pemFromPKWithCRLF.derBytes)
}

func testStraightforwardPEMAndPrivateKeyLineEndingSerialization() throws {
let simplePEMWithLF = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
-----END EC PRIVATE KEY-----
"""

let simplePEMWithCRLF = """
-----BEGIN EC PRIVATE KEY-----\r
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49\r
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG\r
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==\r
-----END EC PRIVATE KEY-----
"""

let privateKeyFromPEMWithLF = try SEC1PrivateKey(pemEncoded: simplePEMWithLF)
let privateKeyFromPEMWithCRLF = try SEC1PrivateKey(pemEncoded: simplePEMWithCRLF)

var lfSerializer = DER.Serializer()
var crlfSerializer = DER.Serializer()

XCTAssertNoThrow(try lfSerializer.serialize(privateKeyFromPEMWithLF))
XCTAssertNoThrow(try crlfSerializer.serialize(privateKeyFromPEMWithCRLF))

XCTAssertEqual(lfSerializer.serializedBytes, crlfSerializer.serializedBytes)

let reserializedPKWithLF = try SEC1PrivateKey(derEncoded: lfSerializer.serializedBytes)
let reserializedPKWithCRLF = try SEC1PrivateKey(derEncoded: crlfSerializer.serializedBytes)

XCTAssertEqual(reserializedPKWithLF.privateKey, reserializedPKWithCRLF.privateKey)
}

func testTruncatedPEMDocumentsAreRejected() throws {
// We drip feed the PEM one extra character at a time. It never parses successfully.
let simplePEM = """
Expand Down Expand Up @@ -681,7 +809,7 @@ class ASN1Tests: XCTestCase {
}
}

func testPEMDocumentWithOnlyNewLines() throws {
func testPEMDocumentWithOnlyLFNewLines() throws {
let simplePEM = """
-----BEGIN EC PRIVATE KEY-----

Expand All @@ -697,6 +825,22 @@ class ASN1Tests: XCTestCase {
}
}

func testPEMDocumentWithOnlyCRLFNewLines() throws {
let simplePEM = """
-----BEGIN EC PRIVATE KEY-----\r
\r
\r
-----END EC PRIVATE KEY-----
"""
XCTAssertThrowsError(try PEMDocument(pemString: simplePEM)) { error in
XCTAssertEqual((error as? ASN1Error)?.code, .invalidPEMDocument)
}

XCTAssertThrowsError(try SEC1PrivateKey(pemEncoded: simplePEM)) { error in
XCTAssertEqual((error as? ASN1Error)?.code, .invalidPEMDocument)
}
}

func testInvalidBase64IsForbidden() throws {
let simplePEM = """
-----BEGIN EC PRIVATE KEY-----
Expand Down