Why We Don't Use Raw ECDH Output: HKDF Session Key Derivation
After X25519 key agreement, both devices have the same 32-byte shared secret. TermOnMac does not use this directly as an AES key. Instead, it runs it through HKDF-SHA256 with a salt constructed from both session nonces and a version-tagged domain separator.
The Protocol Versions
TermOnMac has two key derivation paths, identifiable by the salt prefix:
v1 (identity keys):
// mac_agent/Sources/RemoteDevCore/SessionCrypto.swift
let salt = Data("remotedev-v1".utf8) + Data(sortedNonces.joined().utf8)
self.sessionKey = shared.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: salt,
sharedInfo: Data(),
outputByteCount: 32
)
v2 (ephemeral keys — current):
public func deriveSessionKeyEphemeral(
ephemeralPrivateKey: Curve25519.KeyAgreement.PrivateKey,
peerEphemeralKeyBase64: String,
localNonce: String,
remoteNonce: String
) throws {
let peerKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerKeyData)
let shared = try ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: peerKey)
let sortedNonces = [localNonce, remoteNonce].sorted()
let salt = Data("remotedev-v2".utf8) + Data(sortedNonces.joined().utf8)
self.sessionKey = shared.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: salt,
sharedInfo: Data(),
outputByteCount: 32
)
}
v1 uses the identity key pair for ECDH. v2 uses a per-connection ephemeral key pair. The "remotedev-v2" prefix ensures a v2 derivation produces a different key than a v1 derivation even with identical nonces.
Salt Construction
salt = "remotedev-v2" || sort([localNonce, remoteNonce]).join()
Both nonces are sorted lexicographically before joining. This makes the salt computation order-independent — both the Mac and iPhone produce the same salt without a protocol round-trip to agree on ordering:
let sortedNonces = [localNonce, remoteNonce].sorted()
let salt = Data("remotedev-v2".utf8) + Data(sortedNonces.joined().utf8)
Each session nonce is 32 random bytes, base64-encoded, unique per connection:
// relay_server/src/types.ts
session_nonce?: string; // Base64-encoded 32 random bytes, unique per connection
Because nonces change per connection, the HKDF output changes per connection even if the same ephemeral keys were reused.
HKDF Output
hkdfDerivedSymmetricKey with outputByteCount: 32 produces a 256-bit key for AES-256-GCM. The sharedInfo parameter is empty (Data()).
AES-256-GCM Encryption
The derived key is used directly with AES-GCM. Each encrypted message includes its own 12-byte random nonce:
public func encrypt(_ data: Data) throws -> Data {
guard let key = sessionKey else { throw CryptoError.noSessionKey }
let nonce = AES.GCM.Nonce() // 12 random bytes, new per message
let sealed = try AES.GCM.seal(data, using: key, nonce: nonce)
return nonce.withUnsafeBytes { Data($0) } + sealed.ciphertext + sealed.tag
}
Wire format: [12-byte GCM nonce | ciphertext | 16-byte authentication tag]
Decryption validates the minimum length before attempting to parse:
public func decrypt(_ data: Data) throws -> Data {
guard let key = sessionKey else { throw CryptoError.noSessionKey }
guard data.count > 28 else { throw CryptoError.invalidData }
// minimum: 12 (nonce) + 0 (empty plaintext) + 16 (tag) = 28 bytes
let nonce = try AES.GCM.Nonce(data: data[0..<12])
let ciphertext = data[12..<(data.count - 16)]
let tag = data[(data.count - 16)...]
let box = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag)
return try AES.GCM.open(box, using: key)
}
The GCM nonce is per-message (prevents ciphertext reuse within a session). The HKDF nonce is per-connection (binds the session key to a specific connection).
Session Key Lifecycle
/// Clear the derived session key from memory.
public func clearSessionKey() {
sessionKey = nil
}
clearSessionKey() is called when a connection ends. The session key is not persisted to disk — it exists only in memory for the duration of the connection.