-
Notifications
You must be signed in to change notification settings - Fork 3
Description
The current draft (draft-ietf-hpke-hpke-02) provides clear language on:
- The requirement that ciphertexts be presented to Open() in the same order they were generated by Seal() calls
- The use of a sequence counter for nonce generation
- The automatic increment of the context's sequence number after each successful Seal() and Open()
However, the draft does not address the critical issue of operation atomicity when AEAD operations are made asynchronous, e.g. in a JavaScript runtime (Node.js, browsers, Deno, Cloudflare Workers), where cryptographic operations are either
- Inherently asynchronous (returning Promises through the Web Cryptography API)
- Dispatched to worker threads or separate execution contexts to avoid blocking the process event loop
Consider this naive javascript implementation based on the pseudo-code in the draft
async function ContextSSeal(aad, pt) {
const ct = await Seal(this.key, this.ComputeNonce(this.seq), aad, pt)
this.IncrementSeq()
return ct
}
async function ContextROpen(aad, ct) {
const pt = await Open(this.key, this.ComputeNonce(this.seq), aad, ct) // throws OpenError
this.IncrementSeq()
return pt
}These implementations are non-atomic with respect to sequence counter incrementation because if parallel execution comes into play it may entirely be the case that two ContextSSeal() or ContextROpen() are called "at the same time" and so this.ComputeNonce(this.seq) is called multiple times before each invocation's Seal()/Open() gets resolved.
This implementation then results in reusing the same sequence number, therefore the same nonce 💥.
Improving on this then?
async function ContextSSeal(aad, pt) {
- const ct = await Seal(this.key, this.ComputeNonce(this.seq), aad, pt)
+ const ct = Seal(this.key, this.ComputeNonce(this.seq), aad, pt)
this.IncrementSeq()
- return ct
+ return await ct
}
async function ContextROpen(aad, ct) {
- const pt = await Open(this.key, this.ComputeNonce(this.seq), aad, ct) // throws OpenError
+ const pt = Open(this.key, this.ComputeNonce(this.seq), aad, ct) // Promise rejecting with OpenError
this.IncrementSeq()
- return pt
+ return await pt
}This implementation no longer results in reusing the same sequence number, great 🎉 . But it still has two issues
- if either Seal() or Open() fail then the sequence number has already been incremented (and cannot be reverted since function context has no awareness of its paralllel use), this is generally a non-issue on the Sender side since Seal() is not expected to fail (but who knows...), but a Recipient may easily be tricked into processing a bad [ct, aad] that fails and then their entire context has to be discarded because its out of sync.
- (minor and js specific): if
this.IncrementSeq()throwsMessageLimitReachedErrorAND Seal() or Open() fail there will be a global unhandled rejection
So the only correct implementation in an inherently async environment is?
async function ContextSSeal(aad, pt) {
const release = await this.mutex.lock()
try {
const ct = await Seal(this.key, this.ComputeNonce(this.seq), aad, pt)
this.IncrementSeq()
return ct
} finally {
release()
}
}
async function ContextROpen(aad, ct) {
const release = await this.mutex.lock()
try {
const pt = await Open(this.key, this.ComputeNonce(this.seq), aad, ct) // throws OpenError
this.IncrementSeq()
return pt
} finally {
release()
}
}(That, or the entire ContextSSeal(), ContextROpen() context instance calls need to be synchronized with a mutex)
This is obviously not an issue for implementations that only expose single-shot APIs.
What do you think, would it make sense to add more guidance to the draft so that these naive implementations don't (re1)occur?
Footnotes
-
I've privately disclosed this issue to an HPKE implementation project recently ↩