From 025786bd76415195e5daff0c5f2270ece31bd963 Mon Sep 17 00:00:00 2001 From: Joye Lin Date: Thu, 8 Feb 2024 22:12:31 +0800 Subject: [PATCH] fix: the bytes of the output of the hash function must be base64url-encoded. (#57) Signed-off-by: JOYE LIN Co-authored-by: Lukas.J.Han --- src/crypto.ts | 6 ++++ src/decoy.ts | 6 ++-- src/disclosure.ts | 26 ++++++++++---- src/test/decoy.spec.ts | 17 ++++++--- src/test/disclosure.spec.ts | 70 +++++++++++++++++++++++++++++++++++-- 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/src/crypto.ts b/src/crypto.ts index b537aff6..6a0df71d 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,4 +1,5 @@ import { SDJWTException } from './error'; +import { Base64Url } from './base64url'; export const generateSalt = (length: number): string => { if (length <= 0) { @@ -25,5 +26,10 @@ export const getHasher = (algorithm: string = 'SHA-256') => { return (data: string) => digest(data, algorithm); }; +export const hexToB64Url = (hexString: string) => { + const theBytes = Buffer.from(hexString,'hex') + return Base64Url.encode(theBytes) +} + const toNodeCryptoAlg = (hashAlg: string): string => hashAlg.replace('-', '').toLowerCase(); diff --git a/src/decoy.ts b/src/decoy.ts index e14744a8..cc83dd44 100644 --- a/src/decoy.ts +++ b/src/decoy.ts @@ -1,5 +1,5 @@ import { Base64Url } from './base64url'; -import { generateSalt, digest } from './crypto'; +import { generateSalt, digest , hexToB64Url} from './crypto'; import { Hasher, SaltGenerator } from './type'; export const createDecoy = async ( @@ -7,7 +7,7 @@ export const createDecoy = async ( saltGenerator: SaltGenerator = generateSalt, ): Promise => { const salt = saltGenerator(16); - const digest = await hasher(salt); - const decoy = Base64Url.encode(digest); + const decoyHexString = await hasher(salt); + const decoy = hexToB64Url(decoyHexString) return decoy; }; diff --git a/src/disclosure.ts b/src/disclosure.ts index 83a30003..25a0a4fd 100644 --- a/src/disclosure.ts +++ b/src/disclosure.ts @@ -1,6 +1,7 @@ import { Base64Url } from './base64url'; import { SDJWTException } from './error'; import { Hasher } from './type'; +import { hexToB64Url} from './crypto'; export type DisclosureData = [string, string, T] | [string, T]; @@ -35,7 +36,11 @@ export class Disclosure { } public encode() { - return Base64Url.encode(JSON.stringify(this.decode())); + return this.encodeRaw(JSON.stringify(this.decode())); + } + + public encodeRaw(s: string) { + return Base64Url.encode(s); } public decode(): DisclosureData { @@ -44,12 +49,19 @@ export class Disclosure { : [this.salt, this.value]; } - public async digest(hasher: Hasher): Promise { - if (!this._digest) { - const hash = await hasher(this.encode()); - this._digest = Base64Url.encode(hash); - } - + public async digestRaw(hasher: Hasher, encodeString: string): Promise { + // + // draft-ietf-oauth-selective-disclosure-jwt-07 + // + // The bytes of the output of the hash function MUST be base64url-encoded, and are not the bytes making up the (often used) hex + // representation of the bytes of the digest. + // + const hexString = await hasher(encodeString); + this._digest = hexToB64Url(hexString); return this._digest; } + + public async digest(hasher: Hasher): Promise { + return await this.digestRaw(hasher, this.encode()); + } } diff --git a/src/test/decoy.spec.ts b/src/test/decoy.spec.ts index 1405e410..6ea43e4c 100644 --- a/src/test/decoy.spec.ts +++ b/src/test/decoy.spec.ts @@ -1,16 +1,25 @@ import { createDecoy } from '../decoy'; +import { Base64Url } from '../base64url'; +import { digest } from '../crypto'; describe('Decoy', () => { test('decoy', async () => { const decoyValue = await createDecoy(); - expect(decoyValue.length).toBe(86); + // base64url-encoded sha256 is a 43-octet URL safe string. + expect(decoyValue.length).toBe(43); }); + // ref https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/07/ + // *Claim email*: + // * SHA-256 Hash: JzYjH4svliH0R3PyEMfeZu6Jt69u5qehZo7F7EPYlSE + // * Disclosure: WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ + // * Contents: ["6Ij7tM-a5iVPGboS5tmvVA", "email", "johndoe@example.com"] test('apply hasher and saltGenerator', async () => { const decoyValue = await createDecoy( - async (data) => data, - () => 'salt', + digest, + () => Base64Url.encode('["6Ij7tM-a5iVPGboS5tmvVA", "email", "johndoe@example.com"]'), ); - expect(decoyValue).toBe('c2FsdA'); + expect(decoyValue).toBe('JzYjH4svliH0R3PyEMfeZu6Jt69u5qehZo7F7EPYlSE'); }); + }); diff --git a/src/test/disclosure.spec.ts b/src/test/disclosure.spec.ts index a31c3b3d..8faa98fe 100644 --- a/src/test/disclosure.spec.ts +++ b/src/test/disclosure.spec.ts @@ -1,6 +1,38 @@ -import { generateSalt, digest as hash } from '../crypto'; -import { Disclosure } from '../disclosure'; +import { generateSalt, digest as hashHex } from '../crypto'; +import { Disclosure, DisclosureData } from '../disclosure'; import { SDJWTException } from '../error'; +import { Base64Url } from '../base64url'; + +/* +ref draft-ietf-oauth-selective-disclosure-jwt-07 +Claim given_name: +SHA-256 Hash: jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4 +Disclosure: WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd +Contents: ["2GLC42sKQveCfGfryNRN9w", "given_name", "John"] +For example, the SHA-256 digest of the Disclosure +WyI2cU1RdlJMNWhhaiIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0 would be uutlBuYeMDyjLLTpf6Jxi7yNkEF35jdyWMn9U7b_RYY. +The SHA-256 digest of the Disclosure +WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIkZSIl0 would be w0I8EKcdCtUPkGCNUrfwVp2xEgNjtoIDlOxc9-PlOhs. +*/ +const TestDataDraft7 = { + claimTests: [ + { + contents: '["2GLC42sKQveCfGfryNRN9w", "given_name", "John"]', + digest: 'jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4', + disclosure: 'WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd' + }, + ], + sha256Tests: [ + { + digest: 'uutlBuYeMDyjLLTpf6Jxi7yNkEF35jdyWMn9U7b_RYY', + disclosure: 'WyI2cU1RdlJMNWhhaiIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0' + }, + { + digest: 'w0I8EKcdCtUPkGCNUrfwVp2xEgNjtoIDlOxc9-PlOhs', + disclosure: 'WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIkZSIl0' + }, + ] +} describe('Disclosure', () => { test('create object disclosure', async () => { @@ -57,8 +89,40 @@ describe('Disclosure', () => { test('digest disclosure', async () => { const salt = generateSalt(16); const disclosure = new Disclosure([salt, 'name', 'James']); - const digest = await disclosure.digest(hash); + const digest = await disclosure.digest(hashHex); expect(digest).toBeDefined(); expect(typeof digest).toBe('string'); }); + + + test('should return a digest after calling digest method', async () => { + const givenData: DisclosureData = ["2GLC42sKQveCfGfryNRN9w", "given_name", "John"]; + const theDisclosure = new Disclosure(givenData); + // + // JSON.stringify() version + // SHA-256 Hash : 8VHiz7qTXavxvpiTYDCSr_shkUO6qRcVXjkhEnt1os4 + // Disclosure: WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwiZ2l2ZW5fbmFtZSIsIkpvaG4iXQ + // Contents: ["2GLC42sKQveCfGfryNRN9w","given_name","John"] + // + // Testing encoding of the data using encodeRaw and encode functions. + // The differences in the output of encodeRaw and encode methods + // arise from the formatting during JSON.stringify operation. encodeRaw retains whitespace while encode does not. + expect(theDisclosure.encodeRaw(TestDataDraft7.claimTests[0].contents)).toBe(TestDataDraft7.claimTests[0].disclosure) + expect(theDisclosure.encode()).toBe('WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwiZ2l2ZW5fbmFtZSIsIkpvaG4iXQ') + + // + // Testing digestRaw function. Testing against known digest and disclosure pairs. + // The digest is expected to be same as the known digest when passed with the corresponding disclosure. + // + await expect(theDisclosure.digestRaw(hashHex, TestDataDraft7.claimTests[0].disclosure)).resolves.toBe(TestDataDraft7.claimTests[0].digest) + await expect(theDisclosure.digestRaw(hashHex, 'WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwiZ2l2ZW5fbmFtZSIsIkpvaG4iXQ')).resolves.toBe('8VHiz7qTXavxvpiTYDCSr_shkUO6qRcVXjkhEnt1os4') + await expect(theDisclosure.digest(hashHex)).resolves.toBe('8VHiz7qTXavxvpiTYDCSr_shkUO6qRcVXjkhEnt1os4'); + // + // The result of digestRaw changes based on the hashing strategy used. In this test, we are using the test data from 'draft-ietf-oauth-selective-disclosure-jwt-07'. + // + for (const elem of TestDataDraft7.sha256Tests) { + await expect(theDisclosure.digestRaw(hashHex, elem.disclosure)).resolves.toBe(elem.digest) + } + }); + });