· Matthew Watt · One-offs  · 7 min read

Securely save a SwiftUI view as a password-protected PDF

In order to develop the secure PDF seed phrase backup feature for Nighthawk Wallet 2.0, I needed to figure out how to render a SwiftUI view as a PDF and password protect it with a user-supplied password. In this guide, I share what I learned.

In order to develop the secure PDF seed phrase backup feature for Nighthawk Wallet 2.0, I needed to figure out how to render a SwiftUI view as a PDF and password protect it with a user-supplied password. In this guide, I share what I learned.

Overview

It seemed like a decently-sized effort, so I decided to break it down into the following steps that I would figure out one-by-one:

  1. Get password from user input
  2. Render the view to a PDF file sharable via a share sheet
  3. Password protect the PDF file

Our super secret content

Starting with a new empty iOS app project in Xcode, let’s create a view with some security-sensitive content:

struct SecretsView: View {
    var body: some View {
        VStack(spacing: 16) {
            Text("KEEP SECRET:")
                .font(.largeTitle)
            Text("My phone number is (555) 555-5555")
            Text("The one password I use for everything is M4tTiZcOoL123")
            Text("I'm actually a really big Taylor Swift fan")
        }
    }
}

And our ContentView:

struct ContentView: View {
    var body: some View {
        VStack {
            SecretsView()
        }
        .padding()
    }
}

Which should look like this:

SecretsView running in an Xcode Preview

Get password from user input

Let’s add some state to our ContentView to hold the user’s password, a SecureField to allow the user to enter a password, and some extra spacing to give everything some room to breathe:

struct ContentView: View {
    @State private var password = ""

    var body: some View {
        VStack(alignment: .center, spacing: 16) {
            SecretsView()

            SecureField("Password", text: $password)
                .multilineTextAlignment(.center)
        }
        .padding()
    }
}

We should now have:

ContentView with state for password and a SecureField running in an Xcode Preview

Render the view to a PDF file sharable via a share sheet

For this next step, I started with a web search for “Save a SwiftUI view to a PDF”, which brought me to Paul Hudson’s Hacking with Swift article on the subject, which provided my starting point. Let’s add the following to ContentView:

func render() -> URL {
    let renderer = ImageRenderer(content: SecretsView())

    let url = URL.documentsDirectory.appending(path: "output.pdf")

    renderer.render { size, context in
        var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)

        guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
            return
        }

        pdf.beginPDFPage(nil)
        context(pdf)
        pdf.endPDFPage()
        pdf.closePDF()
    }

    return url
}

And then update ContentView’s body like so:

var body: some View {
    VStack(alignment: .center, spacing: 16) {
        SecretsView()

        SecureField("Password", text: $password)
            .multilineTextAlignment(.center)

        ShareLink(item: render()) {
            Text("Securely save secrets")
        }
    }
    .padding()
}

I made a few changes to suit our demo app’s needs, namely, I changed the output file name and I used a different version of ShareLink that takes content, since the one used in HWS includes a share icon, which I didn’t want. I strongly encourage you to read the original article, as it steps through what this code is doing in far greater detail.

But with that, we should have the following:

Screenshot of Xcode with share sheet code wired up running in an Xcode Preview

Firing up the simulator or a physical device, you should see the following:

Screen recording of entering a password (that does nothing yet) and launching a share sheet with the SwiftUI view as a PDF

Password protect the PDF file

The final step is to figure out how to apply the user’s entered password to the PDF itself. With the code we have now, we do nothing with the password. Let’s change that. In render, after the call to closePdf, add the following:

// Read as a PDF document and encrypt with the provided password
guard let pdfDocument = PDFDocument(url: url) else { return }
pdfDocument.write(
    to: url,
    withOptions: [
        PDFDocumentWriteOption.userPasswordOption : password,
        PDFDocumentWriteOption.ownerPasswordOption : password
    ]
)

We’ll also need to add the following import:

import PDFKit

This code leverages PDFKit to do the following:

  1. Read the PDF data saved at output.pdf into a PDFDocument object
  2. Write the PDF document back to output.pdf, using write options to pass the input password

And that’s really it. Altogether, our render function should now look like this:

func render() -> URL {
    let renderer = ImageRenderer(content: SecretsView())

    let url = URL.documentsDirectory.appending(path: "output.pdf")

    renderer.render { size, context in
        var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)

        guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
            return
        }

        pdf.beginPDFPage(nil)
        context(pdf)
        pdf.endPDFPage()
        pdf.closePDF()

        // Read as a PDF document and encrypt with the provided password
        guard let pdfDocument = PDFDocument(url: url) else { return }
        pdfDocument.write(
            to: url,
            withOptions: [
                PDFDocumentWriteOption.userPasswordOption : password,
                PDFDocumentWriteOption.ownerPasswordOption : password
            ]
        )
    }

    return url
}

And firing it up in the simulator, if I enter the very secure password “password123” and tap share, I can see that I can save the file to disk and view it only by entering the password:

Screen recording of the final demo: entering a password, saving the PDF, and viewing the PDF by entering the password

A problem lurking in the shadows

The eagle-eyed among you may have noticed an issue with the “final” code above. Before continuing, take a moment to see if you can figure it out. I’ll wait.

























Ok, that’s enough time. Let’s dig into it.

A subtle security vulnerability

The issue lies with our render function. Take another look at it and I’ll give you one last chance to spot the bug:

func render() -> URL {
    let renderer = ImageRenderer(content: SecretsView())

    let url = URL.documentsDirectory.appending(path: "output.pdf")

    renderer.render { size, context in
        var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)

        guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
            return
        }

        pdf.beginPDFPage(nil)
        context(pdf)
        pdf.endPDFPage()
        pdf.closePDF()

        // Read as a PDF document and encrypt with the provided password
        guard let pdfDocument = PDFDocument(url: url) else { return }
        pdfDocument.write(
            to: url,
            withOptions: [
                PDFDocumentWriteOption.userPasswordOption : password,
                PDFDocumentWriteOption.ownerPasswordOption : password
            ]
        )
    }

    return url
}

Spot it? It’s really subtle, so you’d be forgiven if you missed it (I did). The issue lies here:

guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
    return
}

Here, we initialize a CGContext to render the PDF into. The problem is the initializer. Let’s check out the documentation for it:

Screenshot of Apple's documentation for a URL-based CGContext

The key problem here is the URL parameter. When we render to a CGContext in this way, we are actually writing an unprotected version of our PDF to disk, after which we use PDFKit to overwrite that same file with the password protected version. In theory, an attacker could capture that unprotected file at some point between when we save it and overwrite it with the final version. Like I said, pretty subtle. Fortunately, the fix is pretty straightforward.

Patching the vulnerability

If the issue is that we are saving the PDF to a file and then overwriting that file, then it would seem that the solution is to write the PDF to some in-memory location, and then use PDFKit to securely save the password-protected version to disk. Handily, CoreGraphics provides an initializer for CGContext that allows us to do just that. Looking at the documentation, we find:

Screenshot of Apple's documentation for a URL-based CGContext

So instead of passing in a CFURL, it looks like we need to supply a CGDataConsumer. How do we do that? Well, since these are C APIs we’re working with, it can be a bit verbose, but it can be broken down as follows:

  1. Create a CFMutableData object that will be the buffer for the raw PDF bytes.
  2. Create a CGDataConsumer, passing in the CFMutableData object
  3. Create our new CGContext, passing in the CGDataConsumer object

Applying the fix

First, the CFMutableData:

guard let pdfData = CFDataCreateMutable(nil, 0) else { return }

Then, the CGDataConsumer:

guard let pdfDataConsumer = CGDataConsumer(data: pdfData),

Finally, the CGContext:

let pdf = CGContext(consumer: pdfDataConsumer, mediaBox: &box, nil)

Put together, the final, patched version of the render function:

func render() -> URL {
    let renderer = ImageRenderer(content: SecretsView())

    let url = URL.documentsDirectory.appending(path: "output.pdf")

    renderer.render { size, context in
        guard let pdfData = CFDataCreateMutable(nil, 0) else { return }
        var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)

        guard let pdfDataConsumer = CGDataConsumer(data: pdfData),
            let pdf = CGContext(consumer: pdfDataConsumer, mediaBox: &box, nil) else {
            return
        }

        pdf.beginPDFPage(nil)
        context(pdf)
        pdf.endPDFPage()
        pdf.closePDF()

        // Read as a PDF document and encrypt with the provided password
        guard let pdfDocument = PDFDocument(url: url) else { return }
        pdfDocument.write(
            to: url,
            withOptions: [
                PDFDocumentWriteOption.userPasswordOption : password,
                PDFDocumentWriteOption.ownerPasswordOption : password
            ]
        )
    }

    return url
}

And that’s it! We have now securely saved a SwiftUI view to a password-protected PDF file, minimal heartache.

All posts

Related Posts

View All Posts »
Fable Reminders - Part 2

Fable Reminders - Part 2

Build a reminders app completely in F#. In part 2, we model our domain using F#'s algebraic data types, bootstrap our application using Elmish, and take a deeper look at some functional programming concepts along the way.

Fable Reminders - Part 1

Fable Reminders - Part 1

Build a reminders app completely in F#. In part 1, we get our base project setup so we're ready to hit the ground running building in F#!