Skip to content

Conversation

@yigityazicilar
Copy link
Contributor

Add Oblivious DNS-over-HTTPS types and serialization

Motivation:

This change adds the types and wire format serialization for
Oblivious DNS-over-HTTPS (ODoH) as specified in RFC 9230, providing the data
structures needed for privacy-preserving DNS resolution through HPKE encryption
and proxy routing.

Modifications:

  • Added ObliviousDoH target to Package.swift with dependencies on
    ObliviousXHelpers
  • Created comprehensive type system with Configuration, MessagePlaintext, and
    Message structs
  • Implemented ODoH.Codable protocol for bidirectional wire format conversion
  • Added detailed error handling with ObliviousDoHError types for protocol
    violations
  • Added test suite covering configuration parsing, message serialization, and
    error cases

Result:

The library provides complete ODoH type infrastructure with
RFC-compliant wire format serialization, enabling future implementation of the
encryption/decryption routine.

Comment on lines 30 to 38
@inline(never)
public static func unsupportedHPKEParameters() -> ObliviousDoHError {
Self.init(backing: .unsupportedHPKEParameters)
}

@inline(never)
public static func invalidODoHData() -> ObliviousDoHError {
Self.init(backing: .invalidODoHData)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two can be computed static vars, no need for them to be functions.

/// HPKE sender context required for response decryption
public let context: HPKE.Sender

internal init(encryptedQuery: Data, context: HPKE.Sender) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are all the inits internal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QueryEncryptionResult and QueryDecryptionResult should only be created by encrypting or decrypting a query. So only the ODoH HPKE logic will create these.

/// Provides bidirectional conversion between Swift types and their network representation
/// as specified in RFC 9230. All ODoH message types implement this protocol to enable
/// consistent serialization and parsing across the protocol stack.
public protocol Codable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this name is quite right, it's quite different to Swift.Codable.

/// Initialize from wire format bytes, consuming data as it parses
/// - Parameter bytes: The raw network data to parse (consumed during parsing)
/// - Returns: `nil` if parsing fails or data is invalid
init?(_ bytes: inout Data)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably have a parameter label, maybe something like init?(decoding data: inout Data)

public protocol Codable {
/// Initialize from wire format bytes, consuming data as it parses
/// - Parameter bytes: The raw network data to parse (consumed during parsing)
/// - Returns: `nil` if parsing fails or data is invalid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know prior to decoding if we'll have enough bytes?

There's also some asymmetry here: encode() throws on failure but here we just return nil.

In NIO-land nil means "not enough bytes" and throws signals parsing failure. (If nil is returned no bytes would've been consumed so it's safe to wait for more bytes and try again.) Is there any reason to not do the same model here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we do know prior to decoding if we'll have enough bytes. The ODoH
configuration format includes explicit length fields at every level:

  1. Configurations level: totalLength (2 bytes)
  2. Each Configuration: length (2 bytes) for contents size
  3. ConfigurationContents: keyLength (2 bytes)

This is exactly what we do at
ODoHRoutine.swift:352:

let totalLength = data.popUInt16(),
var configsData = data.popFirst(Int(totalLength))

ODoHRoutine.swift:470:

let length = bytes.popUInt16(),
var contentsBytes = bytes.popFirst(Int(length))  // Pop the entire object

ODoHRoutine.swift:552:

let keyLength = bytes.popUInt16(),
let key = bytes.popFirst(Int(keyLength)),

So, we only return nil when we actually don't have enough data as the only error we cannot recover from is invalidODoHData. The rest of the errors correctly consume the number of bytes so we can continue parsing the Configurations object until we have a valid Configuration we can handle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we check for this error which gets throws if we don't have enough data.
ODoHRoutine.swift:372:

case .failure(let error):
    if error == ObliviousDoHError.invalidODoHData() {
        break
    }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. In that case the method should be documented to say something to that effect.

Also, why nil vs. throws?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing strategy prioritizes returning usable configurations even when some fail:

  • If we parse 3 configurations and the first 2 are valid but the 3rd fails with unsupportedHPKEParameters, we still return the first 2 valid ones
  • When we get invalidODoHData, it means we're at the end of readable bytes and cannot read more, so everything else is a failure. But we can still return the configurations parsed previously.
  • We only return nil when there are zero usable configurations

This differs from NIO's typical "nil = need more bytes" pattern because we're parsing a collection where partial success is valuable. In ODoH, having some working configurations is better than failing completely due to one bad entry or hitting the end of available data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've described the behaviour of the caller, which is equally achievable with catching errors as it is with checking for nil.

The general guidance is that you should only return nil from inits where the failure is trivial (e.g. Int("not-an-int") returning nil is fine because there's only one path to failure: the string doesn't represent an integer).

Anything more complicated should throw an error. This gives the caller more control as they can inspect different error cases and act appropriately. This could just be doing let x = try? SomeFailableInit().

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the docs here are outdated since this isn't a failable init anymore

/// Creates a new ODoH configuration with the specified contents backing.
///
/// - Parameter contentsBacking: The version-specific contents backing
internal init(contentsBacking: ContentsBacking) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason this shouldn't be private?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is private because it is internally using an enum. Currently ODoH has one version however each new version could potentially have a new contents structure.

The helper function (ODoH.Configuration.v1()) below to create a v1 configuration should be used for the server side and the client should only get the configuration from the server side.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, I'm asking why the init is currently internal and not private?

Comment on lines 269 to 275
public static func query() -> Self {
Self(rawValue: 1)
}

public static func response() -> Self {
Self(rawValue: 2)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can be computed static vars

let totalLength = data.popUInt16(),
var configsData = data.popFirst(Int(totalLength))
else {
return ODoH.ConfigurationParsingResult(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should data be re-instated with fullData here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it fails there the entire Configurations structure is invalid so I am not sure if we should reset it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this is based on the expectation that the data contains enough bytes? If so this should be documented.

data.append(bigEndianBytes: UInt16(self.dnsMessage.count)) // 2 bytes: DNS length
data.append(self.dnsMessage) // Variable: DNS data
data.append(bigEndianBytes: UInt16(self.paddingLength)) // 2 bytes: padding length
data.append(contentsOf: .init(repeating: 0, count: self.paddingLength))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we .init-ing here to append? Just want to make sure we're not having an avoidable temporary allocation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is just 0 bytes repeated paddingLength times. There might be a better way to add that to Data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what type are we .init ing? I suspect its Data/Array.

Either way it'd be a temporary allocation we can avoid with:

data.append(contentsOf: repeatElement(0, count: self.paddingLength))

Comment on lines 676 to 689
/// Used to derive the key identifier from the target's public key configuration
private let ODoHKeyIDInfo = Data("odoh key id".utf8)

/// Used as HPKE info parameter when setting up encryption context for DNS queries
private let ODoHQueryInfo = Data("odoh query".utf8)

/// Used when exporting secrets from HPKE context for response encryption
private let ODoHResponseInfo = Data("odoh response".utf8)

/// Used to derive the AEAD key for encrypting/decrypting DNS responses
private let ODoHKeyInfo = Data("odoh key".utf8)

/// Used to derive the AEAD nonce for encrypting/decrypting DNS responses
private let ODoHNonceInfo = Data("odoh nonce".utf8) No newline at end of file
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top-level constants are generally other be avoided. Better to extend Data instead:

extension Data {
  fileprivate static let oDoHKeyIDInfo = Self(...)
  // ...
}

@yigityazicilar yigityazicilar requested a review from glbrntt August 26, 2025 09:56
public protocol Codable {
/// Initialize from wire format bytes, consuming data as it parses
/// - Parameter bytes: The raw network data to parse (consumed during parsing)
/// - Returns: `nil` if parsing fails or data is invalid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. In that case the method should be documented to say something to that effect.

Also, why nil vs. throws?

public var version: Int {
contentsBacking.version
}
// length prefix (UInt16)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. Code comments should provide enough context for any reader/maintainer, not just enough for the author.

/// Creates a new ODoH configuration with the specified contents backing.
///
/// - Parameter contentsBacking: The version-specific contents backing
internal init(contentsBacking: ContentsBacking) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, I'm asking why the init is currently internal and not private?

let totalLength = data.popUInt16(),
var configsData = data.popFirst(Int(totalLength))
else {
return ODoH.ConfigurationParsingResult(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this is based on the expectation that the data contains enough bytes? If so this should be documented.

data.append(bigEndianBytes: UInt16(self.dnsMessage.count)) // 2 bytes: DNS length
data.append(self.dnsMessage) // Variable: DNS data
data.append(bigEndianBytes: UInt16(self.paddingLength)) // 2 bytes: padding length
data.append(contentsOf: .init(repeating: 0, count: self.paddingLength))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what type are we .init ing? I suspect its Data/Array.

Either way it'd be a temporary allocation we can avoid with:

data.append(contentsOf: repeatElement(0, count: self.paddingLength))

Comment on lines 672 to 684
fileprivate static let ODoHKeyIDInfo = Self("odoh key id".utf8)

/// Used as HPKE info parameter when setting up encryption context for DNS queries
fileprivate static let ODoHQueryInfo = Self("odoh query".utf8)

/// Used when exporting secrets from HPKE context for response encryption
fileprivate static let ODoHResponseInfo = Self("odoh response".utf8)

/// Used to derive the AEAD key for encrypting/decrypting DNS responses
fileprivate static let ODoHKeyInfo = Self("odoh key".utf8)

/// Used to derive the AEAD nonce for encrypting/decrypting DNS responses
fileprivate static let ODoHNonceInfo = Self("odoh nonce".utf8)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As properties these should all start with a lowercase: oDoH...

@yigityazicilar yigityazicilar requested a review from glbrntt August 26, 2025 14:27
public protocol Codable {
/// Initialize from wire format bytes, consuming data as it parses
/// - Parameter bytes: The raw network data to parse (consumed during parsing)
/// - Returns: `nil` if parsing fails or data is invalid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the docs here are outdated since this isn't a failable init anymore

@glbrntt glbrntt enabled auto-merge (squash) September 5, 2025 16:01
@glbrntt glbrntt disabled auto-merge September 5, 2025 16:02
@glbrntt glbrntt added the 🆕 semver/minor Adds new public API. label Sep 9, 2025
@glbrntt glbrntt enabled auto-merge (squash) September 9, 2025 11:52
@glbrntt glbrntt merged commit b4e1b44 into apple:main Sep 9, 2025
34 of 35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🆕 semver/minor Adds new public API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants