Skip to content

Commit

Permalink
feat: added JWS General JSON Serialization signing
Browse files Browse the repository at this point in the history
resolves #129
  • Loading branch information
panva committed Dec 16, 2020
1 parent 60bcd51 commit 6fb862c
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 81 deletions.
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@
"import": "./dist/node/webcrypto/esm/jws/flattened/verify.js",
"require": "./dist/node/webcrypto/cjs/jws/flattened/verify.js"
},
"./jws/general/sign": {
"browser": "./dist/browser/jws/general/sign.js",
"import": "./dist/node/esm/jws/general/sign.js",
"require": "./dist/node/cjs/jws/general/sign.js"
},
"./webcrypto/jws/general/sign": {
"import": "./dist/node/webcrypto/esm/jws/general/sign.js",
"require": "./dist/node/webcrypto/cjs/jws/general/sign.js"
},
"./jws/general/verify": {
"browser": "./dist/browser/jws/general/verify.js",
"import": "./dist/node/esm/jws/general/verify.js",
Expand Down
181 changes: 181 additions & 0 deletions src/jws/general/sign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/* eslint-disable max-classes-per-file */
import FlattenedSign from '../flattened/sign.js'
import { JWSInvalid } from '../../util/errors.js'

import type { KeyLike, GeneralJWS, JWSHeaderParameters, SignOptions } from '../../types.d'

export interface Signature {
/**
* Sets the JWS Protected Header on the Signature object.
*
* @param protectedHeader JWS Protected Header.
*/
setProtectedHeader(protectedHeader: JWSHeaderParameters): Signature

/**
* Sets the JWS Unprotected Header on the Signature object.
*
* @param unprotectedHeader JWS Unprotected Header.
*/
setUnprotectedHeader(unprotectedHeader: JWSHeaderParameters): Signature
}

interface SignatureReference {
protectedHeader?: JWSHeaderParameters
unprotectedHeader?: JWSHeaderParameters
options?: SignOptions
key: KeyLike
}

const signatureRef: WeakMap<IndividualSignature, SignatureReference> = new WeakMap()

class IndividualSignature implements Signature {
setProtectedHeader(protectedHeader: JWSHeaderParameters) {
if (this._protectedHeader) {
throw new TypeError('setProtectedHeader can only be called once')
}
this._protectedHeader = protectedHeader
return this
}

setUnprotectedHeader(unprotectedHeader: JWSHeaderParameters) {
if (this._unprotectedHeader) {
throw new TypeError('setUnprotectedHeader can only be called once')
}
this._unprotectedHeader = unprotectedHeader
return this
}

private set _protectedHeader(value: JWSHeaderParameters) {
signatureRef.get(this)!.protectedHeader = value
}

private get _protectedHeader(): JWSHeaderParameters {
return signatureRef.get(this)!.protectedHeader!
}

private set _unprotectedHeader(value: JWSHeaderParameters) {
signatureRef.get(this)!.unprotectedHeader = value
}

private get _unprotectedHeader(): JWSHeaderParameters {
return signatureRef.get(this)!.unprotectedHeader!
}
}

/**
* The GeneralSign class is a utility for creating General JWS objects.
*
* @example
* ```
* // ESM import
* import GeneralSign from 'jose/jws/general/sign'
* ```
*
* @example
* ```
* // CJS import
* const { default: GeneralSign } = require('jose/jws/general/sign')
* ```
*
* @example
* ```
* // usage
* import parseJwk from 'jose/jwk/parse'
*
* const encoder = new TextEncoder()
* const ecPrivateKey = await parseJwk({
* alg: 'ES256',
* crv: 'P-256',
* kty: 'EC',
* d: 'VhsfgSRKcvHCGpLyygMbO_YpXc7bVKwi12KQTE4yOR4',
* x: 'ySK38C1jBdLwDsNWKzzBHqKYEE5Cgv-qjWvorUXk9fw',
* y: '_LeQBw07cf5t57Iavn4j-BqJsAD1dpoz8gokd3sBsOo'
* })
* const rsaPrivateKey = await parseJwk({
* alg: 'PS256',
* e: 'AQAB',
* n: 'vwKaDQxZtTDZVzoKmpzNNh5lG-tweqG_52qBRihiHPDqbvEDdM0GMFH6QVC7BZb_7lXvQ1QSYL8CWkigMuebx6LoCwCazsQ_IpaOYfmjkEAQ8HmXaRM5LyZ8Nch8iajgMSZkOTGi-15kLBskaM2VhC4l8WSVykgLqI196N1pd969xIXgweBqH1DJnLJoz5395j2b9SFRdu0VXIWzFtGA4DQmastcRvF-3McTebWTnTWVpEQPu-GixYbDyAtyjnVW7e2nfV0xoShYbFqWSJ4XwkbG7y8_mjsRL140LMHmq9mGR1WvF-KeT59iy5gg-63LXnUcTyAg45bMjH-ZRtlZ5Q',
* d: 'M4srMPw1NPzSmYQzGlfX1JPNKwSUnLMLSxJlgh4ho7erO3bUPO-ajO2CP5_eZ_rAY3tTDnMgZnUE2IIioLn5Qp0GSvnFzKgOdXH1SCEKb0GqkInvPs6OLtgOyqCoYqlsnjbC8uAfH__vvisw3wsjHsEpQgOnnCdm5fwQjwc4j7zWby-EY0xFS8rUnfU6hSJ9Uw73ztftZuTXbmLFc5bw2mnAEuX18R9GVqduxRIqZQZfgUpmE5MbL8YBee3pZ51zjAa98z42kGS_0A33kXlXzcFDMd21cnfpZGKtIrigmTKabnc6MFZxaotmTaJqYUK58angQ3MkjTqMuj81JKRW4Q',
* p: '-hAX8Gm_6xyIfo3v2pj6k9iaf7S8_vf8Hh05BrTpYHBb0FvnPxsm9vEd4H4BOKBQjoT5biXZpgCdTyLxvo1USrf0ocs3BHfBraSG_ohMlpjaR_biALz8tKOdlsAZIoUBwFMigdDfeOCnBtGao2UcyiYVPw4p5Nd-T36xRV7fMB0',
* q: 'w4uUhbhWGt4-ibmwf2Kdaiz1PxyCxnZICBCGreH3WTvayjrAYEr_TVbMOU0_Bj7KagJxcwTEN2HlfFjLUDAatH8gwgmUPJzh5PmgJvOQJpKLVGGKU-xwDt2nbzZ3W0do8HtoC-rlL3cX9itmOI8YcxCRv4B1zrj5we53pH5itmk',
* dp: 'TLxJjFX3Nd_Qpv1JYExXgK0UZCIDaT6SGG-hQ0Sa5SQ1mI_LO5tKbrb5Ex23pDfV4JY_sKRe0MkZfOJdSrs15aPjpw6kOHPDdFSrtEoBLqmDOlgxbEaSSaB3yH30eJpWOj2ItktxeDeAKeCCUqfBmOrs1Ce1hWr3cM-Q-JevZ6U',
* dq: 'XIE8aqHQgfdfCFJCr5BcPW01O3zmVLKB0ubWf42lMJ6DGyX9-c-gxNpp1DW5ud-ca9fqCWpY1IZIRLHQxIdtKrP1MDXN3Xqt1l9MpwCT0duDdBCMmrUAMdgjrBXNEu5OM219xB2D_BdPy5GuUtVG0LAm8rv3fyq8ZETGbpenZPk',
* qi: 'fsRY6JkXC2exI5Df16QnHO85T7QXAOX4SQbyF2jbiIqzyTrmIoMgxPeoFv26JqWjzaWtogIPLleFNA1EWpvtKwZQ8K0iCJZWoyCjYXUwhln1gaXjSRkecLL5_BUab8OmxmEChwDO95xyXd2r70ObaxqtLgpVqNERa-P2RArwMGQ',
* kty: 'RSA',
* })
*
* const sign = new GeneralSign(encoder.encode('It’s a dangerous business, Frodo, going out your door.'))
*
* sign
* .addSignature(ecPrivateKey)
* .setProtectedHeader({ alg: 'ES256' })
*
* sign
* .addSignature(rsaPrivateKey)
* .setProtectedHeader({ alg: 'PS256' })
*
* const jws = await sign.sign()
* ```
*/
export default class GeneralSign {
private _payload: Uint8Array

private _signatures: IndividualSignature[] = []

/**
* @param payload Binary representation of the payload to sign.
*/
constructor(payload: Uint8Array) {
this._payload = payload
}

addSignature(key: KeyLike, options?: SignOptions): Signature {
const signature = new IndividualSignature()
signatureRef.set(signature, { key, options })
this._signatures.push(signature)
return signature
}

/**
* Signs and resolves the value of the General JWS object.
*/
async sign(): Promise<GeneralJWS> {
const jws: GeneralJWS = {
signatures: [],
}

await Promise.all(
this._signatures.map(async (sig, i) => {
const { protectedHeader, unprotectedHeader, options, key } = signatureRef.get(sig)!
const flattened = new FlattenedSign(this._payload)

if (protectedHeader) {
flattened.setProtectedHeader(protectedHeader)
}

if (unprotectedHeader) {
flattened.setUnprotectedHeader(unprotectedHeader)
}

const { payload, ...rest } = await flattened.sign(key, options)

if ('payload' in jws && jws.payload !== payload) {
throw new JWSInvalid(`index ${i} signature produced a different payload`)
} else {
jws.payload = payload
}

jws.signatures.push(rest)
}),
)

if ('payload' in jws && jws.payload === undefined) {
delete jws.payload
}

return jws
}
}

export type { KeyLike, GeneralJWS, JWSHeaderParameters }
10 changes: 10 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ export interface FlattenedJWS extends Partial<FlattenedJWSInput> {
signature: string
}

/**
* General JWS definition. Payload is an optional return property, it
* is not returned when JWS Unencoded Payload Option
* [RFC7797](https://tools.ietf.org/html/rfc7797) is used.
*/
export interface GeneralJWS {
payload?: string
signatures: Omit<FlattenedJWSInput, 'payload'>[]
}

export interface JoseHeaderParameters {
/**
* "kid" (Key ID) Header Parameter.
Expand Down
153 changes: 153 additions & 0 deletions test/jws/general.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/* eslint-disable no-param-reassign */
import test from 'ava';

const root = !('WEBCRYPTO' in process.env) ? '#dist' : '#dist/webcrypto';
Promise.all([
import(`${root}/jws/general/sign`),
import(`${root}/jws/general/verify`),
import(`${root}/util/random`),
]).then(
([{ default: GeneralSign }, { default: generalVerify }, { default: random }]) => {
test.before(async (t) => {
const encode = TextEncoder.prototype.encode.bind(new TextEncoder());
t.context.plaintext = encode('It’s a dangerous business, Frodo, going out your door.');
t.context.secret = random(new Uint8Array(48));
});

test('General JWS signing', async (t) => {
const sig = new GeneralSign(t.context.plaintext);

sig
.addSignature(t.context.secret)
.setProtectedHeader({ bar: 'baz' })
.setUnprotectedHeader({ alg: 'HS256' });

sig
.addSignature(t.context.secret)
.setProtectedHeader({ bar: 'baz' })
.setUnprotectedHeader({ alg: 'HS384' });

const generalJws = await sig.sign();

t.is(
generalJws.payload,
'SXTigJlzIGEgZGFuZ2Vyb3VzIGJ1c2luZXNzLCBGcm9kbywgZ29pbmcgb3V0IHlvdXIgZG9vci4',
);
t.is(generalJws.signatures.length, 2);
});

test('General JWS signing b64:false', async (t) => {
const sig = new GeneralSign(t.context.plaintext);

sig
.addSignature(t.context.secret)
.setProtectedHeader({ bar: 'baz', b64: false, crit: ['b64'] })
.setUnprotectedHeader({ alg: 'HS256' });

sig
.addSignature(t.context.secret)
.setProtectedHeader({ bar: 'baz', b64: false, crit: ['b64'] })
.setUnprotectedHeader({ alg: 'HS384' });

const generalJws = await sig.sign();

t.false('payload' in generalJws);
t.is(generalJws.signatures.length, 2);
});

test('General JWS signing validations', async (t) => {
const sig = new GeneralSign(t.context.plaintext);

t.throws(
() => {
sig
.addSignature(t.context.secret)
.setProtectedHeader({ bar: 'baz', crit: ['b64'], b64: false, alg: 'HS256' })
.setProtectedHeader({ bar: 'baz', crit: ['b64'], b64: false, alg: 'HS256' });
},
{ instanceOf: TypeError, message: 'setProtectedHeader can only be called once' },
);

t.throws(
() => {
sig
.addSignature(t.context.secret)
.setProtectedHeader({ bar: 'baz', crit: ['b64'], b64: true, alg: 'HS384' })
.setUnprotectedHeader({ foo: 'bar' })
.setUnprotectedHeader({ foo: 'bar' });
},
{ instanceOf: TypeError, message: 'setUnprotectedHeader can only be called once' },
);

await t.throwsAsync(sig.sign(), {
message: 'index 1 signature produced a different payload',
code: 'ERR_JWS_INVALID',
});
});

test('General JWS verify format validation', async (t) => {
const sig = new GeneralSign(t.context.plaintext);

sig
.addSignature(t.context.secret)
.setProtectedHeader({ bar: 'baz' })
.setUnprotectedHeader({ alg: 'HS256' });

const generalJws = await sig.sign();

{
await t.throwsAsync(generalVerify(null, t.context.secret), {
message: 'General JWS must be an object',
code: 'ERR_JWS_INVALID',
});
}

{
await t.throwsAsync(generalVerify({ signatures: null }, t.context.secret), {
message: 'JWS Signatures missing or incorrect type',
code: 'ERR_JWS_INVALID',
});
}

{
await t.throwsAsync(generalVerify({ signatures: [null] }, t.context.secret), {
message: 'JWS Signatures missing or incorrect type',
code: 'ERR_JWS_INVALID',
});
}

{
const jws = { payload: generalJws.payload, signatures: [] };

await t.throwsAsync(generalVerify(jws, t.context.secret), {
message: 'signature verification failed',
code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED',
});
}

{
await t.notThrowsAsync(generalVerify(generalJws, t.context.secret));
}

{
const { payload, signatures } = generalJws;
const jws = { payload, signatures: [...signatures, {}] };

await t.notThrowsAsync(generalVerify(jws, t.context.secret));
}

{
const { payload, signatures } = generalJws;
const jws = { payload, signatures: [{}, ...signatures] };

await t.notThrowsAsync(generalVerify(jws, t.context.secret));
}
});
},
(err) => {
test('failed to import', (t) => {
console.error(err);
t.fail();
});
},
);
Loading

0 comments on commit 6fb862c

Please sign in to comment.