Skip to content

Commit 1656515

Browse files
refactor:split ODoH.Routine into server and client versions
1 parent a3f3d56 commit 1656515

File tree

2 files changed

+129
-90
lines changed

2 files changed

+129
-90
lines changed

Sources/ObliviousDoH/ODoHRoutine.swift

Lines changed: 123 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import Foundation
3333
/// // Handle error
3434
/// }
3535
///
36-
/// // 3. Initialize ODoH routine with selected configuration
37-
/// let clientRoutine = try ODoH.Routine(configuration: selectedConfig)
36+
/// // 3. Initialize ODoH client routine with selected configuration
37+
/// let clientRoutine = try ODoH.ClientRoutine(configuration: selectedConfig)
3838
///
3939
/// // 4. Create DNS query with padding for privacy
4040
/// let dnsQuery: Data = buildDNSQuery(domain: "example.com", type: .A)
@@ -59,7 +59,7 @@ import Foundation
5959
/// // 1. Server creates and publishes configuration
6060
/// let serverPrivateKey = Curve25519.KeyAgreement.PrivateKey()
6161
/// let serverConfig = try ODoH.Configuration.v1(privateKey: serverPrivateKey)
62-
/// let serverRoutine = try ODoH.Routine(configuration: serverConfig)
62+
/// let serverRoutine = try ODoH.ServerRoutine(configuration: serverConfig)
6363
///
6464
/// // 2. Publish configuration at well-known endpoint
6565
/// let configsToPublish: [ODoH.Configuration] = [serverConfig]
@@ -85,14 +85,14 @@ import Foundation
8585
/// // 6. Send encrypted response back through proxy to client...
8686
/// ```
8787
public enum ODoH: Sendable {
88-
/// ODoH cryptographic routine for encrypting queries and decrypting responses.
88+
/// Shared helpers for ODoH cryptographic routines.
8989
///
90-
/// Handles the client-side and server-side cryptographic operations for Oblivious DNS over HTTPS.
90+
/// Contains common functionality for both client and server ODoH operations.
9191
/// Initialized with a configuration containing the target's public key and algorithm parameters.
92-
public struct Routine {
93-
private var ct: HPKE.Ciphersuite
94-
private var pkR: any HPKEDiffieHellmanPublicKey
95-
private var keyID: Data
92+
internal struct RoutineCore {
93+
internal var ct: HPKE.Ciphersuite
94+
internal var pkR: any HPKEDiffieHellmanPublicKey
95+
internal var keyID: Data
9696

9797
/// Initialize ODoH encryption with target server configuration.
9898
///
@@ -101,7 +101,7 @@ public enum ODoH: Sendable {
101101
/// HKDF as specified in RFC 9230 Section 6.2.
102102
///
103103
/// - Parameter configuration: Target server's ODoH configuration
104-
public init(configuration: Configuration) throws {
104+
internal init(configuration: Configuration) throws {
105105
guard configuration.contents.aead != .exportOnly else {
106106
throw ObliviousDoHError.unsupportedHPKEParameters
107107
}
@@ -115,6 +115,83 @@ public enum ODoH: Sendable {
115115
self.keyID = configuration.contents.identifier
116116
}
117117

118+
/// Derive AEAD key and nonce for response encryption.
119+
///
120+
/// Uses HKDF Extract-and-Expand with query plaintext and response nonce as salt.
121+
/// Formula: Extract(Q_plain || len(nonce) || nonce, secret) → Expand for key/nonce.
122+
///
123+
/// - Parameters:
124+
/// - secret: Exported secret from HPKE context
125+
/// - queryPlain: Original query plaintext
126+
/// - responseNonce: Server-generated nonce
127+
/// - Returns: Derived AEAD key and nonce
128+
internal func deriveSecrets(
129+
secret: SymmetricKey,
130+
queryPlain: Data,
131+
responseNonce: Data
132+
) throws -> (key: SymmetricKey, nonce: Data) {
133+
// Build salt: Q_plain || len(resp_nonce) || resp_nonce
134+
var salt = Data()
135+
salt.reserveCapacity(queryPlain.count + 2 + responseNonce.count)
136+
salt.append(queryPlain)
137+
salt.append(bigEndianBytes: UInt16(responseNonce.count))
138+
salt.append(responseNonce)
139+
140+
// Extract PRK
141+
let prk = self.ct.kdf.extract(salt: salt, ikm: secret)
142+
143+
// Expand to get key and nonce
144+
let key = self.ct.kdf.expand(
145+
prk: prk,
146+
info: Data.oDoHKeyInfo,
147+
outputByteCount: self.ct.aead.keyByteCount
148+
)
149+
let nonce = self.ct.kdf.expand(
150+
prk: prk,
151+
info: Data.oDoHNonceInfo,
152+
outputByteCount: self.ct.aead.nonceByteCount
153+
)
154+
155+
return (key, Data(nonce))
156+
}
157+
158+
/// Construct Additional Authenticated Data (AAD) for AEAD operations.
159+
///
160+
/// Format: message_type (1 byte) || key_length (2 bytes) || key_data
161+
///
162+
/// - Parameters:
163+
/// - type: Message type (query or response)
164+
/// - key: Key identifier or response nonce
165+
/// - Returns: AAD bytes for AEAD operations
166+
internal func aad(_ type: Message.MessageType, key: Data) -> Data {
167+
var aad = Data()
168+
let keyLength = UInt16(key.count)
169+
aad.reserveCapacity(1 + 2 + key.count)
170+
aad.append(type.rawValue)
171+
aad.append(bigEndianBytes: keyLength)
172+
aad.append(key)
173+
return aad
174+
}
175+
}
176+
177+
/// ODoH client routine for encrypting queries and decrypting responses.
178+
///
179+
/// Handles the client-side cryptographic operations for Oblivious DNS over HTTPS.
180+
/// Initialized with a configuration containing the target's public key and algorithm parameters.
181+
public struct ClientRoutine {
182+
private var core: RoutineCore
183+
184+
/// Initialize ODoH client routine with target server configuration.
185+
///
186+
/// Extracts the HPKE ciphersuite parameters and derives the key identifier
187+
/// from the provided configuration. The key identifier is computed using
188+
/// HKDF as specified in RFC 9230 Section 6.2.
189+
///
190+
/// - Parameter configuration: Target server's ODoH configuration
191+
public init(configuration: Configuration) throws {
192+
self.core = try RoutineCore(configuration: configuration)
193+
}
194+
118195
/// Encrypt DNS query using HPKE for transmission through proxy.
119196
///
120197
/// Returns encrypted message and sender context needed for response decryption.
@@ -125,14 +202,14 @@ public enum ODoH: Sendable {
125202
queryPlain: MessagePlaintext
126203
) throws -> QueryEncryptionResult {
127204
var context = try HPKE.Sender(
128-
recipientKey: self.pkR,
129-
ciphersuite: self.ct,
205+
recipientKey: self.core.pkR,
206+
ciphersuite: self.core.ct,
130207
info: Data.oDoHQueryInfo
131208
)
132209

133210
let sealedData = try context.seal(
134211
queryPlain.encode(),
135-
authenticating: self.aad(.query, key: self.keyID)
212+
authenticating: self.core.aad(.query, key: self.core.keyID)
136213
)
137214
let encapsulatedKey = context.encapsulatedKey
138215

@@ -146,7 +223,7 @@ public enum ODoH: Sendable {
146223

147224
let message = Message(
148225
messageType: .query,
149-
keyID: self.keyID,
226+
keyID: self.core.keyID,
150227
encryptedMessage: encryptedMessage
151228
)
152229

@@ -178,20 +255,20 @@ public enum ODoH: Sendable {
178255
let responseEncrypted = response.encryptedMessage
179256

180257
// Derive secrets according to RFC
181-
let (aeadKey, aeadNonce) = try self.deriveSecrets(
258+
let (aeadKey, aeadNonce) = try self.core.deriveSecrets(
182259
secret: context.exportSecret(
183260
context: Data.oDoHResponseInfo,
184-
outputByteCount: self.ct.aead.keyByteCount
261+
outputByteCount: self.core.ct.aead.keyByteCount
185262
),
186263
queryPlain: queryPlain.encode(),
187264
responseNonce: responseNonce
188265
)
189266

190267
// Build AAD for response
191-
let aad = self.aad(.response, key: responseNonce)
268+
let aad = self.core.aad(.response, key: responseNonce)
192269

193270
// Decrypt using derived key/nonce (regular AEAD, not HPKE)
194-
var plaintext = try self.ct.aead.open(
271+
var plaintext = try self.core.ct.aead.open(
195272
responseEncrypted,
196273
nonce: aeadNonce,
197274
authenticating: aad,
@@ -262,6 +339,25 @@ public enum ODoH: Sendable {
262339
response: response
263340
)
264341
}
342+
}
343+
344+
/// ODoH server routine for decrypting queries and encrypting responses.
345+
///
346+
/// Handles the server-side cryptographic operations for Oblivious DNS over HTTPS.
347+
/// Initialized with a configuration containing the server's public key and algorithm parameters.
348+
public struct ServerRoutine {
349+
private var core: RoutineCore
350+
351+
/// Initialize ODoH server routine with server configuration.
352+
///
353+
/// Extracts the HPKE ciphersuite parameters and derives the key identifier
354+
/// from the provided configuration. The key identifier is computed using
355+
/// HKDF as specified in RFC 9230 Section 6.2.
356+
///
357+
/// - Parameter configuration: Server's ODoH configuration
358+
public init(configuration: Configuration) throws {
359+
self.core = try RoutineCore(configuration: configuration)
360+
}
265361

266362
/// Decrypt DNS query using server's private key.
267363
///
@@ -283,22 +379,22 @@ public enum ODoH: Sendable {
283379
}
284380

285381
var ciphertext = query.encryptedMessage
286-
guard let enc = ciphertext.popFirst(self.ct.kem.encapsulatedKeySize) else {
382+
guard let enc = ciphertext.popFirst(self.core.ct.kem.encapsulatedKeySize) else {
287383
throw CryptoKitError.incorrectParameterSize
288384
}
289385

290386
// Setup HPKE recipient context
291387
var context = try HPKE.Recipient(
292388
privateKey: privateKey,
293-
ciphersuite: self.ct,
389+
ciphersuite: self.core.ct,
294390
info: Data.oDoHQueryInfo,
295391
encapsulatedKey: enc
296392
)
297393

298394
// Decrypt query
299395
var plaintext = try context.open(
300396
ciphertext,
301-
authenticating: self.aad(.query, key: self.keyID)
397+
authenticating: self.core.aad(.query, key: self.core.keyID)
302398
)
303399

304400
return QueryDecryptionResult(plaintextQuery: try MessagePlaintext(decoding: &plaintext), context: context)
@@ -337,25 +433,25 @@ public enum ODoH: Sendable {
337433
responsePlain: MessagePlaintext
338434
) throws -> Data {
339435
// Generate response nonce: random(max(Nn, Nk))
340-
let nonceSize = self.ct.aead.nonceByteCount
341-
let keySize = self.ct.aead.keyByteCount
436+
let nonceSize = self.core.ct.aead.nonceByteCount
437+
let keySize = self.core.ct.aead.keyByteCount
342438
let responseNonceSize = max(nonceSize, keySize)
343439
let responseNonce = Data((0..<responseNonceSize).map { _ in UInt8.random(in: 0...255) })
344440

345441
// Derive secrets
346-
let (aeadKey, aeadNonce) = try self.deriveSecrets(
442+
let (aeadKey, aeadNonce) = try self.core.deriveSecrets(
347443
secret: recipient.exportSecret(
348444
context: Data.oDoHResponseInfo,
349-
outputByteCount: self.ct.aead.keyByteCount
445+
outputByteCount: self.core.ct.aead.keyByteCount
350446
),
351447
queryPlain: queryPlain.encode(),
352448
responseNonce: responseNonce
353449
)
354450

355451
// Encrypt response using derived keys (regular AEAD)
356-
let encrypted = try self.ct.aead.seal(
452+
let encrypted = try self.core.ct.aead.seal(
357453
responsePlain.encode(),
358-
authenticating: self.aad(.response, key: responseNonce),
454+
authenticating: self.core.aad(.response, key: responseNonce),
359455
nonce: aeadNonce,
360456
using: aeadKey
361457
)
@@ -392,64 +488,6 @@ public enum ODoH: Sendable {
392488
responsePlain: responsePlain
393489
)
394490
}
395-
396-
/// Derive AEAD key and nonce for response encryption.
397-
///
398-
/// Uses HKDF Extract-and-Expand with query plaintext and response nonce as salt.
399-
/// Formula: Extract(Q_plain || len(nonce) || nonce, secret) → Expand for key/nonce.
400-
///
401-
/// - Parameters:
402-
/// - secret: Exported secret from HPKE context
403-
/// - queryPlain: Original query plaintext
404-
/// - responseNonce: Server-generated nonce
405-
/// - Returns: Derived AEAD key and nonce
406-
private func deriveSecrets(
407-
secret: SymmetricKey,
408-
queryPlain: Data,
409-
responseNonce: Data
410-
) throws -> (key: SymmetricKey, nonce: Data) {
411-
// Build salt: Q_plain || len(resp_nonce) || resp_nonce
412-
var salt = Data()
413-
salt.reserveCapacity(queryPlain.count + 2 + responseNonce.count)
414-
salt.append(queryPlain)
415-
salt.append(bigEndianBytes: UInt16(responseNonce.count))
416-
salt.append(responseNonce)
417-
418-
// Extract PRK
419-
let prk = self.ct.kdf.extract(salt: salt, ikm: secret)
420-
421-
// Expand to get key and nonce
422-
let key = self.ct.kdf.expand(
423-
prk: prk,
424-
info: Data.oDoHKeyInfo,
425-
outputByteCount: self.ct.aead.keyByteCount
426-
)
427-
let nonce = self.ct.kdf.expand(
428-
prk: prk,
429-
info: Data.oDoHNonceInfo,
430-
outputByteCount: self.ct.aead.nonceByteCount
431-
)
432-
433-
return (key, Data(nonce))
434-
}
435-
436-
/// Construct Additional Authenticated Data (AAD) for AEAD operations.
437-
///
438-
/// Format: message_type (1 byte) || key_length (2 bytes) || key_data
439-
///
440-
/// - Parameters:
441-
/// - type: Message type (query or response)
442-
/// - key: Key identifier or response nonce
443-
/// - Returns: AAD bytes for AEAD operations
444-
private func aad(_ type: Message.MessageType, key: Data) -> Data {
445-
var aad = Data()
446-
let keyLength = UInt16(key.count)
447-
aad.reserveCapacity(1 + 2 + key.count)
448-
aad.append(type.rawValue)
449-
aad.append(bigEndianBytes: keyLength)
450-
aad.append(key)
451-
return aad
452-
}
453491
}
454492

455493
// - MARK: Protocol Types

Tests/ObliviousDoHTests/ObliviousDoHTests.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,32 +126,33 @@ final class ObliviousDoHTests: XCTestCase {
126126
let serverPrivateKey = Curve25519.KeyAgreement.PrivateKey.init()
127127
let configuration = try ODoH.Configuration.v1(privateKey: serverPrivateKey)
128128

129-
let routine = try ODoH.Routine(configuration: configuration)
129+
let clientRoutine = try ODoH.ClientRoutine(configuration: configuration)
130+
let serverRoutine = try ODoH.ServerRoutine(configuration: configuration)
130131

131132
let query = ODoH.MessagePlaintext(dnsMessage: Data(request.utf8), paddingLength: 128)
132-
let queryEncryptResult = try routine.encryptQuery(
133+
let queryEncryptResult = try clientRoutine.encryptQuery(
133134
queryPlain: query
134135
)
135136

136137
XCTAssertNotEqual(query.encode(), queryEncryptResult.encryptedQuery)
137138

138-
let queryDecryptResult = try routine.decryptQuery(
139+
let queryDecryptResult = try serverRoutine.decryptQuery(
139140
queryData: queryEncryptResult.encryptedQuery,
140141
privateKey: serverPrivateKey
141142
)
142143

143144
XCTAssertEqual(query, queryDecryptResult.plaintextQuery)
144145

145146
let response = ODoH.MessagePlaintext(dnsMessage: Data(responseText.utf8), paddingLength: 64)
146-
let encryptedResponse = try routine.encryptResponse(
147+
let encryptedResponse = try serverRoutine.encryptResponse(
147148
queryDecryptionResult: queryDecryptResult,
148149
responsePlain: response
149150
)
150151

151152
XCTAssertNotEqual(response.encode(), encryptedResponse)
152153

153154
// 4. Client decrypts response
154-
let decryptedResponse = try routine.decryptResponse(
155+
let decryptedResponse = try clientRoutine.decryptResponse(
155156
queryEncryptionResult: queryEncryptResult,
156157
queryPlain: query,
157158
responseData: encryptedResponse

0 commit comments

Comments
 (0)