-
Notifications
You must be signed in to change notification settings - Fork 71
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add support for low level JWE functions
- Loading branch information
Showing
8 changed files
with
597 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
import { base64urlToBytes, bytesToBase64url, encodeBase64url, decodeBase64url, toSealed } from './util' | ||
|
||
interface RecipientHeader { | ||
alg: string | ||
iv: string | ||
tag: string | ||
epk?: Record<string, any> // Ephemeral Public Key | ||
} | ||
|
||
export interface Recipient { | ||
header: RecipientHeader | ||
encrypted_key: string | ||
} | ||
|
||
interface JWE { | ||
protected: string | ||
iv: string | ||
ciphertext: string | ||
tag: string | ||
aad?: string | ||
recipients?: Recipient[] | ||
} | ||
|
||
export interface EncryptionResult { | ||
ciphertext: Uint8Array | ||
tag: Uint8Array | ||
iv: Uint8Array | ||
protectedHeader?: string | ||
recipient?: Recipient | ||
cek?: Uint8Array | ||
} | ||
|
||
export interface Encrypter { | ||
alg: string | ||
enc: string | ||
encrypt: (cleartext: Uint8Array, protectedHeader: Record<string, any>, aad?: Uint8Array) => Promise<EncryptionResult> | ||
encryptCek?: (cek: Uint8Array) => Promise<Recipient> | ||
} | ||
|
||
export interface Decrypter { | ||
alg: string | ||
enc: string | ||
decrypt: (sealed: Uint8Array, iv: Uint8Array, aad?: Uint8Array, recipient?: Record<string, any>) => Promise<Uint8Array> | ||
} | ||
|
||
function validateJWE(jwe: JWE) { | ||
if (!(jwe.protected && jwe.iv && jwe.ciphertext && jwe.tag)) { | ||
throw new Error('Invalid JWE') | ||
} | ||
if (jwe.recipients) { | ||
jwe.recipients.map(rec => { | ||
if (!(rec.header && rec.encrypted_key)) { | ||
throw new Error('Invalid JWE') | ||
} | ||
}) | ||
} | ||
} | ||
|
||
function encodeJWE( | ||
{ | ||
ciphertext, | ||
tag, | ||
iv, | ||
protectedHeader, | ||
recipient | ||
}: EncryptionResult, | ||
aad?: Uint8Array | ||
): JWE { | ||
const jwe: JWE = { | ||
protected: protectedHeader, | ||
iv: bytesToBase64url(iv), | ||
ciphertext: bytesToBase64url(ciphertext), | ||
tag: bytesToBase64url(tag) | ||
} | ||
if (aad) jwe.aad = bytesToBase64url(aad) | ||
if (recipient) jwe.recipients = [recipient] | ||
return jwe | ||
} | ||
|
||
export async function createJWE(cleartext: Uint8Array, encrypters: Encrypter[], protectedHeader = {}, aad?: Uint8Array): Promise<JWE> { | ||
if (encrypters[0].alg === 'dir') { | ||
if (encrypters.length > 1) throw new Error('Can only do "dir" encryption to one key.') | ||
const encryptionResult = await encrypters[0].encrypt(cleartext, protectedHeader, aad) | ||
return encodeJWE(encryptionResult, aad) | ||
} else { | ||
const tmpEnc = encrypters[0].enc | ||
if (!encrypters.reduce((acc, encrypter) => acc && encrypter.enc === tmpEnc, true)) { | ||
throw new Error('Incompatible encrypters passed') | ||
} | ||
let cek | ||
let jwe | ||
for (const encrypter of encrypters) { | ||
if (!cek) { | ||
const encryptionResult = await encrypter.encrypt(cleartext, protectedHeader, aad) | ||
cek = encryptionResult.cek | ||
jwe = encodeJWE(encryptionResult, aad) | ||
} else { | ||
jwe.recipients.push(await encrypter.encryptCek(cek)) | ||
} | ||
} | ||
return jwe | ||
} | ||
} | ||
|
||
export async function decryptJWE(jwe: JWE, decrypter: Decrypter): Promise<Uint8Array> { | ||
validateJWE(jwe) | ||
const protHeader = JSON.parse(decodeBase64url(jwe.protected)) | ||
if (protHeader.enc !== decrypter.enc) throw new Error(`Decrypter does not support: '${protHeader.enc}'`) | ||
const sealed = toSealed(jwe.ciphertext, jwe.tag) | ||
const aad = new Uint8Array(Buffer.from(jwe.aad ? `${jwe.protected}.${jwe.aad}` : jwe.protected)) | ||
let cleartext = null | ||
if (protHeader.alg === 'dir' && decrypter.alg === 'dir') { | ||
cleartext = await decrypter.decrypt(sealed, base64urlToBytes(jwe.iv), aad) | ||
} else if (!jwe.recipients || jwe.recipients.length === 0) { | ||
throw new Error('Invalid JWE') | ||
} else { | ||
for (const recipient of jwe.recipients) { | ||
Object.assign(recipient.header, protHeader) | ||
if (recipient.header.alg === decrypter.alg) { | ||
cleartext = await decrypter.decrypt(sealed, base64urlToBytes(jwe.iv), aad, recipient) | ||
} | ||
if (cleartext !== null) break | ||
} | ||
} | ||
if (cleartext === null) throw new Error('Failed to decrypt') | ||
return cleartext | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
import { decryptJWE, createJWE, Encrypter } from '../JWE' | ||
import vectors from './jwe-vectors.js' | ||
import { | ||
xc20pDirEncrypter, | ||
xc20pDirDecrypter, | ||
x25519Encrypter, | ||
x25519Decrypter | ||
} from '../xc20pEncryption' | ||
import { decodeBase64url } from '../util' | ||
import * as u8a from 'uint8arrays' | ||
import { randomBytes } from '@stablelib/random' | ||
import { generateKeyPairFromSeed } from '@stablelib/x25519' | ||
|
||
describe('JWE', () => { | ||
describe('decryptJWE', () => { | ||
describe('Direct encryption', () => { | ||
test.each(vectors.dir.pass)('decrypts valid jwe', async ({ key, cleartext, jwe }) => { | ||
const decrypter = xc20pDirDecrypter(u8a.fromString(key, 'base64pad')) | ||
const cleartextU8a = await decryptJWE(jwe, decrypter) | ||
expect(u8a.toString(cleartextU8a)).toEqual(cleartext) | ||
}) | ||
|
||
test.each(vectors.dir.fail)('fails to decrypt bad jwe', async ({ key, jwe }) => { | ||
const decrypter = xc20pDirDecrypter(u8a.fromString(key, 'base64pad')) | ||
await expect(decryptJWE(jwe, decrypter)).rejects.toThrow('Failed to decrypt') | ||
}) | ||
|
||
test.each(vectors.dir.invalid)('throws on invalid jwe', async ({ jwe }) => { | ||
const decrypter = xc20pDirDecrypter(randomBytes(32)) | ||
await expect(decryptJWE(jwe, decrypter)).rejects.toThrow('Invalid JWE') | ||
}) | ||
}) | ||
|
||
describe('X25519 key exchange', () => { | ||
test.each(vectors.x25519.pass)('decrypts valid jwe', async ({ key, cleartext, jwe }) => { | ||
const decrypter = x25519Decrypter(u8a.fromString(key, 'base64pad')) | ||
const cleartextU8a = await decryptJWE(jwe, decrypter) | ||
expect(u8a.toString(cleartextU8a)).toEqual(cleartext) | ||
}) | ||
|
||
test.each(vectors.x25519.fail)('fails to decrypt bad jwe', async ({ key, jwe }) => { | ||
const decrypter = x25519Decrypter(u8a.fromString(key, 'base64pad')) | ||
await expect(decryptJWE(jwe, decrypter)).rejects.toThrow('Failed to decrypt') | ||
}) | ||
|
||
test.each(vectors.x25519.invalid)('throws on invalid jwe', async ({ jwe }) => { | ||
const decrypter = x25519Decrypter(randomBytes(32)) | ||
await expect(decryptJWE(jwe, decrypter)).rejects.toThrow('Invalid JWE') | ||
}) | ||
}) | ||
}) | ||
|
||
describe('createJWE', () => { | ||
describe('Direct encryption', () => { | ||
let key, cleartext, encrypter, decrypter | ||
|
||
beforeEach(() => { | ||
key = randomBytes(32) | ||
cleartext = u8a.fromString('my secret message') | ||
encrypter = xc20pDirEncrypter(key) | ||
decrypter = xc20pDirDecrypter(key) | ||
}) | ||
|
||
it('Creates with only ciphertext', async () => { | ||
const jwe = await createJWE(cleartext, [encrypter]) | ||
expect(jwe.aad).toBeUndefined() | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ alg:'dir', enc:'XC20P' }) | ||
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext) | ||
}) | ||
|
||
it('Creates with data in protected header', async () => { | ||
const jwe = await createJWE(cleartext, [encrypter], { more: 'protected' }) | ||
expect(jwe.aad).toBeUndefined() | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ alg:'dir', enc:'XC20P', more: 'protected' }) | ||
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext) | ||
}) | ||
|
||
it('Creates with aad', async () => { | ||
const aad = u8a.fromString('this data is authenticated') | ||
const jwe = await createJWE(cleartext, [encrypter], { more: 'protected' }, aad) | ||
expect(u8a.fromString(jwe.aad, 'base64url')).toEqual(aad) | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ alg:'dir', enc:'XC20P', more: 'protected' }) | ||
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext) | ||
delete jwe.aad | ||
await expect(decryptJWE(jwe, decrypter)).rejects.toThrow('Failed to decrypt') | ||
}) | ||
}) | ||
|
||
describe('X25519 key exchange encryption', () => { | ||
describe('One recipient', () => { | ||
let pubkey, secretkey, cleartext, encrypter, decrypter | ||
|
||
beforeEach(() => { | ||
secretkey = randomBytes(32) | ||
pubkey = generateKeyPairFromSeed(secretkey).publicKey | ||
cleartext = u8a.fromString('my secret message') | ||
encrypter = x25519Encrypter(pubkey) | ||
decrypter = x25519Decrypter(secretkey) | ||
}) | ||
|
||
it('Creates with only ciphertext', async () => { | ||
const jwe = await createJWE(cleartext, [encrypter]) | ||
expect(jwe.aad).toBeUndefined() | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ enc:'XC20P' }) | ||
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext) | ||
}) | ||
|
||
it('Creates with data in protected header', async () => { | ||
const jwe = await createJWE(cleartext, [encrypter], { more: 'protected' }) | ||
expect(jwe.aad).toBeUndefined() | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ enc:'XC20P', more: 'protected' }) | ||
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext) | ||
}) | ||
|
||
it('Creates with aad', async () => { | ||
const aad = u8a.fromString('this data is authenticated') | ||
const jwe = await createJWE(cleartext, [encrypter], { more: 'protected' }, aad) | ||
expect(u8a.fromString(jwe.aad, 'base64url')).toEqual(aad) | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ enc:'XC20P', more: 'protected' }) | ||
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext) | ||
delete jwe.aad | ||
await expect(decryptJWE(jwe, decrypter)).rejects.toThrow('Failed to decrypt') | ||
}) | ||
}) | ||
|
||
describe('Multiple recipients', () => { | ||
let pubkey1, secretkey1, pubkey2, secretkey2, cleartext | ||
let encrypter1, decrypter1, encrypter2, decrypter2 | ||
|
||
beforeEach(() => { | ||
secretkey1 = randomBytes(32) | ||
pubkey1 = generateKeyPairFromSeed(secretkey1).publicKey | ||
secretkey2 = randomBytes(32) | ||
pubkey2 = generateKeyPairFromSeed(secretkey2).publicKey | ||
cleartext = u8a.fromString('my secret message') | ||
encrypter1 = x25519Encrypter(pubkey1) | ||
decrypter1 = x25519Decrypter(secretkey1) | ||
encrypter2 = x25519Encrypter(pubkey2) | ||
decrypter2 = x25519Decrypter(secretkey2) | ||
}) | ||
|
||
it('Creates with only ciphertext', async () => { | ||
const jwe = await createJWE(cleartext, [encrypter1, encrypter2]) | ||
expect(jwe.aad).toBeUndefined() | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ enc:'XC20P' }) | ||
expect(await decryptJWE(jwe, decrypter1)).toEqual(cleartext) | ||
expect(await decryptJWE(jwe, decrypter2)).toEqual(cleartext) | ||
}) | ||
|
||
it('Creates with data in protected header', async () => { | ||
const jwe = await createJWE(cleartext, [encrypter1, encrypter2], { more: 'protected' }) | ||
expect(jwe.aad).toBeUndefined() | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ enc:'XC20P', more: 'protected' }) | ||
expect(await decryptJWE(jwe, decrypter1)).toEqual(cleartext) | ||
expect(await decryptJWE(jwe, decrypter2)).toEqual(cleartext) | ||
}) | ||
|
||
it('Creates with aad', async () => { | ||
const aad = u8a.fromString('this data is authenticated') | ||
const jwe = await createJWE(cleartext, [encrypter1, encrypter2], { more: 'protected' }, aad) | ||
expect(u8a.fromString(jwe.aad, 'base64url')).toEqual(aad) | ||
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ enc:'XC20P', more: 'protected' }) | ||
expect(await decryptJWE(jwe, decrypter1)).toEqual(cleartext) | ||
expect(await decryptJWE(jwe, decrypter2)).toEqual(cleartext) | ||
delete jwe.aad | ||
await expect(decryptJWE(jwe, decrypter1)).rejects.toThrow('Failed to decrypt') | ||
await expect(decryptJWE(jwe, decrypter2)).rejects.toThrow('Failed to decrypt') | ||
}) | ||
|
||
it('Incompatible encrypters throw', async () => { | ||
const enc1 = { enc: 'cool enc alg1' } as Encrypter | ||
const enc2 = { enc: 'cool enc alg2' } as Encrypter | ||
await expect(createJWE(cleartext, [enc1, enc2])).rejects.toThrow('Incompatible encrypters passed') | ||
}) | ||
}) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.