Skip to content

Commit 3070b71

Browse files
seebeesrobin-aws
authored andcommitted
fix: Boundary condition error in VerifyStream (#509)
Fixes: #507 Tests exist that push a single byte at a time. This will hit every boundary condition. However this is not complete enough. State can be reset as the code transitions from one boundary condition to another. The prime example of this problem the `VerifyStream`. It has 4 boundary in the `_transform` function. The body header, the body, the auth tag, and signature. By sending 1 byte as the code transitions from the body header to the body the code handling the exiting of the body header has no way to interact with the body, because the 1 byte of data completely isolates these code branches. In this case the code was buffering the body header in `frameBuffer` but when exiting this section and passing control to the actual content section, it used the `chunk` to form the tail. When passing a single byte, this tail would _always_ be empty, because it would have been incorporated into the frame header. Adding tests to span the boundary conditions to verify both this fix and that there are not other lurking.
1 parent a27c137 commit 3070b71

File tree

2 files changed

+108
-26
lines changed

2 files changed

+108
-26
lines changed

modules/decrypt-node/src/verify_stream.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export class VerifyStream extends PortableTransformWithType {
163163
if (this._verify) {
164164
this._verify.update(frameBuffer.slice(0, frameHeader.readPos))
165165
}
166-
const tail = chunk.slice(frameHeader.readPos)
166+
const tail = frameBuffer.slice(frameHeader.readPos)
167167
this.emit('BodyInfo', frameHeader)
168168
state.currentFrame = frameHeader
169169
return setImmediate(() => this._transform(tail, enc, callback))
@@ -219,7 +219,7 @@ export class VerifyStream extends PortableTransformWithType {
219219
*/
220220
this._verifyState.finalAuthTagReceived = true
221221
/* Overwriting the _transform function.
222-
* Data flow control is not handled here.
222+
* Data flow control is now handled here.
223223
*/
224224
this._transform = (
225225
chunk: Buffer,

modules/decrypt-node/test/decrypt.test.ts

Lines changed: 106 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,33 +55,86 @@ describe('decrypt', () => {
5555
fixtures.base64CiphertextAlgAes256GcmIv12Tag16HkdfSha384EcdsaP384(),
5656
'base64'
5757
)
58-
const i = ciphertext.values()
59-
const ciphertextStream = from(
60-
(
61-
_: number,
62-
next: (err: Error | null, chunk: Uint8Array | null) => void
63-
) => {
64-
/* Pushing 1 byte at time is the most annoying thing.
65-
* This is done intentionally to hit _every_ boundary condition.
66-
*/
67-
const { value, done } = i.next()
68-
if (done) return next(null, null)
69-
next(null, new Uint8Array([value]))
70-
}
71-
)
7258

73-
const { plaintext: test, messageHeader } = await decrypt(
74-
fixtures.decryptKeyring(),
75-
ciphertextStream
59+
/* Pushing 1 byte at time is annoying.
60+
* But there can be state that can get reset
61+
* from a 1 byte push when the buffering
62+
* is internal to the stream.
63+
* So while 1 byte will hit the beginning
64+
* boundary conditions,
65+
* and the exiting boundary conditions,
66+
* but it will never span any boundary conditions.
67+
* The prime example of this is
68+
* the `VerifyStream`.
69+
* It has 4 byte boundary in the `_transform` function.
70+
* The body header, the body, the auth tag, and signature.
71+
* By sending 1 byte
72+
* as the code transitions from the body header
73+
* to the body
74+
* the code handling the exiting
75+
* of the body header has no way to interact
76+
* with the body,
77+
* because the 1 byte of data
78+
* completely isolates these code branches.
79+
* So I push at different sizes to hit
80+
* as many boundary conditions as possible.
81+
* Why 20 as a max?
82+
* Permuting every possible combination
83+
* would take too long
84+
* and so anything short of _everything_
85+
* is a compromise.
86+
* 20 seems large enough.
87+
* I did not do an initial offset
88+
* because that just moves me closer
89+
* to permuting every option.
90+
* An alternative would be a variable chunk size.
91+
* Doing this randomly is a bad idea.
92+
* Tests that only fail _sometimes_
93+
* are bad tests.
94+
* It is too easy to try again,
95+
* and when everything passes just move on.
96+
* And again, doing every permutation
97+
* is too expensive at this time.
98+
*/
99+
const results = await Promise.all(
100+
[
101+
{ size: 1 },
102+
{ size: 2 },
103+
{ size: 3 },
104+
{ size: 4 },
105+
{ size: 5 },
106+
{ size: 6 },
107+
{ size: 7 },
108+
{ size: 8 },
109+
{ size: 9 },
110+
{ size: 10 },
111+
{ size: 11 },
112+
{ size: 12 },
113+
{ size: 13 },
114+
{ size: 14 },
115+
{ size: 15 },
116+
{ size: 16 },
117+
{ size: 17 },
118+
{ size: 18 },
119+
{ size: 19 },
120+
{ size: 20 },
121+
].map(async (op) =>
122+
decrypt(
123+
fixtures.decryptKeyring(),
124+
chunkCipherTextStream(ciphertext, op)
125+
)
126+
)
76127
)
77128

78-
expect(messageHeader.suiteId).to.equal(
79-
AlgorithmSuiteIdentifier.ALG_AES256_GCM_IV12_TAG16_HKDF_SHA384_ECDSA_P384
80-
)
81-
expect(messageHeader.encryptionContext).to.deep.equal(
82-
fixtures.encryptionContext()
83-
)
84-
expect(test.toString('base64')).to.equal(fixtures.base64Plaintext())
129+
results.map(({ plaintext: test, messageHeader }) => {
130+
expect(messageHeader.suiteId).to.equal(
131+
AlgorithmSuiteIdentifier.ALG_AES256_GCM_IV12_TAG16_HKDF_SHA384_ECDSA_P384
132+
)
133+
expect(messageHeader.encryptionContext).to.deep.equal(
134+
fixtures.encryptionContext()
135+
)
136+
expect(test.toString('base64')).to.equal(fixtures.base64Plaintext())
137+
})
85138
})
86139

87140
it('Precondition: The sequence number is required to monotonically increase, starting from 1.', async () => {
@@ -121,3 +174,32 @@ describe('decrypt', () => {
121174
).to.rejectedWith(Error, 'maxBodySize exceeded.')
122175
})
123176
})
177+
178+
function chunkCipherTextStream(ciphertext: Buffer, { size }: { size: number }) {
179+
const i = ciphertext.values()
180+
181+
return from(
182+
(
183+
_: number,
184+
next: (err: Error | null, chunk: Uint8Array | null) => void
185+
) => {
186+
const { value, done } = eat(i, size)
187+
if (done) return next(null, null)
188+
next(null, new Uint8Array(value))
189+
}
190+
)
191+
192+
function eat(i: IterableIterator<number>, size: number) {
193+
return Array(size)
194+
.fill(1)
195+
.reduce<IteratorResult<number[], any>>(
196+
({ value }) => {
197+
const { value: item, done } = i.next()
198+
if (done && value.length) return { value, done: false }
199+
if (!done) value.push(item)
200+
return { value, done }
201+
},
202+
{ value: <number[]>[], done: false }
203+
)
204+
}
205+
}

0 commit comments

Comments
 (0)