Skip to content

Commit

Permalink
feat: add support for low level JWE functions
Browse files Browse the repository at this point in the history
  • Loading branch information
oed committed Sep 13, 2020
1 parent d97d5cd commit 7cb3bdf
Show file tree
Hide file tree
Showing 8 changed files with 597 additions and 6 deletions.
24 changes: 24 additions & 0 deletions src/Digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,27 @@ export function toEthereumAddress(hexPublicKey: string): string {
const hashInput = u8a.fromString(hexPublicKey.slice(2), 'base16')
return `0x${u8a.toString(keccak(hashInput).slice(-20), 'base16')}`
}

function writeUint32BE(value: number, array = new Uint8Array(4)): Uint8Array {
const encoded = u8a.fromString(value.toString(), 'base10')
array.set(encoded, 4 - encoded.length)
return array
}

const lengthAndInput = (input: Uint8Array): Uint8Array => u8a.concat([writeUint32BE(input.length), input])

// This implementation of concatKDF was inspired by these two implementations:
// https://github.com/digitalbazaar/minimal-cipher/blob/master/algorithms/ecdhkdf.js
// https://github.com/panva/jose/blob/master/lib/jwa/ecdh/derive.js
export function concatKDF(secret: Uint8Array, keyLen: number, alg: string): Uint8Array {
if (keyLen !== 256) throw new Error(`Unsupported key length: ${keyLen}`)
const value = u8a.concat([
lengthAndInput(u8a.fromString(alg)),
lengthAndInput(new Uint8Array(0)), // apu
lengthAndInput(new Uint8Array(0)), // apv
writeUint32BE(keyLen)
])
// since our key lenght is 256 we only have to do one round
const roundNumber = 1
return hash(u8a.concat([ writeUint32BE(roundNumber), secret, value ]))
}
127 changes: 127 additions & 0 deletions src/JWE.ts
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
}
178 changes: 178 additions & 0 deletions src/__tests__/JWE-test.ts
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')
})
})
})
})
})
2 changes: 1 addition & 1 deletion src/__tests__/SignerAlgorithm-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import EllipticSigner from '../EllipticSigner'
import NaclSigner from '../NaclSigner'
import { ec as EC } from 'elliptic'
import nacl from 'tweetnacl'
import { base64ToBytes, base64urlToBytes, decodeBase64url } from '../util'
import { base64ToBytes, base64urlToBytes } from '../util'
import { sha256 } from '../Digest'
import { encode } from '@stablelib/utf8'
const secp256k1 = new EC('secp256k1')
Expand Down
Loading

0 comments on commit 7cb3bdf

Please sign in to comment.