Skip to content

Commit 4945303

Browse files
committed
Add size method to ReadableStream returned by encrypt and decrypt
1 parent 4e6d0d7 commit 4945303

File tree

6 files changed

+110
-18
lines changed

6 files changed

+110
-18
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,12 @@ const e = new Encrypter()
112112
e.setScryptWorkFactor(12)
113113
e.setPassphrase("light-original-energy-average-wish-blind-vendor-pencil-illness-scorpion")
114114
const encryptedStream = await e.encrypt(file.stream())
115+
console.log(encryptedStream.size(file.size))
115116

116117
const d = new Decrypter()
117118
d.addPassphrase("light-original-energy-average-wish-blind-vendor-pencil-illness-scorpion")
118119
const decryptedStream = await d.decrypt(encryptedStream)
120+
console.log(decryptedStream.size(encryptedStream.size(file.size)))
119121

120122
console.log(await new Response(decryptedStream).text())
121123
```

lib/format.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ async function parseNextStanza(hdr: LineReader): Promise<{ s: Stanza, next?: nev
6161
}
6262

6363
export async function parseHeader(header: ReadableStream<Uint8Array>): Promise<{
64-
stanzas: Stanza[], MAC: Uint8Array, headerNoMAC: Uint8Array, rest: ReadableStream<Uint8Array>,
64+
stanzas: Stanza[], MAC: Uint8Array,
65+
headerNoMAC: Uint8Array, headerSize: number,
66+
rest: ReadableStream<Uint8Array>,
6567
}> {
6668
const hdr = new LineReader(header)
6769
const versionLine = await hdr.readLine()
@@ -83,7 +85,7 @@ export async function parseHeader(header: ReadableStream<Uint8Array>): Promise<{
8385
const MAC = base64nopad.decode(macLine.slice(4))
8486
const { rest, transcript } = hdr.close()
8587
const headerNoMAC = transcript.slice(0, transcript.length - 1 - macLine.length + 3)
86-
return { stanzas, headerNoMAC, MAC, rest: prepend(header, rest) }
88+
return { stanzas, headerNoMAC, MAC, headerSize: transcript.length, rest: prepend(header, rest) }
8789
}
8890
}
8991

lib/index.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sha256 } from "@noble/hashes/sha256"
44
import { randomBytes } from "@noble/hashes/utils"
55
import { ScryptIdentity, ScryptRecipient, X25519Identity, X25519Recipient } from "./recipients.js"
66
import { encodeHeader, encodeHeaderNoMAC, parseHeader, Stanza } from "./format.js"
7-
import { decryptSTREAM, encryptSTREAM } from "./stream.js"
7+
import { ciphertextSize, decryptSTREAM, encryptSTREAM, plaintextSize } from "./stream.js"
88
import { readAll, stream, read, readAllString, prepend } from "./io.js"
99

1010
export * as armor from "./armor.js"
@@ -64,6 +64,27 @@ export interface Recipient {
6464
wrapFileKey(fileKey: Uint8Array): Stanza[] | Promise<Stanza[]>;
6565
}
6666

67+
/**
68+
* A ReadableStream that can also report the expected output size based on the
69+
* input size.
70+
*/
71+
export interface ReadableStreamWithSize extends ReadableStream<Uint8Array> {
72+
/**
73+
* Calculate the expected plaintext size from the given ciphertext size, or
74+
* vice versa.
75+
*
76+
* @param sourceSize - The size of the ciphertext or plaintext to calculate
77+
* the expected output size for.
78+
*
79+
* @returns The expected plaintext size if the input is a ciphertext, or the
80+
* expected ciphertext size if the input is a plaintext.
81+
*
82+
* @throws Only if the input is a ciphertext, and the ciphertext size is
83+
* too small or invalid. There are no invalid plaintext sizes.
84+
*/
85+
size(sourceSize: number): number
86+
}
87+
6788
export { generateIdentity, identityToRecipient } from "./recipients.js"
6889

6990
/**
@@ -139,11 +160,11 @@ export class Encrypter {
139160
* encoded as UTF-8.
140161
*
141162
* @returns A promise that resolves to the encrypted file as a Uint8Array,
142-
* or as a ReadableStream if the input was a stream.
163+
* or as a {@link ReadableStreamWithSize} if the input was a stream.
143164
*/
144165
async encrypt(file: Uint8Array | string): Promise<Uint8Array>
145-
async encrypt(file: ReadableStream<Uint8Array>): Promise<ReadableStream<Uint8Array>>
146-
async encrypt(file: Uint8Array | string | ReadableStream<Uint8Array>): Promise<Uint8Array | ReadableStream<Uint8Array>> {
166+
async encrypt(file: ReadableStream<Uint8Array>): Promise<ReadableStreamWithSize>
167+
async encrypt(file: Uint8Array | string | ReadableStream<Uint8Array>): Promise<Uint8Array | ReadableStreamWithSize> {
147168
const fileKey = randomBytes(16)
148169
const stanzas: Stanza[] = []
149170

@@ -163,9 +184,13 @@ export class Encrypter {
163184
const streamKey = hkdf(sha256, fileKey, nonce, "payload", 32)
164185
const encrypter = encryptSTREAM(streamKey)
165186

166-
if (file instanceof ReadableStream) return prepend(file.pipeThrough(encrypter), header, nonce)
167-
if (typeof file === "string") file = new TextEncoder().encode(file)
168-
return await readAll(prepend(stream(file).pipeThrough(encrypter), header, nonce))
187+
if (!(file instanceof ReadableStream)) {
188+
if (typeof file === "string") file = new TextEncoder().encode(file)
189+
return await readAll(prepend(stream(file).pipeThrough(encrypter), header, nonce))
190+
}
191+
return Object.assign(prepend(file.pipeThrough(encrypter), header, nonce), {
192+
size: (size: number): number => ciphertextSize(size) + header.length + nonce.length
193+
})
169194
}
170195
}
171196

@@ -230,22 +255,25 @@ export class Decrypter {
230255
* Ignored if the input is a stream.
231256
*
232257
* @returns A promise that resolves to the decrypted file, or to a
233-
* ReadableStream of the decrypted file if the input was a stream.
234-
* The header is processed before the promise resolves.
258+
* {@link ReadableStreamWithSize} of the decrypted file if the input was a
259+
* stream. The header is processed before the promise resolves.
235260
*/
236261
async decrypt(file: Uint8Array, outputFormat?: "uint8array"): Promise<Uint8Array>
237262
async decrypt(file: Uint8Array, outputFormat: "text"): Promise<string>
238-
async decrypt(file: ReadableStream<Uint8Array>): Promise<ReadableStream<Uint8Array>>
239-
async decrypt(file: Uint8Array | ReadableStream<Uint8Array>, outputFormat?: "text" | "uint8array"): Promise<string | Uint8Array | ReadableStream<Uint8Array>> {
263+
async decrypt(file: ReadableStream<Uint8Array>): Promise<ReadableStreamWithSize>
264+
async decrypt(file: Uint8Array | ReadableStream<Uint8Array>, outputFormat?: "text" | "uint8array"): Promise<string | Uint8Array | ReadableStreamWithSize> {
240265
const s = file instanceof ReadableStream ? file : stream(file)
241-
const { fileKey, rest } = await this.decryptHeaderInternal(s)
266+
const { fileKey, headerSize, rest } = await this.decryptHeaderInternal(s)
242267
const { data: nonce, rest: payload } = await read(rest, 16)
243268

244269
const streamKey = hkdf(sha256, fileKey, nonce, "payload", 32)
245270
const decrypter = decryptSTREAM(streamKey)
246271
const out = payload.pipeThrough(decrypter)
247272

248-
if (file instanceof ReadableStream) return out
273+
const outWithSize = Object.assign(out, {
274+
size: (size: number): number => plaintextSize(size - headerSize - nonce.length)
275+
})
276+
if (file instanceof ReadableStream) return outWithSize
249277
if (outputFormat === "text") return await readAllString(out)
250278
return await readAll(out)
251279
}
@@ -267,7 +295,7 @@ export class Decrypter {
267295
return (await this.decryptHeaderInternal(stream(header))).fileKey
268296
}
269297

270-
private async decryptHeaderInternal(file: ReadableStream<Uint8Array>): Promise<{ fileKey: Uint8Array, rest: ReadableStream<Uint8Array> }> {
298+
private async decryptHeaderInternal(file: ReadableStream<Uint8Array>): Promise<{ fileKey: Uint8Array, headerSize: number, rest: ReadableStream<Uint8Array> }> {
271299
const h = await parseHeader(file)
272300
const fileKey = await this.unwrapFileKey(h.stanzas)
273301
if (fileKey === null) throw Error("no identity matched any of the file's recipients")
@@ -276,7 +304,7 @@ export class Decrypter {
276304
const mac = hmac(sha256, hmacKey, h.headerNoMAC)
277305
if (!compareBytes(h.MAC, mac)) throw Error("invalid header HMAC")
278306

279-
return { fileKey: fileKey, rest: h.rest }
307+
return { fileKey, headerSize: h.headerSize, rest: h.rest }
280308
}
281309

282310
private async unwrapFileKey(stanzas: Stanza[]): Promise<Uint8Array | null> {

lib/stream.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ export function decryptSTREAM(key: Uint8Array): TransformStream<Uint8Array, Uint
4848
})
4949
}
5050

51+
export function plaintextSize(ciphertextSize: number): number {
52+
if (ciphertextSize < chacha20poly1305Overhead) {
53+
throw Error("ciphertext is too small")
54+
}
55+
if (ciphertextSize === chacha20poly1305Overhead) {
56+
return 0 // Empty plaintext.
57+
}
58+
const fullChunks = Math.floor(ciphertextSize / chunkSizeWithOverhead)
59+
const lastChunk = ciphertextSize % chunkSizeWithOverhead
60+
if (0 < lastChunk && lastChunk <= chacha20poly1305Overhead) {
61+
throw Error("ciphertext size is invalid")
62+
}
63+
let size = ciphertextSize
64+
size -= fullChunks * chacha20poly1305Overhead
65+
size -= lastChunk > 0 ? chacha20poly1305Overhead : 0
66+
return size
67+
}
68+
5169
export function encryptSTREAM(key: Uint8Array): TransformStream<Uint8Array, Uint8Array> {
5270
const streamNonce = new Uint8Array(12)
5371
const incNonce = () => {
@@ -84,3 +102,8 @@ export function encryptSTREAM(key: Uint8Array): TransformStream<Uint8Array, Uint
84102
},
85103
})
86104
}
105+
106+
export function ciphertextSize(plaintextSize: number): number {
107+
const chunks = Math.max(1, Math.ceil(plaintextSize / chunkSize))
108+
return plaintextSize + chacha20poly1305Overhead * chunks
109+
}

tests/index.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,14 @@ describe("AgeEncrypter", function () {
140140
const source = randomBytesStream(size, chunk)
141141
const sourceHash = new HashingTransformStream()
142142
const encrypted = await e.encrypt(source.pipeThrough(sourceHash))
143-
const decrypted = await d.decrypt(encrypted)
143+
const expectedCiphertextSize = encrypted.size(size)
144+
const ciphertextSize = new SizeTransformStream()
145+
const encryptedWithSize = encrypted.pipeThrough(ciphertextSize)
146+
const decrypted = await d.decrypt(encryptedWithSize)
147+
assert.equal(size, decrypted.size(expectedCiphertextSize))
144148
const decryptedHash = new HashingTransformStream()
145149
await readAll(decrypted.pipeThrough(decryptedHash))
150+
assert.equal(ciphertextSize.size, expectedCiphertextSize)
146151
assert.deepEqual(sourceHash.digest, decryptedHash.digest)
147152
})
148153
it("should throw when using multiple passphrases", function () {
@@ -214,3 +219,15 @@ class HashingTransformStream extends TransformStream<Uint8Array, Uint8Array> {
214219
})
215220
}
216221
}
222+
223+
class SizeTransformStream extends TransformStream<Uint8Array, Uint8Array> {
224+
size = 0
225+
constructor() {
226+
super({
227+
transform: (chunk, controller) => {
228+
this.size += chunk.length
229+
controller.enqueue(chunk)
230+
}
231+
})
232+
}
233+
}

tests/stream.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, it, assert, expect } from "vitest"
2+
import { plaintextSize, ciphertextSize } from "../lib/stream.js"
3+
4+
describe("stream", function () {
5+
it.for([
6+
0, 1, 15, 16, 17, 500,
7+
64 * 1024 - 1, 64 * 1024, 64 * 1024 + 1,
8+
64 * 1024 * 2 - 1, 64 * 1024 * 2, 64 * 1024 * 2 + 1
9+
])("should round-trip plaintext size and ciphertext size", function (ps) {
10+
assert.equal(ps, plaintextSize(ciphertextSize(ps)),
11+
`plaintextSize(ciphertextSize(${ps})) should return ${ps}`)
12+
})
13+
it.for([
14+
0, 1, 15,
15+
64 * 1024 + 16 + 1, 64 * 1024 + 16 + 15,
16+
64 * 1024 * 2 + 16 * 2 + 1, 64 * 1024 * 2 + 16 * 2 + 15,
17+
])("should throw for invalid chiphertext size", function (cs) {
18+
expect(() => plaintextSize(cs)).to.throw()
19+
})
20+
})

0 commit comments

Comments
 (0)