AES-256-GCM decryption failure with AES.GCM.open in Swift

200 Views Asked by At

Quick background: I want the server to encrypt a small payload of data using public keys provided by clients so that the clients can decrypt it and the server doesn't have to store the sensitive data.

The basic workflow I've come to is:

  1. Have the client generate a public and private key
  2. Send the public key to the server in a request
  3. The server generates its own one-time key pair, encrypts the data with the client's public key
  4. Server responds with information necessary for the client to decrypt it.

I'm using ECDH and AES-256-GCM, as those seem to be well-regarded for the task.

With the client and server both being in Ruby, it works great. I also have a functioning JavaScript client using SubtleCrypto/WebCrypto. The server responds with:

  • the public key it used when deriving the shared secret
  • the IV used
  • ciphertext

The client needs only this and it can decrypt the data. Perfect.

The problem is now when trying to write a client in Swift. (This is also the first time I'm ever writing Swift, so bear with me). I'm doing a proof of concept using a simple CLI app and swift-crypto. Everything works fine up to and including computing the shared secret. This matches the secret the server used exactly, so that's good. When it comes to actually decrypting the data I get an error:

Crypto.CryptoKitError.underlyingCoreCryptoError(error: 503316581)
import Foundation

#if os(Linux)
import FoundationNetworking
import Crypto
#endif

extension Data {
  public func urlsafeBase64() -> String {
    return base64EncodedString()
      .replacingOccurrences(of: "+", with: "-")
      .replacingOccurrences(of: "/", with: "_")
      .replacingOccurrences(of: "=", with: "")
  }

  public init?(base64urlEncoded input: String) {
      var base64 = input
      base64 = base64.replacingOccurrences(of: "-", with: "+")
      base64 = base64.replacingOccurrences(of: "_", with: "/")
      while base64.count % 4 != 0 {
          base64 = base64.appending("=")
      }
      self.init(base64Encoded: base64)
  }
}

let ourPrivateKey = P256.KeyAgreement.PrivateKey()
let ourPublicKey = ourPrivateKey.publicKey

let reqData: [String: Any] = ["pem": ourPublicKey.pemRepresentation]

// SNIP -- HTTP interaction with the backend. Server returns:
// iv, ciphertext, tag, server's public key in PEM format
// Response loaded into `EncryptedData` struct.
// iv, tag, and ciphertext are all base64-urlsafe-encoded

struct EncryptedData: Codable {
  var ciphertext: String?
  var iv: String?
  var tag: String?
  var pem: String?
}

let serverPubKey = try! P256.KeyAgreement.PublicKey(pemRepresentation: ed.pem!)
let sharedSecret = try! ourPrivateKey.sharedSecretFromKeyAgreement(with: serverPubKey)
// At this point, we're good. sharedSecret is exactly the same as the server generates

// I don't have confidence in this next step. It's not a step I had to do
// in the Ruby or JavaScript implementations.
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
  using: SHA256.self,
  salt: Data(),
  sharedInfo: Data(),
  outputByteCount: 32
)

// In Ruby and JS this is called the IV. I'm guessing it's referred to as nonce in Swift?
let nonceData = Data(base64urlEncoded: ed.iv!)!
let ciphertextData = Data(base64urlEncoded: ed.ciphertext!)!
let tagData = Data(base64urlEncoded: ed.tag!)!

let sealedBox = try! AES.GCM.SealedBox(
  nonce: AES.GCM.Nonce(data: nonceData),
  ciphertext: ciphertextData,
  tag: tagData
)

let decrypted = try! AES.GCM.open(sealedBox, using: symmetricKey)
// Boom: Crypto.CryptoKitError.underlyingCoreCryptoError(error: 503316581)

So the question is: what am I missing here? Why is it that even though the client and server arrive at the same secret, decryption fails with such a vague error message?

If there's interest, here's the code on the backend I'm testing against:

def enc_test
  message = JSON.generate(ts: Time.now.to_s)

  user_pubkey = OpenSSL::PKey::EC.new(params.require("pem"))

  render(json: encrypt(message, user_pubkey))
end

private

ALGO = "aes-256-gcm"

def encode64(data) = Base64.urlsafe_encode64(data, padding: false)

def encrypt(message, user_pubkey)
  server_key = OpenSSL::PKey::EC.generate("prime256v1")
  derived_key = server_key.derive(user_pubkey)

  ciphertext, tag, iv = OpenSSL::Cipher.new(ALGO).encrypt.then do |c|
    iv = c.random_iv

    c.key = derived_key
    c.iv = iv
    [c.update(message) + c.final, c.auth_tag, iv]
  end

  {
    ciphertext: encode64(ciphertext),
    tag: encode64(tag),
    iv: encode64(iv),
    pem: server_key.to_public_pem,
  }
end
1

There are 1 best solutions below

0
mroach On

Of course right after posting this I gave it a re-think and figured it out. That step I wasn't sure about, deriving a key, was in fact the problem. The shared secret is the symmetric key.

Making this one change fixes it:

let symmetricKey = SymmetricKey(data: sharedSecret)