Encrypt/decrypt anything in the browser using streams on background threads.
Quickly and efficiently decrypt remote resources in the browser. Display the files in the DOM, or download them with conflux.
| .decrypt | .encrypt | .saveZip | |
|---|---|---|---|
| Chrome | âś… | âś… | âś… |
| Edge >18 | âś… | âś… | âś… |
| Safari ≥14.1 | ✅ | ✅ | ✅ |
| Firefox ≥102 | ✅ | ✅ | ✅ |
âś… = Full support with workers
npm install --save @transcend-io/penumbraimport { penumbra } from '@transcend-io/penumbra';
penumbra.get(...files).then(penumbra.save);penumbra.get() uses RemoteResource descriptors to specify where to request resources and their various decryption parameters.
/**
* A file to download from a remote resource, that is optionally encrypted
*/
type RemoteResource = {
/** The URL to fetch the encrypted or unencrypted file from */
url: string;
/** The mimetype of the resulting file */
mimetype?: string;
/** The name of the underlying file without the extension */
filePrefix?: string;
/** If the file is encrypted, these are the required params */
decryptionOptions?: PenumbraDecryptionOptions;
/** Relative file path (needed for zipping) */
path?: string;
/** Fetch options */
requestInit?: RequestInit;
/** Last modified date */
lastModified?: Date;
/** Expected file size */
size?: number;
};Fetch and decrypt remote files.
penumbra.get(...resources: RemoteResource[]): Promise<PenumbraFile[]>Encrypt files.
penumbra.encrypt(options: PenumbraEncryptionOptions, ...files: PenumbraFile[]): Promise<PenumbraEncryptedFile[]>
/**
* penumbra.encrypt() encryption options config (Uint8Array or base64-encoded string)
*/
type PenumbraEncryptionOptions = {
/** Encryption key */
key: string | Uint8Array;
};Encrypt an empty stream:
size = 4096 * 128;
addEventListener('penumbra-progress', (e) => console.log(e.type, e.detail));
addEventListener('penumbra-complete', (e) => console.log(e.type, e.detail));
file = penumbra.encrypt(null, {
stream: new Response(new Uint8Array(size)).body,
size,
});
data = [];
file.then(async ([encrypted]) => {
console.log('encryption complete');
data.push(new Uint8Array(await new Response(encrypted.stream).arrayBuffer()));
});Encrypt and decrypt text:
const te = new self.TextEncoder();
const td = new self.TextDecoder();
const input = '[test string]';
const buffer = te.encode(input);
const { byteLength: size } = buffer;
const stream = new Response(buffer).body;
const options = null;
const file = {
stream,
size,
};
const [encrypted] = await penumbra.encrypt(options, file);
const decryptionInfo = await penumbra.getDecryptionInfo(encrypted);
const [decrypted] = await penumbra.decrypt(decryptionInfo, encrypted);
const decryptedData = await new Response(decrypted.stream).arrayBuffer();
const decryptedText = td.decode(decryptedData);
console.log('decrypted text:', decryptedText);Get decryption info for a file, including the iv, authTag, and key. This may only be called on files that have finished being encrypted.
penumbra.getDecryptionInfo(file: PenumbraFile): Promise<PenumbraDecryptionOptions>Decrypt files.
penumbra.decrypt(options: PenumbraDecryptionOptions, ...files: PenumbraEncryptedFile[]): Promise<PenumbraFile[]>const te = new TextEncoder();
const td = new TextDecoder();
const data = te.encode('test');
const { byteLength: size } = data;
const [encrypted] = await penumbra.encrypt(null, {
stream: data,
size,
});
const options = await penumbra.getDecryptionInfo(encrypted);
const [decrypted] = await penumbra.decrypt(options, encrypted);
const decryptedData = await new Response(decrypted.stream).arrayBuffer();
return td.decode(decryptedData) === 'test';Save files retrieved by Penumbra. Downloads a .zip if there are multiple files. Returns an AbortController that can be used to cancel an in-progress save stream.
penumbra.save(data: PenumbraFile[], fileName?: string): AbortControllerLoad files retrieved by Penumbra into memory as a Blob.
penumbra.getBlob(data: PenumbraFile[] | PenumbraFile | ReadableStream, type?: string): Promise<Blob>Get file text (if content is text) or URI (if content is not viewable).
penumbra.getTextOrURI(data: PenumbraFile[]): Promise<{ type: 'text'|'uri', data: string, mimetype: string }[]>Save a zip containing files retrieved by Penumbra.
type ZipOptions = {
/** Filename to save to (.zip is optional) */
name?: string;
/** Total size of archive in bytes (if known ahead of time, for 'store' compression level) */
size?: number;
/** PenumbraFile[] to add to zip archive */
files?: PenumbraFile[];
/** Abort controller for cancelling zip generation and saving */
controller?: AbortController;
/** Allow & auto-rename duplicate files sent to writer. Defaults to on */
allowDuplicates: boolean;
/** Zip archive compression level */
compressionLevel?: number;
/** Store a copy of the resultant zip file in-memory for inspection & testing */
saveBuffer?: boolean;
/**
* Auto-registered `'progress'` event listener. This is equivalent to calling
* `PenumbraZipWriter.addEventListener('progress', onProgress)`
*/
onProgress?(event: CustomEvent<ZipProgressDetails>): void;
/**
* Auto-registered `'complete'` event listener. This is equivalent to calling
* `PenumbraZipWriter.addEventListener('complete', onComplete)`
*/
onComplete?(event: CustomEvent<{}>): void;
};
penumbra.saveZip(options?: ZipOptions): PenumbraZipWriter;
interface PenumbraZipWriter extends EventTarget {
/**
* Add decrypted PenumbraFiles to zip
*
* @param files - Decrypted PenumbraFile[] to add to zip
* @returns Total observed size of write call in bytes
*/
write(...files: PenumbraFile[]): Promise<number>;
/**
* Enqueue closing of the Penumbra zip writer (after pending writes finish)
*
* @returns Total observed zip size in bytes after close completes
*/
close(): Promise<number>;
/** Cancel Penumbra zip writer */
abort(): void;
/** Get buffered output (requires saveBuffer mode) */
getBuffer(): Promise<ArrayBuffer>;
/** Get all written & pending file paths */
getFiles(): string[];
/**
* Get observed zip size after all pending writes are resolved
*/
getSize(): Promise<number>;
}
type ZipProgressDetails = {
/** Percentage completed. `null` indicates indetermination */
percent: number | null;
/** The number of bytes or items written so far */
written: number;
/** The total number of bytes or items to write. `null` indicates indetermination */
size: number | null;
};Example:
const files = [
{
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/tortoise.jpg.enc',
filePrefix: 'tortoise.jpg',
mimetype: 'image/jpeg',
decryptionOptions: {
key: 'vScyqmJKqGl73mJkuwm/zPBQk0wct9eQ5wPE8laGcWM=',
iv: '6lNU+2vxJw6SFgse',
authTag: 'ELry8dZ3djg8BRB+7TyXZA==',
},
},
];
const writer = penumbra.saveZip();
await writer.write(...(await penumbra.get(...files)));
await writer.close();const decryptedText = await penumbra
.get({
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/NYT.txt.enc',
mimetype: 'text/plain',
filePrefix: 'NYT',
decryptionOptions: {
key: 'vScyqmJKqGl73mJkuwm/zPBQk0wct9eQ5wPE8laGcWM=',
iv: '6lNU+2vxJw6SFgse',
authTag: 'gadZhS1QozjEmfmHLblzbg==',
},
})
.then((file) => penumbra.getTextOrURI(file)[0])
.then(({ data }) => {
document.getElementById('my-paragraph').innerText = data;
});const imageSrc = await penumbra
.get({
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/tortoise.jpg.enc',
filePrefix: 'tortoise',
mimetype: 'image/jpeg',
decryptionOptions: {
key: 'vScyqmJKqGl73mJkuwm/zPBQk0wct9eQ5wPE8laGcWM=',
iv: '6lNU+2vxJw6SFgse',
authTag: 'ELry8dZ3djg8BRB+7TyXZA==',
},
})
.then((file) => penumbra.getTextOrURI(file)[0])
.then(({ data }) => {
document.getElementById('my-img').src = data;
});penumbra
.get({
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/africa.topo.json.enc',
filePrefix: 'africa.topo.json',
mimetype: 'application/json',
decryptionOptions: {
key: 'vScyqmJKqGl73mJkuwm/zPBQk0wct9eQ5wPE8laGcWM=',
iv: '6lNU+2vxJw6SFgse',
authTag: 'ELry8dZ3djg8BRB+7TyXZA==',
},
})
.then((file) => penumbra.save(file));
// saves africa.topo.json file to diskpenumbra
.get(
{
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/africa.topo.json.enc',
filePrefix: 'africa',
mimetype: 'image/jpeg',
decryptionOptions: {
key: 'vScyqmJKqGl73mJkuwm/zPBQk0wct9eQ5wPE8laGcWM=',
iv: '6lNU+2vxJw6SFgse',
authTag: 'ELry8dZ3djg8BRB+7TyXZA==',
},
},
{
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/NYT.txt.enc',
mimetype: 'text/plain',
filePrefix: 'NYT',
decryptionOptions: {
key: 'vScyqmJKqGl73mJkuwm/zPBQk0wct9eQ5wPE8laGcWM=',
iv: '6lNU+2vxJw6SFgse',
authTag: 'gadZhS1QozjEmfmHLblzbg==',
},
},
{
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/tortoise.jpg', // this is not encrypted
filePrefix: 'tortoise',
mimetype: 'image/jpeg',
},
)
.then((files) => penumbra.save(files, 'example'));
// saves example.zip file to disk// Resources to load
const resources = [
{
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/NYT.txt.enc',
filePrefix: 'NYT',
mimetype: 'text/plain',
decryptionOptions: {
key: 'vScyqmJKqGl73mJkuwm/zPBQk0wct9eQ5wPE8laGcWM=',
iv: '6lNU+2vxJw6SFgse',
authTag: 'gadZhS1QozjEmfmHLblzbg==',
},
},
{
url: 'https://s3-us-west-2.amazonaws.com/your-bucket/tortoise.jpg.enc',
filePrefix: 'tortoise',
mimetype: 'image/jpeg',
decryptionOptions: {
key: 'vScyqmJKqGl73mJkuwm/zPBQk0wct9eQ5wPE8laGcWM=',
iv: '6lNU+2vxJw6SFgse',
authTag: 'ELry8dZ3djg8BRB+7TyXZA==',
},
},
];
// preconnect to the origins
penumbra.preconnect(...resources);
// or preload all of the URLS
penumbra.preload(...resources);You can listen to encrypt/decrypt job completion events through the penumbra-complete event.
window.addEventListener(
'penumbra-complete',
({ detail: { id, decryptionInfo } }) => {
console.log(
`finished encryption job #${id}%. decryption options:`,
decryptionInfo,
);
},
);You can listen to download and encrypt/decrypt job progress events through the penumbra-progress event.
window.addEventListener(
'penumbra-progress',
({ detail: { percent, id, type } }) => {
console.log(`${type}% ${percent}% done for ${id}`);
// example output: decrypt 33% done for https://example.com/encrypted-data
},
);Note: this feature requires the Content-Length response header to be exposed. This works by adding Access-Control-Expose-Headers: Content-Length to the response header (read more here and here)
On Amazon S3, this means adding the following line to your bucket policy, inside the <CORSRule> block:
<ExposeHeader>Content-Length</ExposeHeader>You can check if Penumbra is supported by the current browser by comparing penumbra.supported(): PenumbraSupportLevel with penumbra.supported.levels.
if (penumbra.supported() > penumbra.supported.levels.none) {
// penumbra is supported
}
/** penumbra.supported.levels - Penumbra user agent support levels */
enum PenumbraSupportLevel {
/** Old browser where Penumbra does not work at all */
none = -0,
/** Modern browser with full support */
full = 2,
}Everything Penumbra uses is widely supported by modern browsers, but depending on your browser target, you can load polyfills for:
TransformStreamWritableStreamReadableStreamCustomEventProxyBigInt(if usingpenumbra.saveZip)
# setup
pnpm install
pnpm build
# run tests
pnpm test