Skip to content

Commit b7dc81e

Browse files
authored
fix: sequence number order (#158)
The sequence number **must** be honored. This is especially important for non-signed algorithm suites. The test vector added to this PR illustrates this. It is the encrypted string `’asdf’` with a frame length of 1. The frames have been rearranged to `’fdsa’`. Without this change the ESDK would decrypt this blob.
1 parent a2f2ed9 commit b7dc81e

File tree

8 files changed

+93
-4
lines changed

8 files changed

+93
-4
lines changed

modules/decrypt-browser/src/decrypt.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,21 @@ interface FramedDecryptOptions extends BodyDecryptOptions {
100100
async function bodyDecrypt ({ buffer, getSubtleDecrypt, headerInfo }: BodyDecryptOptions) {
101101
let readPos = headerInfo.headerIv.byteLength + headerInfo.rawHeader.byteLength + headerInfo.headerAuthTag.byteLength
102102
const clearBuffers: ArrayBuffer[] = []
103+
let sequenceNumber = 0
103104
while (true) {
105+
/* Keeping track of the sequence number myself. */
106+
sequenceNumber += 1
107+
104108
const { clearBlob, frameInfo } = await framedDecrypt({ buffer, getSubtleDecrypt, headerInfo, readPos })
109+
110+
/* Precondition: The sequenceNumber is required to monotonically increase, starting from 1.
111+
* This is to avoid a bad actor from abusing the sequence number on un-signed algorithm suites.
112+
* If the frame size matched the data format (say NDJSON),
113+
* then the data could be significantly altered just by rearranging the frames.
114+
* Non-framed data returns a sequenceNumber of 1.
115+
*/
116+
needs(frameInfo.sequenceNumber === sequenceNumber, 'Encrypted body sequence out of order.')
117+
105118
clearBuffers.push(clearBlob)
106119
readPos = frameInfo.readPos
107120
if (frameInfo.isFinalFrame) {

modules/decrypt-browser/test/decrypt.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,15 @@ describe('decrypt', () => {
3232
expect(messageHeader.encryptionContext).to.deep.equal(fixtures.encryptionContext())
3333
expect(test).to.deep.equal(fixtures.plaintext())
3434
})
35+
36+
it('Precondition: The sequence number is required to monotonically increase, starting from 1.', async () => {
37+
return decrypt(
38+
fixtures.decryptKeyring(),
39+
fixtures.frameSequenceOutOfOrder()
40+
).then(() => {
41+
throw new Error('should not succeed')
42+
}, err => {
43+
expect(err).to.be.instanceOf(Error)
44+
})
45+
})
3546
})

modules/decrypt-browser/test/fixtures.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ export function encryptionContext () {
3838
}
3939
}
4040

41+
export function frameSequenceOutOfOrder () {
42+
/* This is the string 'asdf' encrypted with ALG_AES128_GCM_IV12_TAG16.
43+
* The data key is all zeros.
44+
* The frames have been reordered in order to test decryption failure.
45+
*/
46+
return new Uint8Array([
47+
1, 128, 0, 20, 111, 198, 208, 129, 64, 41, 115, 91, 247, 27, 148, 148, 102, 70, 149, 210, 0, 0, 0, 1, 0, 1, 107, 0, 1, 107, 0, 3, 0, 0, 0, 2, 0, 0, 0, 0, 12, 0, 0, 0, 1, // header
48+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 178, 85, 20, 93, 96, 34, 206, 116, 216, 115, 183, 217, 192, 84, 155, 15, // header auth
49+
0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 71, 217, 75, 144, 128, 252, 7, 157, 174, 2, 89, 248, 206, 24, 122, 69, 226, 95, // f
50+
0, 0, 0, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 62, 114, 251, 4, 157, 182, 94, 8, 230, 234, 185, 222, 120, 209, 235, 186, 207, 112, // d
51+
0, 0, 0, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 42, 44, 171, 202, 172, 191, 0, 232, 145, 31, 77, 90, 8, 233, 45, 106, 60, 176, // s
52+
0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 248, 112, 28, 63, 229, 61, 238, 146, 255, 228, 38, 170, 100, 42, 251, 21, 208 // a
53+
])
54+
}
55+
4156
class TestKeyring extends KeyringWebCrypto {
4257
async _onEncrypt () : Promise<WebCryptoEncryptionMaterial> {
4358
throw new Error('I should never see this error')

modules/decrypt-node/src/decrypt_stream.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import Duplexify from 'duplexify'
2525
import { Duplex } from 'stream' // eslint-disable-line no-unused-vars
2626

2727
// @ts-ignore
28-
import { pipeline } from 'readable-stream'
28+
import { pipeline, PassThrough } from 'readable-stream'
2929

3030
export interface DecryptStreamOptions {
3131
maxBodySize?: number
@@ -44,7 +44,13 @@ export function decryptStream (
4444
const verifyStream = new VerifyStream({ maxBodySize })
4545
const decipherStream = getDecipherStream()
4646

47-
pipeline(parseHeaderStream, verifyStream, decipherStream)
47+
/* pipeline will _either_ stream.destroy or the callback.
48+
* decipherStream uses destroy to dispose the material.
49+
* So I tack a pass though stream onto the end.
50+
*/
51+
pipeline(parseHeaderStream, verifyStream, decipherStream, new PassThrough(), (err: Error) => {
52+
if (err) stream.emit('error', err)
53+
})
4854

4955
const stream = new Duplexify(parseHeaderStream, decipherStream)
5056

modules/decrypt-node/src/parse_header_stream.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,17 @@ export class ParseHeaderStream extends PortableTransformWithType {
108108
// The header is parsed, pass control
109109
const readPos = rawHeader.byteLength + headerIv.byteLength + headerAuthTag.byteLength
110110
const tail = headerBuffer.slice(readPos)
111-
this._transform = (chunk: any, _enc: string, cb: Function) => cb(null, chunk)
111+
/* needs calls in downstream _transform streams will throw.
112+
* But streams are async.
113+
* So this error should be turned into an `.emit('error', ex)`.
114+
*/
115+
this._transform = (chunk: any, _enc: string, cb: Function) => {
116+
try {
117+
cb(null, chunk)
118+
} catch (ex) {
119+
this.emit('error', ex)
120+
}
121+
}
112122
// flush the tail. Stream control is now in the verify and decrypt streams
113123
return setImmediate(() => this._transform(tail, encoding, callback))
114124
})

modules/decrypt-node/src/verify_stream.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,16 @@ interface VerifyState {
4949
authTagBuffer: Buffer
5050
currentFrame?: BodyHeader
5151
signatureInfo: Buffer
52+
sequenceNumber: number
5253
}
5354

5455
export class VerifyStream extends PortableTransformWithType {
5556
private _headerInfo!: HeaderInfo
5657
private _verifyState: VerifyState = {
5758
buffer: Buffer.alloc(0),
5859
authTagBuffer: Buffer.alloc(0),
59-
signatureInfo: Buffer.alloc(0)
60+
signatureInfo: Buffer.alloc(0),
61+
sequenceNumber: 0
6062
}
6163
private _verify?: AWSVerify
6264
private _maxBodySize?: number
@@ -118,6 +120,17 @@ export class VerifyStream extends PortableTransformWithType {
118120
*/
119121
needs(!this._maxBodySize || this._maxBodySize >= frameHeader.contentLength, 'maxBodySize exceeded.')
120122

123+
/* Keeping track of the sequence number myself. */
124+
state.sequenceNumber += 1
125+
126+
/* Precondition: The sequence number is required to monotonically increase, starting from 1.
127+
* This is to avoid a bad actor from abusing the sequence number on un-signed algorithm suites.
128+
* If the frame size matched the data format (say NDJSON),
129+
* then the data could be significantly altered just by rearranging the frames.
130+
* Non-framed data returns a sequenceNumber of 1.
131+
*/
132+
needs(frameHeader.sequenceNumber === state.sequenceNumber, 'Encrypted body sequence out of order.')
133+
121134
if (this._verify) {
122135
this._verify.update(frameBuffer.slice(0, frameHeader.readPos))
123136
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,12 @@ describe('decrypt', () => {
6767
expect(messageHeader.encryptionContext).to.deep.equal(fixtures.encryptionContext())
6868
expect(test.toString('base64')).to.equal(fixtures.base64Plaintext())
6969
})
70+
71+
it('Precondition: The sequence number is required to monotonically increase, starting from 1.', async () => {
72+
return expect(decrypt(
73+
fixtures.decryptKeyring(),
74+
fixtures.frameSequenceOutOfOrder(),
75+
{ encoding: 'base64' }
76+
)).to.rejectedWith(Error, 'Encrypted body sequence out of order.')
77+
})
7078
})

modules/decrypt-node/test/fixtures.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ export function encryptionContext () {
3737
}
3838
}
3939

40+
export function frameSequenceOutOfOrder () {
41+
/* This is the string 'asdf' encrypted with ALG_AES128_GCM_IV12_TAG16.
42+
* The data key is all zeros.
43+
* The frames have been reordered in order to test decryption failure.
44+
*/
45+
return 'AYAAFG/G0IFAKXNb9xuUlGZGldIAAAABAAFrAAFrAAMAAAACAAAAAAwAAAAB' + // header
46+
'AAAAAAAAAAAAAAAAslUUXWAiznTYc7fZwFSbDw' + // header auth
47+
'AAAAQAAAAAAAAAAAAAAAR9lLkID8B52uAln4zhh6ReJf' + // f
48+
'AAAAMAAAAAAAAAAAAAAAPnL7BJ22Xgjm6rneeNHrus9w' + // d
49+
'AAAAIAAAAAAAAAAAAAAAKiyryqy/AOiRH01aCOktajyw' + // s
50+
'AAAAEAAAAAAAAAAAAAAAEPhwHD/lPe6S/+QmqmQq+xXQ' // a
51+
}
52+
4053
export function decryptKeyring () {
4154
class TestKeyring extends KeyringNode {
4255
async _onEncrypt () : Promise<NodeEncryptionMaterial> {

0 commit comments

Comments
 (0)