-
Notifications
You must be signed in to change notification settings - Fork 14
Add Oblivious DNS-over-HTTPS types and serialization #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
| @inline(never) | ||
| public static func unsupportedHPKEParameters() -> ObliviousDoHError { | ||
| Self.init(backing: .unsupportedHPKEParameters) | ||
| } | ||
|
|
||
| @inline(never) | ||
| public static func invalidODoHData() -> ObliviousDoHError { | ||
| Self.init(backing: .invalidODoHData) | ||
| } |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
Configurationslevel:totalLength(2 bytes)- Each
Configuration:length(2 bytes) for contents size 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 objectODoHRoutine.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.
There was a problem hiding this comment.
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
}There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
nilwhen 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.
There was a problem hiding this comment.
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().
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
| public static func query() -> Self { | ||
| Self(rawValue: 1) | ||
| } | ||
|
|
||
| public static func response() -> Self { | ||
| Self(rawValue: 2) | ||
| } |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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))| /// 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 |
There was a problem hiding this comment.
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(...)
// ...
}| 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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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))| 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) |
There was a problem hiding this comment.
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...
| 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 |
There was a problem hiding this comment.
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
Co-authored-by: Gus Cairo <[email protected]>
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:
ObliviousXHelpers
Message structs
violations
error cases
Result:
The library provides complete ODoH type infrastructure with
RFC-compliant wire format serialization, enabling future implementation of the
encryption/decryption routine.