When you need to load an RSA private key that arrives in modern PKCS #8 (often PEM-armoured) and turn it into a SecKey
you can actually use on iOS, Apple’s Security framework gives you surprisingly few shortcuts.
The tiny, self-contained utility below fills that gap: it unwraps the PKCS #8 container, sanity-checks the ASN.1 structure, and finally hands the raw PKCS #1 blob to SecKeyCreateWithData
.
This post dissects what the code does & how it does it — section by section — and then embeds the full source at the end.
Inhalt
What to do?
1. Normalise the Input
Private keys reach your code in one of three wrappers:
What you have | What it looks like | What the importer does |
---|---|---|
PEM file | -----BEGIN PRIVATE KEY----- … -----END PRIVATE KEY----- | Strips the header/footer and newlines, then base‑64‑decodes the body. |
Base‑64 string (no headers) | A blob of ASCII characters | Just base‑64‑decodes it. |
DER bytes | Already raw binary | Uses the bytes as‑is. |
Why bother? — All three variants ultimately represent the same PKCS #8 structure; we simply convert them to a single, clean “DER” byte buffer so later steps work on a predictable target.
2. Peel Off the PKCS #8 Wrapper
PKCS #8 is a generic envelope that can hide many key types (RSA, EC, Ed25519…).
The code uses a minimalist ASN.1 reader to:
- Confirm the overall object is well‑formed (
SEQUENCE
with sane length fields). - Check the embedded AlgorithmIdentifier says “RSA” (OID
1.2.840.113549.1.1.1
). - Extract the embedded OCTET STRING — that inner blob is the classic PKCS #1
RSAPrivateKey
we actually want.
If any tag, length, or OID is out of place the importer throws a descriptive error and stops; no half‑baked keys sneak through.
3. Turn the Raw PKCS #1 Blob into a SecKey
Apple’s SecKeyCreateWithData
needs two things:
- Bytes — the PKCS #1 structure contains the RSA modulus, exponents, primes, etc.
- Attributes — a dictionary that tells the API what those bytes represent.
The importer reads just far enough into the PKCS #1 structure to discover the modulus length; that gives it the key size in bits (e.g. 2048, 4096).
It then builds the attribute dictionary:
[
kSecAttrKeyType: kSecAttrKeyTypeRSA,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrKeySizeInBits: <calculated bit length>,
kSecAttrIsPermanent: <optional>,
kSecAttrApplicationTag:<optional>
]
Finally it calls SecKeyCreateWithData
.
If you asked for the key to be permanent, macOS/iOS writes it into the user’s Keychain under the supplied application tag; otherwise it lives only in memory.
4. Error Handling with Meaningful Names
Instead of bubbling up vague CFError
s, the importer wraps every failure point in a clear Swift enum:
invalidPEM
/encryptedPEM
– header issues before parsing.invalidDER
/unexpectedASN1Structure
/unsupportedAlgorithm
– the byte structure is corrupt or not RSA.keyCreationFailed(CFError)
– Apple’s API rejected the data; you still get the originalCFError
for extra clues.
The Pay‑Off
Call one of the three convenience methods (secKey(fromPEM:)
, secKey(fromPKCS8DER:)
, or secKey(fromBase64PKCS8:)
) and you receive a fully‑validated, ready‑to‑use SecKey
object in one line of code.
You avoid:
- Writing your own ASN.1 parser.
- Handling PEM decoding edge‑cases.
- Manually building Security‑framework dictionaries.
In short, the class is a slim adapter that translates “whatever form the key arrived in” into “the exact bytes and metadata SecKey
expects,” while guarding every step with strict checks and human‑readable errors.
In depth
1. Error Namespace
Every potential failure has its own strongly-typed, self-documenting case in RSAPrivateKeyImporterError
.
This makes debugging simpler and avoids having to pattern-match on opaque CFError
s:
invalidPEM
&encryptedPEM
— problems before we even touch ASN.1.invalidDER
,unexpectedASN1Structure
,unsupportedAlgorithm
— low-level parsing/validation issues.keyCreationFailed(CFError)
—SecKeyCreateWithData
returnednil
; you still get Apple’s originalCFError
for context.
2. From PEM to Raw DER
- Header detection – looks for
-----BEGIN PRIVATE KEY-----
; bails out early if the encrypted variant is found. - Whitespace stripping & Base64 decode – a single regex removes newlines and spaces, leaving pure Base64 that
Data(base64Encoded:)
can digest.
The result is a byte-exact PKCS #8 DER blob.
3. A Minimalist DER Reader
Rather than pull in a full ASN.1 library, the file defines a simple DERReader
that can:
- Read a single byte.
- Decode short- and long-form length fields (up to four octets).
- Grab or skip an entire TLV (Tag-Length-Value) segment without allocations.
That’s sufficient because we only need a handful of primitive types (SEQUENCE (0x30)
, INTEGER (0x02)
, OBJECT IDENTIFIER (0x06)
, OCTET STRING (0x04)
).
4. Unwrapping PKCS #8 → PKCS #1
PKCS #8 is a generic “PrivateKeyInfo” wrapper.
The inner algorithm identifier tells us whether the payload is RSA, EC, etc.
Steps performed:
- Verify outer structure – must be
SEQUENCE
. - Skip the version
INTEGER
. - Parse
AlgorithmIdentifier
– expects the OID1.2.840.113549.1.1.1
(rsaEncryption). - Extract the
OCTET STRING
– that blob is the classic PKCS #1RSAPrivateKey
.
5. Building a SecKey
- Read the inner
RSAPrivateKey
SEQUENCE
to discover the modulus size, which becomeskSecAttrKeySizeInBits
. - Construct an attribute dictionary; caller decides whether the key should live only in RAM or be persisted to the Keychain (
isPermanent
+ optionalapplicationTag
). - Call
SecKeyCreateWithData
; if it fails, relay the underlyingCFError
.
You now hold a fully-functional SecKey
ready for signing, decrypting, or exporting its public half.
6. Three Public Convenience Entrypoints
Method | Expected input |
---|---|
secKey(fromPEM:) | PEM string (PKCS #8) |
secKey(fromPKCS8DER:) | Raw DER Data |
secKey(fromBase64PKCS8:) | Base64 string without headers |
All three converge on the same internal pipeline: parse → unwrap → build SecKey
.
7. Usage Example
// Assume `pemString` contains an unencrypted PKCS#8 block.
do {
let key = try PKCS8RSAPrivateKeyImporter.secKey(fromPEM: pemString,
isPermanent: true,
applicationTag: "com.example.mykey".data(using: .utf8))
// ✔️ Ready to use `key`
} catch {
print("Import failed:", error)
}
8. Security & Performance Notes
- No third-party dependencies – zero-copy parsing keeps allocations minimal.
- Strict validation – ASN.1 structure, algorithm OID, and length fields are all checked to avoid “garbage-in” mishaps.
- Keychain-ready – setting
isPermanent = true
stores the key underkSecAttrKeyClassPrivate
; add anapplicationTag
to find or delete it later. - Encryption not supported (by design) – decrypting PKCS #8 would require a pass-phrase workflow and additional crypto that’s out of scope here – maybe later
9. Complete Source Code
//
// PKCS8RSAPrivateKeyImporter.swift
// 2025 - Julian Decker
//
import Foundation
import Security
// MARK: - Error Namespace
public enum RSAPrivateKeyImporterError: Error {
case invalidPEM // Couldn’t strip headers / Base64-decode.
case encryptedPEM // BEGIN ENCRYPTED PRIVATE KEY encountered.
case invalidDER // Malformed DER (length overrun, etc.).
case unexpectedASN1Structure // Tag mismatch or unexpected ordering.
case unsupportedAlgorithm // AlgorithmIdentifier OID ≠ rsaEncryption.
case keyCreationFailed(CFError) // SecKeyCreateWithData returned nil.
}
// MARK: - Importer
public final class PKCS8RSAPrivateKeyImporter {
private init() {}
// MARK: PEM ⇢ DER
private static let unencryptedHeaders: [(String, String)] = [
("-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
]
private static let encryptedHeaders: [(String, String)] = [
("-----BEGIN ENCRYPTED PRIVATE KEY-----", "-----END ENCRYPTED PRIVATE KEY-----")
]
/// Strips PEM armor and Base64-decodes to raw DER.
public static func derData(fromPEM pem: String) throws -> Data {
// Reject encrypted blocks early.
if encryptedHeaders.contains(where: { pem.contains($0.0) && pem.contains($0.1) }) {
throw RSAPrivateKeyImporterError.encryptedPEM
}
guard let (header, footer) = unencryptedHeaders.first(where: { pem.contains($0.0) && pem.contains($0.1) }) else {
throw RSAPrivateKeyImporterError.invalidPEM
}
let base64 = pem
.replacingOccurrences(of: header, with: "")
.replacingOccurrences(of: footer, with: "")
.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression)
guard let data = Data(base64Encoded: base64) else {
throw RSAPrivateKeyImporterError.invalidPEM
}
return data
}
// MARK: – Minimal DER Reader
private struct DERReader {
let data: Data
private(set) var offset: Data.Index
init(data: Data) {
self.data = data
self.offset = data.startIndex
}
mutating func readByte() throws -> UInt8 {
guard offset < data.endIndex else { throw RSAPrivateKeyImporterError.invalidDER }
defer { offset = data.index(after: offset) }
return data[offset]
}
/// Reads short- or long-form length (≤ 4 bytes).
mutating func readLength() throws -> Int {
let first = try readByte()
if first & 0x80 == 0 { return Int(first) }
let octetCount = Int(first & 0x7F)
guard octetCount > 0 && octetCount <= 4 else { throw RSAPrivateKeyImporterError.invalidDER }
var length = 0
for _ in 0..<octetCount { length = (length << 8) | Int(try readByte()) }
return length
}
/// Reads a complete TLV and returns (tag, valueBytes).
mutating func readTLV() throws -> (tag: UInt8, value: Data) {
let tag = try readByte()
let length = try readLength()
guard data.distance(from: offset, to: data.endIndex) >= length else {
throw RSAPrivateKeyImporterError.invalidDER
}
let start = offset
offset = data.index(offset, offsetBy: length)
return (tag, data[start..<offset])
}
/// Skips a TLV without allocating.
mutating func skipTLV() throws {
_ = try readTLV()
}
}
// MARK: PKCS#8 ⇢ PKCS#1 Unwrapping
private static let rsaOID: [UInt8] = [0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01] // 1.2.840.113549.1.1.1
private static func extractPKCS1(fromPKCS8 der: Data) throws -> Data {
var reader = DERReader(data: der)
// (1) Outer SEQUENCE
let (seqTag, seqBytes) = try reader.readTLV()
guard seqTag == 0x30 else { throw RSAPrivateKeyImporterError.unexpectedASN1Structure }
var seqReader = DERReader(data: seqBytes)
// (2) Version INTEGER
let (verTag, _) = try seqReader.readTLV()
guard verTag == 0x02 else { throw RSAPrivateKeyImporterError.unexpectedASN1Structure }
// (3) AlgorithmIdentifier SEQUENCE
let (algTag, algBytes) = try seqReader.readTLV()
guard algTag == 0x30 else { throw RSAPrivateKeyImporterError.unexpectedASN1Structure }
// └─ Parse the AlgorithmIdentifier to verify OID == rsaEncryption.
var algReader = DERReader(data: algBytes)
let (oidTag, oidValue) = try algReader.readTLV()
guard oidTag == 0x06 else { throw RSAPrivateKeyImporterError.unexpectedASN1Structure }
guard oidValue.elementsEqual(Data(rsaOID)) else { throw RSAPrivateKeyImporterError.unsupportedAlgorithm }
// Optional parameters (usually NULL) are skipped if present.
if algReader.offset < algReader.data.endIndex {
try algReader.skipTLV()
}
// (4) PrivateKey OCTET STRING — PKCS#1 blob
let (octetTag, octetData) = try seqReader.readTLV()
guard octetTag == 0x04 else { throw RSAPrivateKeyImporterError.unexpectedASN1Structure }
return octetData
}
// MARK: SecKey Creation
private static func makeSecKey(fromPKCS1 pkcs1: Data,
isPermanent: Bool,
applicationTag: Data?) throws -> SecKey {
var reader = DERReader(data: pkcs1)
// RSAPrivateKey SEQUENCE
let (outerTag, outerBytes) = try reader.readTLV()
guard outerTag == 0x30 else { throw RSAPrivateKeyImporterError.unexpectedASN1Structure }
var rsaReader = DERReader(data: outerBytes)
let (vTag, _) = try rsaReader.readTLV()
guard vTag == 0x02 else { throw RSAPrivateKeyImporterError.unexpectedASN1Structure }
// Modulus INTEGER — compute correct bit length (strip possible 0x00 padding byte).
let (modTag, modData) = try rsaReader.readTLV()
guard modTag == 0x02 else { throw RSAPrivateKeyImporterError.unexpectedASN1Structure }
let modulus = modData.first == 0x00 ? modData.dropFirst() : modData[...]
let bitSize = modulus.count * 8
// Attrs dictionary (optionally persistent).
var attrs: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
kSecAttrKeySizeInBits as String: bitSize
]
if isPermanent { attrs[kSecAttrIsPermanent as String] = true }
if let tag = applicationTag { attrs[kSecAttrApplicationTag as String] = tag }
var cfError: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(pkcs1 as CFData, attrs as CFDictionary, &cfError) else {
throw RSAPrivateKeyImporterError.keyCreationFailed(cfError!.takeRetainedValue())
}
return key
}
// MARK: Public API
/// Imports a PEM-encoded (unencrypted) PKCS#8 RSA private key.
/// - Parameters:
/// - pem: PEM string.
/// - isPermanent: Persist in Keychain if `true`.
/// - applicationTag: Optional application-tag for persisted keys.
public static func secKey(fromPEM pem: String,
isPermanent: Bool = false,
applicationTag: Data? = nil) throws -> SecKey {
let der8 = try derData(fromPEM: pem)
let pkcs1 = try extractPKCS1(fromPKCS8: der8)
return try makeSecKey(fromPKCS1: pkcs1, isPermanent: isPermanent, applicationTag: applicationTag)
}
/// Imports raw DER-encoded PKCS#8.
public static func secKey(fromPKCS8DER der: Data,
isPermanent: Bool = false,
applicationTag: Data? = nil) throws -> SecKey {
let pkcs1 = try extractPKCS1(fromPKCS8: der)
return try makeSecKey(fromPKCS1: pkcs1, isPermanent: isPermanent, applicationTag: applicationTag)
}
/// Imports Base64-encoded PKCS#8 (no headers).
public static func secKey(fromBase64PKCS8 base64: String,
isPermanent: Bool = false,
applicationTag: Data? = nil) throws -> SecKey {
guard let der = Data(base64Encoded: base64) else {
throw RSAPrivateKeyImporterError.invalidPEM
}
return try secKey(fromPKCS8DER: der,
isPermanent: isPermanent,
applicationTag: applicationTag)
}
}
SwiftHappy key-importing!
Additional Code to test the Class:
import Foundation
import Security
#if DEBUG || TESTING
// MARK: – Test Harness
private func runTests() {
print("=== PKCS8RSAPrivateKeyImporter Self‑Test ===")
// 1. Success path (replace with a real key for a true positive test)
do {
let key = try PKCS8RSAPrivateKeyImporter.secKey(fromPEM: samplePEM())
print("✓ Imported key, bit length =", SecKeyGetBlockSize(key) * 8)
} catch {
print("✗ Expected success but got error:", error)
}
// 2. Encrypted PEM should be rejected.
do {
_ = try PKCS8RSAPrivateKeyImporter.secKey(fromPEM: sampleEncryptedPEM())
print("✗ Unexpected success for encrypted PEM")
} catch RSAPrivateKeyImporterError.encryptedPEM {
print("✓ Correctly detected encrypted PEM")
} catch {
print("✗ Wrong error for encrypted PEM:", error)
}
// 3. Totally invalid PEM.
do {
_ = try PKCS8RSAPrivateKeyImporter.secKey(fromPEM: "not even PEM")
print("✗ Unexpected success for garbage PEM")
} catch RSAPrivateKeyImporterError.invalidPEM {
print("✓ Correctly rejected invalid PEM")
} catch {
print("✗ Wrong error for garbage PEM:", error)
}
// 4. DER + Base64 convenience check.
do {
let der = try PKCS8RSAPrivateKeyImporter.derData(fromPEM: samplePEM())
let key1 = try PKCS8RSAPrivateKeyImporter.secKey(fromPKCS8DER: der)
let key2 = try PKCS8RSAPrivateKeyImporter.secKey(fromBase64PKCS8: der.base64EncodedString())
guard SecKeyGetBlockSize(key1) == SecKeyGetBlockSize(key2) else {
print("✗ DER/Base64 key size mismatch")
return
}
print("✓ DER and Base64 import pathways succeeded")
} catch {
print("✗ DER/Base64 import failed:", error)
}
print("=== Test run complete ===")
}
// MARK: – Sample Key Stubs
/// A deliberately *truncated* placeholder key. Replace with a real unencrypted
/// PKCS#8 RSA key (e.g. generated via: `openssl genpkey -algorithm RSA -out key.pem`).
private func samplePEM() -> String {
return """
-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAPVNQyaHZ6Rth8uH
E6FAhIQbG4E1jjeACqiHUnV8KYSf8bjgKZaHYUgetEBineNAW9CbK4AlDfTD1SHT
4+cz8WQhpHYg31X9XJQp/ivnfNFQ/ecOhidGFQCMfVyLGWwVDicqPt+UYzKt5A0C
p71eDIT2gLs5qfoYto7tm/QrDMvVAgMBAAECgYASmzCrvHuNCk3Rj0Za5dTnXFMC
wvgtl4W3cMQ9axSPHb6tAjvFUjF70fBkLdbCBQCx2wM6rhTX6v7AmRzhTZxSkQKA
RsLTxzoZvRvr77yaXqt2Z3sINws4LRhrHRssrFJGGrG5dqM+Jl0TZsjJtkC3hHxU
E8NecsRxbNBY8iuD4QJBAPwCIy00/7UEa03p+zM5zB8rP3KEidDyAJHHRlBe8qyw
3Y2HLbocLNGQm2UWIy0uT2M/Y70X4Mafo0cROOd7LFkCQQD5L+5CJOBL2j9ANbar
eq9VDn4NidhD4bAxEDLjHUKWaC7v//B01xNfotC9pIc8vfug9jpIOZz+7LuxKZDa
lzvdAkA5ss+VASZurpVW4HSINPp8RG2hbaEUOuRQfDyoGCUdztzbE5EvpFXKoX5C
tb/WD11TzuaqG7Z2I4TBt7q8nSH5AkEAlO4Jp2ykxZD2FqlDuO9FVT+pJOxK3h9I
D7rPvx+gyYYQ7433J83XnWQRABcSYMJnXsdrA/mzFEVm1Da0hYC33QJAG/6gyAFu
tQ3JJcJNl8cIoYIsAMoIAFwTpUKOB7/+Nuz48LI4rFAgn0xdtdABqWC6Z08z0dC9
/jg84a/cMXfBUw==
-----END PRIVATE KEY-----
"""
}
/// A stub block that will always trigger `.encryptedPEM`.
private func sampleEncryptedPEM() -> String {
return """
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIC3TBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIXuPojJm1QfACAggA
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBDMGKjyLDZF4tp2ehabAQQdBIIC
gCs4ZGK1Lsoc+tDMPuKgZcpkaJLgejLFFCRCU9/m9LmE49oD6Tybavxu7sb3oY5G
J5PcQeP26fucSH7YGJwbqulN72wjwmlrlx7c7BEZs591DLJHFximm1tojkuvtku1
EpHGAMw4OTUFv8zjIjLYEnBBphBZ6YTmSa1hp3ZS0KD6eEkklGKhOtI3aNr7THdR
PHyBU98vtyp4rXnWhYmTSGWCov9Tv3l36XyJYyNyBlpq4NBpQeX9ZqUGn69k1bcr
UM2o9TAyVRB2kFSZFA5A9MqopW0b9V8NaI45f3PHrkzhwfrdflcbwqEnQGIkyKcP
iV0NUUoEAB8O5oGsyubECcQ3jZ8dvDct2JsoMMeTDsmVEBX5c+3pWf9n+zSdD+j5
p95nxiGeF9hb80ZVHhNQFUVt9+qopwhTglTT8djvlP81BzHlwVYtbzquEG3ND5ci
Z9NPesiD6YKPIhQAjPZZ4dC7Zn/xQ+x5IOZXfvblioMJTW7wz7JY5oRLFGk2/eoH
tQG8Gtx+vfcm4kx39NK2mpNdNqURssjWNAc42ZZ7b+n/66DHihtaU2eIDXn+3lma
43Cc7S8emVveaH/7CBO6zRgk+BYqEcmSZiNs4ct230uyD6ptkPkB1lh6JxPk+vg6
sEU9Ik+Ak/KhsRUM/dN+JLrsF/U3Evc0fiu6HKYzsXx8B5g3lY3h0Fh8k+1JJRoo
Rn1A78EcjOBq0X6OH7LE5dlJYLwbfeLr52/nLQGkWAd8mCjs/hEPP4gxYbHC15CK
QZ7LyKpKJI8zQe9+jLhi9J6gKm2D01+gPm2/yksYDg0ofWZ+yDJbHQnpchEKBhxp
tsMBaX3QNqtL6N1QxObG1HU=
-----END ENCRYPTED PRIVATE KEY-----
"""
}
// Run the tests.
runTests()
#endif
Swift