Skip to content

Commit

Permalink
Add PEM support through PEMRepresentable (#26)
Browse files Browse the repository at this point in the history
* Add proper PEM support through `PEMRepresentable`

* Fix review comment and add more documentation

* Fix review comments

* Remove `allowedPEMDiscriminators`
  • Loading branch information
dnadoba authored Apr 19, 2023
1 parent 2e9c332 commit a53d9f6
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 19 deletions.
144 changes: 131 additions & 13 deletions Sources/SwiftASN1/Basic ASN1 Types/PEMDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,126 @@
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

#if canImport(Foundation)
import Foundation

/// Defines a type that can be serialized in PEM-encoded form.
///
/// 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.
public protocol PEMSerializable: DERSerializable {
/// The PEM discriminator identifying this object type.
///
/// The PEM discriminator is in the first line of a PEM string after `BEGIN` and at the end of the string after `END` e.g.
/// ```
/// -----BEGIN defaultPEMDiscriminator-----
/// <base 64 DER representation of this object>
/// -----END defaultPEMDiscriminator-----
/// ```
static var defaultPEMDiscriminator: String { get }

func serializeAsPEM(discriminator: String) throws -> PEMDocument
}

/// Defines a type that can be parsed from a PEM-encoded form.
///
/// 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:)``.
public protocol PEMParseable: DERParseable {
/// The PEM discriminator identifying this object type.
///
/// The PEM discriminator is in the first line of a PEM string after `BEGIN` and at the end of the string after `END` e.g.
/// ```
/// -----BEGIN defaultPEMDiscriminator-----
/// <base 64 DER representation of this object>
/// -----END defaultPEMDiscriminator-----
/// ```
static var defaultPEMDiscriminator: String { get }

init(pemDocument: PEMDocument) throws
}

/// Defines a type that can be serialized in and parsed from PEM-encoded form.
///
/// Users implementing this type are expected to just provide the ``PEMParseable/defaultPEMDiscriminator``.
///
/// Objects that are ``PEMRepresentable`` can be construct from a PEM `String` through ``PEMParseable/init(pemEncoded:)``.
///
/// A PEM `String` can be serialized by constructing a ``PEMDocument`` by calling ``PEMSerializable/serializeAsPEM()`` and then accessing the ``PEMDocument/pemString`` preropty.
public typealias PEMRepresentable = PEMSerializable & PEMParseable

extension PEMParseable {

/// Initialize this object from a serialized PEM representation.
///
/// 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.
@inlinable
public init(pemEncoded pemString: String) throws {
try self.init(pemDocument: try PEMDocument(pemString: pemString))
}

/// Initialize this object from a serialized PEM representation.
/// 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
@inlinable
public init(pemDocument: PEMDocument) throws {
guard pemDocument.discriminator == Self.defaultPEMDiscriminator else {
throw ASN1Error.invalidPEMDocument(reason: "PEMDocument has incorrect discriminator \(pemDocument.discriminator). Expected \(Self.defaultPEMDiscriminator) instead")
}

try self.init(derEncoded: pemDocument.derBytes)
}
}

extension PEMSerializable {
/// Serializes `self` as a PEM document with given `discriminator`.
/// - Parameter discriminator: PEM discriminator used in for the BEGIN and END encapsulation boundaries.
/// - Returns: DER encoded PEM document
@inlinable
public func serializeAsPEM(discriminator: String) throws -> PEMDocument {
var serializer = DER.Serializer()
try serializer.serialize(self)

return PEMDocument(type: discriminator, derBytes: serializer.serializedBytes)
}

/// Serializes `self` as a PEM document with the ``defaultPEMDiscriminator``.
@inlinable
public func serializeAsPEM() throws -> PEMDocument {
try self.serializeAsPEM(discriminator: Self.defaultPEMDiscriminator)
}
}

/// A PEM document is some data, and a discriminator type that is used to advertise the content.
public struct PEMDocument {
private static let lineLength = 64

public var type: String
fileprivate static let lineLength = 64

public var derBytes: Data

@available(*, deprecated, renamed: "discriminator")
public var type: String {
get { discriminator }
set { discriminator = newValue }
}

/// The PEM discriminator is in the first line of a PEM string after `BEGIN` and at the end of the string after `END` e.g.
/// ```
/// -----BEGIN discriminator-----
/// <base 64 encoded derBytes>
/// -----END discriminator-----
/// ```
public var discriminator: String

public var derBytes: [UInt8]

public init(pemString: String) throws {
// A PEM document looks like this:
Expand Down Expand Up @@ -53,30 +163,38 @@ public struct PEMDocument {
throw ASN1Error.invalidPEMDocument(reason: "PEMDocument not correctly base64 encoded")
}

self.type = discriminator
self.derBytes = derBytes
self.discriminator = discriminator
self.derBytes = Array(derBytes)
}

public init(type: String, derBytes: Data) {
self.type = type
public init(type: String, derBytes: [UInt8]) {
self.discriminator = type
self.derBytes = derBytes
}

/// PEM string is a base 64 encoded string of ``derBytes`` enclosed in BEGIN and END encapsulation boundaries with the specified ``discriminator`` type.
///
/// Example PEM string:
/// ```
/// -----BEGIN discriminator-----
/// <base 64 encoded derBytes>
/// -----END discriminator-----
/// ```
public var pemString: String {
var encoded = self.derBytes.base64EncodedString()[...]
let pemLineCount = (encoded.utf8.count + PEMDocument.lineLength) / PEMDocument.lineLength
var encoded = Data(self.derBytes).base64EncodedString()[...]
let pemLineCount = (encoded.utf8.count + Self.lineLength) / Self.lineLength
var pemLines = [Substring]()
pemLines.reserveCapacity(pemLineCount + 2)

pemLines.append("-----BEGIN \(self.type)-----")
pemLines.append("-----BEGIN \(self.discriminator)-----")

while encoded.count > 0 {
let prefixIndex = encoded.index(encoded.startIndex, offsetBy: PEMDocument.lineLength, limitedBy: encoded.endIndex) ?? encoded.endIndex
let prefixIndex = encoded.index(encoded.startIndex, offsetBy: Self.lineLength, limitedBy: encoded.endIndex) ?? encoded.endIndex
pemLines.append(encoded[..<prefixIndex])
encoded = encoded[prefixIndex...]
}

pemLines.append("-----END \(self.type)-----")
pemLines.append("-----END \(self.discriminator)-----")

return pemLines.joined(separator: "\n")
}
Expand Down
17 changes: 17 additions & 0 deletions Sources/SwiftASN1/Docs.docc/PEM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Parsing and Serializing PEM

Serialize and deserialize objects from PEM format.

### Parsing an object from a PEM string

Types conforming to the ``PEMParseable`` protocol can be constructed from a PEM string by calling ``PEMParseable/init(pemEncoded:)`` on the specific type. 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``.

### Serializing an object as a PEM string
Types conforming to the ``PEMSerializable`` protocol can be serialized to a PEM document by calling ``PEMSerializable/serializeAsPEM()`` on the specific type. This will encode the object through ``DER/Serializer``, then encode the DER encoded bytes as base64 and use ``PEMSerializable/defaultPEMDiscriminator`` as the discriminator. The PEM string can then be access through ``PEMDocument/pemString`` property on ``PEMDocument``.

### Related Types

- ``PEMDocument``
- ``PEMRepresentable``
- ``PEMParseable``
- ``PEMSerializable``
8 changes: 8 additions & 0 deletions Sources/SwiftASN1/Docs.docc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ This module provides several moving pieces:
2. A DER parser that can construct the ASN.1 tree from serialized bytes (``DER/parse(_:)-72yd1``).
3. A DER serializer that can construct serialized bytes from the ASN.1 tree (``DER/Serializer``).
4. A number of built-in ASN.1 types, representing common constructs.
5. A PEM parser and serializer

These moving pieces combine to provide support for the DER representation of ASN.1 suitable for a wide range of cryptographic uses.

Expand All @@ -32,6 +33,7 @@ These moving pieces combine to provide support for the DER representation of ASN
### Articles

- <doc:DecodingASN1>
- <doc:PEM>

### Parsing DER

Expand Down Expand Up @@ -83,3 +85,9 @@ These moving pieces combine to provide support for the DER representation of ASN
- ``ASN1IA5String``
- ``ASN1TeletexString``
- ``ASN1UniversalString``

### Parsing and Serializing PEM
- ``PEMRepresentable``
- ``PEMParseable``
- ``PEMSerializable``
- ``PEMDocument``
53 changes: 48 additions & 5 deletions Tests/SwiftASN1Tests/ASN1Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ class ASN1Tests: XCTestCase {
}
}

func testStraightforwardPEMParsing() throws {
func testStraightforwardPEMDocumentParsing() throws {
let simplePEM = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49
Expand All @@ -423,20 +423,39 @@ O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
-----END EC PRIVATE KEY-----
"""
let document = try PEMDocument(pemString: simplePEM)
XCTAssertEqual(document.type, "EC PRIVATE KEY")
XCTAssertEqual(document.discriminator, "EC PRIVATE KEY")
XCTAssertEqual(document.derBytes.count, 121)

let parsed = try DER.parse(Array(document.derBytes))
let parsed = try DER.parse(document.derBytes)
let pkey = try SEC1PrivateKey(derEncoded: parsed)

let reserialized = document.pemString
XCTAssertEqual(reserialized, simplePEM)

var serializer = DER.Serializer()
XCTAssertNoThrow(try serializer.serialize(pkey))
let reserialized2 = PEMDocument(type: "EC PRIVATE KEY", derBytes: Data(serializer.serializedBytes))
let reserialized2 = PEMDocument(type: "EC PRIVATE KEY", derBytes: serializer.serializedBytes)
XCTAssertEqual(reserialized2.pemString, simplePEM)
}

func testStraightforwardPEMParsing() throws {
let simplePEM = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBHli4jaj+JwWQlU0yhZUu+TdMPVhZ3wR2PS416Sz/K/oAoGCCqGSM49
AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzG
O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
-----END EC PRIVATE KEY-----
"""
let pkey = try SEC1PrivateKey(pemEncoded: simplePEM)

let reserialized = try pkey.serializeAsPEM().pemString
XCTAssertEqual(reserialized, simplePEM)

var serializer = DER.Serializer()
XCTAssertNoThrow(try serializer.serialize(pkey))
let reserialized2 = try SEC1PrivateKey(derEncoded: serializer.serializedBytes)
XCTAssertEqual(try reserialized2.serializeAsPEM().pemString, simplePEM)
}

func testTruncatedPEMDocumentsAreRejected() throws {
// We drip feed the PEM one extra character at a time. It never parses successfully.
Expand All @@ -451,9 +470,13 @@ O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
XCTAssertThrowsError(try PEMDocument(pemString: String(simplePEM[..<index]))) { error in
XCTAssertEqual((error as? ASN1Error)?.code, .invalidPEMDocument)
}
XCTAssertThrowsError(try SEC1PrivateKey(pemEncoded: String(simplePEM[..<index]))) { error in
XCTAssertEqual((error as? ASN1Error)?.code, .invalidPEMDocument)
}
}

XCTAssertNoThrow(try PEMDocument(pemString: simplePEM))
XCTAssertNoThrow(try SEC1PrivateKey(pemEncoded: simplePEM))
}

func testMismatchedDiscriminatorsAreRejected() throws {
Expand All @@ -468,6 +491,10 @@ O9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
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 testOverlongLinesAreForbidden() throws {
Expand All @@ -482,6 +509,10 @@ AwEHoUQDQgAEOhvJhbc3zM4SJooCaWdyheY2E6wWkISg7TtxJYgb/S0Zz7WruJzGO
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 testEarlyShortLinesAreForbidden() throws {
Expand All @@ -496,6 +527,10 @@ GO9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
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 testEmptyPEMDocument() throws {
Expand All @@ -506,6 +541,10 @@ GO9zxi7HTvuXyQr7QKSBtdCGmHym+WoPsbA==
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 {
Expand All @@ -519,6 +558,10 @@ O9zxi7HTvuXyQr7QKSBtdC%mHym+WoPsbA==
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 testAllowSingleComponentOIDs() throws {
Expand Down
4 changes: 3 additions & 1 deletion Tests/SwiftASN1Tests/Test Helper Types/SEC1PrivateKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import SwiftASN1
// parameters [0] EXPLICIT ECDomainParameters OPTIONAL,
// publicKey [1] EXPLICIT BIT STRING OPTIONAL
// }
struct SEC1PrivateKey: DERImplicitlyTaggable {
struct SEC1PrivateKey: DERImplicitlyTaggable, PEMRepresentable {
static let defaultPEMDiscriminator: String = "EC PRIVATE KEY"

static var defaultIdentifier: ASN1Identifier {
return .sequence
}
Expand Down

0 comments on commit a53d9f6

Please sign in to comment.