February 3, 2020·6 min read·engineeringswiftcryptographylinuxpackages

Swift Crypto brings CryptoKit APIs to Linux

Swift Crypto shares a cryptographic programming model across platforms while retaining independent backends and explicit limits around hardware-backed keys.

A shared cryptographic protocol becomes harder to maintain when its client and server use unrelated interfaces for the same operation. The byte formats must agree, but so must validation and error behavior. Swift Crypto, announced on February 3, brings the CryptoKit programming model beyond Apple platforms. It delegates to CryptoKit where that framework is available and supplies a BoringSSL-based implementation elsewhere. The public contract can therefore remain familiar even when the underlying cryptographic engine changes.

I like Swift's use of types to make invalid assumptions visible. Cryptography is a particularly useful place to apply that preference because buffer ownership and argument interpretation are part of correctness. A concise call helps when its parameters carry meaningful types. It does not remove the protocol decisions surrounding the call, including where a key comes from or who is entitled to use it.

The package has two compilation paths

The launch-day package description declares a library product named Crypto. Its targets include a vendored BoringSSL library and a shim target, with the Swift target depending on both. The package also contains its own tests. There are no external package dependencies listed in that manifest, because the cryptographic C implementation is included in the source distribution.

The corresponding README explains how platform selection changes the build. On Apple platforms with CryptoKit, the package re-exports the framework. On Linux, it builds the common Swift API and the backing implementation that calls BoringSSL. Some API code and algorithms written directly in Swift are shared with CryptoKit, while the BoringSSL backend belongs to Swift Crypto.

That arrangement has a useful consequence for application architecture. A module containing portable cryptographic operations can depend on Crypto without choosing a C implementation in each call site. Platform-dependent decisions stay inside the package. A module that manages hardware-backed keys still needs an explicit platform boundary, since an API wrapper cannot supply hardware missing from the target machine.

The README requires Swift 5.1 or later and describes the distribution as source-only, without a stable ABI. Its compatibility promise concerns the public API under semantic versioning. Consumers build the package as part of their own dependency graph. That is a different distribution contract from delivering a separately compiled binary framework, and it suits a library whose non-Apple implementation needs to be updated through ordinary package management.

A sealed box includes authentication

The launch-day AES-GCM implementation makes the operation's structure visible. seal accepts plaintext and a SymmetricKey, and returns a SealedBox. The nonce parameter is optional. Omitting it allows the implementation to choose a fresh nonce, which the announcement identifies as a protection against a common mistake in constructing encryption schemes.

A function can keep key management outside the encryption operation. Accepting an existing key makes the dependency explicit and lets the caller decide how the key is obtained:

import Foundation
import Crypto

func sealMessage(_ message: Data, using key: SymmetricKey) throws
    -> AES.GCM.SealedBox {
    try AES.GCM.seal(message, using: key)
}

func openMessage(_ box: AES.GCM.SealedBox, using key: SymmetricKey)
    throws -> Data {
    try AES.GCM.open(box, using: key)
}

The caller supplies the same key when opening the box. This example deliberately returns the structured result, so the function does not have to choose a storage format. In an application, the key must remain available through an appropriate management mechanism. Generating a new unrelated key for each decryption attempt would not recover the original message.

Both methods can throw. Opening authenticates the encrypted content as part of the operation, so successful parsing of a box does not establish that its contents are trustworthy. The error path belongs in the caller's protocol behavior. Treating any failure as permission to accept plaintext through another route would defeat the purpose of requiring authenticated encryption.

Serialization has a precise layout

SealedBox stores a combined representation internally and exposes the nonce, ciphertext and authentication tag as separate properties. The implementation uses a sixteen-byte tag and a default twelve-byte nonce. Its combined property concatenates the nonce, ciphertext and tag in that order. That property is optional because the combined format is only available for the default nonce length.

The initializer accepting combined data first checks that the input can contain the nonce and tag. The minimum is twenty-eight bytes, allowing a message with no plaintext bytes. This is a structural check. Actual authentication happens when the box is opened with a key. The distinction is useful when designing a decoder because constructing an object and accepting its message are separate stages.

A transport using the default representation can reconstruct a box before opening it. The throwing initializer and the throwing open call preserve both failure paths:

func decodeMessage(_ encoded: Data, using key: SymmetricKey) throws
    -> Data {
    let box = try AES.GCM.SealedBox(combined: encoded)
    return try AES.GCM.open(box, using: key)
}

That code relies on the documented combined layout. A protocol that carries the nonce and tag in separate fields can use the other initializer instead. The implementation checks the tag length there and retains the supplied nonce length. A protocol specification should say which representation it carries, since a common algorithm name alone does not define the bytes exchanged between implementations.

Associated data belongs to the protocol

AES-GCM also exposes overloads with an authenticating argument. The argument is additional data covered by authentication alongside the encrypted message. Both sealing and opening accept it through DataProtocol, so the API does not require the caller to pass one particular concrete collection representation.

For example, a protocol might authenticate a message header while encrypting its payload. The header must be available to both sides in the same representation. Changing its serialization between sealing and opening changes the authenticated input, even if an application considers the two decoded headers equivalent. Defining that representation is therefore part of defining the protocol.

This is one reason I prefer the higher-level API over manually arranging C buffers. The function signature distinguishes the plaintext, key and additional authenticated data. A reviewer can identify those roles without consulting a positional array of pointers. The application still has to decide which fields need authentication and whether an attacker can influence their encoding.

Apple's WWDC 2019 explanation of CryptoKit makes the same broader distinction between cryptographic operations and complete security solutions. It recommends system facilities for common needs such as transport protection and credential storage. CryptoKit remains powerful enough to implement an incorrect protocol. Providing an authenticated cipher does not decide authorization, identity or replay handling for its caller.

Compatibility includes rejected inputs

The announcement describes a shared test suite between CryptoKit and Swift Crypto. The aim is semantic agreement, including cases where BoringSSL's validation differs from the validation CryptoKit requires. That matters beyond producing matching ciphertext for a successful example. An interface also defines which encodings it rejects and what operations it permits on a key.

The launch README asks contributors of new algorithms to supply tests against available test vectors, including Wycheproof vectors where available. Bug fixes should include regression tests. These requirements give the common API a concrete verification process. For consumers, they also make a useful distinction between a documented compatibility goal and an assumption based only on matching type names.

Independent backends permit comparisons between implementations, while shared tests constrain their behavior. Maintaining that arrangement takes work whenever platform validation rules differ. The payoff is that portability can include those rules instead of leaving each application to discover discrepancies at its own client-server boundary.

The source also shows why checking a round trip alone is an incomplete test of interoperability. Sealing and opening with one implementation exercises its internal agreement. A second implementation may interpret a malformed representation differently or impose a different validation rule. Shared tests need to describe those boundary cases as well as ordinary messages. This follows directly from the project's stated requirement that the same API have the same semantics across backends.

A portable application can consequently keep its own tests focused on the protocol it defines. A header encoding, an accepted message version or a rule for choosing a key is application behavior. The library's test suite cannot decide those choices for the caller, even when it establishes agreement about AES-GCM itself.

The supported scope is intentionally limited

Swift Crypto excludes APIs that depend on Apple's Secure Enclave. Portable software cannot treat a software-held key and a hardware-managed key as interchangeable merely because both can support a signing operation. The project also explicitly declines to support every cryptographic algorithm. A smaller selection makes algorithm choice more constrained and gives maintainers a clearer set of implementations to test.

For a Swift application spanning Apple clients and Linux services, the immediate benefit is shared code around supported operations. Key storage and platform security remain separate design concerns. The package makes that boundary fairly clear, with the common API describing cryptographic work and the platform-specific code deciding how the underlying implementation performs it.