diff --git a/package.json b/package.json index ff0003b..3239821 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@types/express": "^4.17.13", "@types/fs-extra": "^9.0.13", "@types/jsonwebtoken": "^8.5.5", + "@types/jws": "^3.2.4", "@types/morgan": "^1.9.3", "@types/node": "^16.7.13", "@types/sharp": "^0.28.6", @@ -22,6 +23,7 @@ "fp-ts": "^2.11.1", "fs-extra": "^10.0.0", "jsonwebtoken": "^8.5.1", + "jws": "^4.0.0", "libxmljs2": "^0.28.0", "morgan": "^1.10.0", "node-html-parser": "^4.1.4", diff --git a/src/access_tokens.ts b/src/access_tokens.ts index 6750d60..8029527 100644 --- a/src/access_tokens.ts +++ b/src/access_tokens.ts @@ -5,7 +5,6 @@ import crypto from "crypto"; import { Encryption } from "./encryption"; import logger from "./logger"; import { Clock, SystemClock } from "./clock"; -import { b64Encode, b64Decode } from "./b64"; type AccessToken = { value: string; @@ -60,14 +59,11 @@ export class EncryptedAccessTokens implements AccessTokens { this.encryption = encryption; } - mint = (authToken: string): string => - b64Encode(JSON.stringify(this.encryption.encrypt(authToken))); + mint = (authToken: string): string => this.encryption.encrypt(authToken); authTokenFor(value: string): string | undefined { try { - return this.encryption.decrypt( - JSON.parse(b64Decode(value)) - ); + return this.encryption.decrypt(value); } catch { logger.warn("Failed to decrypt access token..."); return undefined; diff --git a/src/app.ts b/src/app.ts index bf3d4be..5022ab8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,7 +16,7 @@ import readConfig from "./config"; import sonos, { bonobService } from "./sonos"; import { MusicService } from "./music_service"; import { SystemClock } from "./clock"; -import { jwtTokenSigner } from "./encryption"; +import { jwtSigner } from "./encryption"; const config = readConfig(); @@ -88,7 +88,7 @@ const app = server( applyContextPath: true, logRequests: true, version, - tokenSigner: jwtTokenSigner(config.secret) + tokenSigner: jwtSigner(config.secret) } ); diff --git a/src/encryption.ts b/src/encryption.ts index 21b4a33..24b9342 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -5,10 +5,15 @@ import { createHash, } from "crypto"; import jwt from "jsonwebtoken"; +import jws from "jws"; const ALGORITHM = "aes-256-cbc"; const IV = randomBytes(16); +function isError(thing: any): thing is Error { + return thing.name && thing.message +} + export type Signer = { sign: (value: string) => string; verify: (token: string) => string; @@ -20,7 +25,8 @@ export const pSigner = (signer: Signer) => ({ try { return resolve(signer.sign(value)); } catch(e) { - reject(`Failed to sign value: ${e}`); + if(isError(e)) reject(e.message) + else reject(`Failed to sign value: ${e}`); } }); }, @@ -29,19 +35,20 @@ export const pSigner = (signer: Signer) => ({ try { return resolve(signer.verify(token)); }catch(e) { - reject(`Failed to verify value: ${e}`); + if(isError(e)) reject(e.message) + else reject(`Failed to verify value: ${e}`); } }); } }); -export const jwtTokenSigner = (secret: string) => ({ +export const jwtSigner = (secret: string) => ({ sign: (value: string) => jwt.sign(value, secret), verify: (token: string) => { try { return jwt.verify(token, secret) as string; } catch (e) { - throw `Failed to decode jwt, try re-authorising account`; + throw new Error(`Failed to verify jwt, try re-authorising account within sonos app`); } }, }); @@ -52,11 +59,22 @@ export type Hash = { }; export type Encryption = { - encrypt: (value: string) => Hash; - decrypt: (hash: Hash) => string; + encrypt: (value: string) => string; + decrypt: (value: string) => string; }; -const encryption = (secret: string): Encryption => { +export const jwsEncryption = (secret: string): Encryption => { + return { + encrypt: (value: string) => jws.sign({ + header: { alg: 'HS256' }, + payload: value, + secret: secret, + }), + decrypt: (value: string) => jws.decode(value).payload + } +} + +export const cryptoEncryption = (secret: string): Encryption => { const key = createHash("sha256") .update(String(secret)) .digest("base64") @@ -64,26 +82,26 @@ const encryption = (secret: string): Encryption => { return { encrypt: (value: string) => { const cipher = createCipheriv(ALGORITHM, key, IV); - return { - iv: IV.toString("hex"), - encryptedData: Buffer.concat([ - cipher.update(value), - cipher.final(), - ]).toString("hex"), - }; + return `${IV.toString("hex")}.${Buffer.concat([ + cipher.update(value), + cipher.final(), + ]).toString("hex")}`; }, - decrypt: (hash: Hash) => { + decrypt: (value: string) => { + const parts = value.split("."); + if(parts.length != 2) throw `Invalid value to decrypt`; + const decipher = createDecipheriv( ALGORITHM, key, - Buffer.from(hash.iv, "hex") + Buffer.from(parts[0]!, "hex") ); return Buffer.concat([ - decipher.update(Buffer.from(hash.encryptedData, "hex")), + decipher.update(Buffer.from(parts[1]!, "hex")), decipher.final(), ]).toString(); }, }; }; -export default encryption; +export default jwsEncryption; diff --git a/src/server.ts b/src/server.ts index 0b79cc9..292057d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,7 +34,7 @@ import { Icon, ICONS, festivals, features } from "./icon"; import _, { shuffle } from "underscore"; import morgan from "morgan"; import { takeWithRepeats } from "./utils"; -import { jwtTokenSigner, Signer } from "./encryption"; +import { jwtSigner, Signer } from "./encryption"; export const BONOB_ACCESS_TOKEN_HEADER = "bat"; @@ -97,7 +97,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = { applyContextPath: true, logRequests: false, version: "v?", - tokenSigner: jwtTokenSigner(`bonob-${uuid()}`), + tokenSigner: jwtSigner(`bonob-${uuid()}`), }; function server( diff --git a/tests/access_tokens.test.ts b/tests/access_tokens.test.ts index 478d368..1742289 100644 --- a/tests/access_tokens.test.ts +++ b/tests/access_tokens.test.ts @@ -133,10 +133,7 @@ describe("EncryptedAccessTokens", () => { describe("encrypt and decrypt", () => { it("should be able to round trip the token", () => { const authToken = `the token - ${uuid()}`; - const hash = { - encryptedData: "the encrypted token", - iv: "vi", - }; + const hash = "the encrypted token"; encryption.encrypt.mockReturnValue(hash); encryption.decrypt.mockReturnValue(authToken); @@ -144,9 +141,7 @@ describe("EncryptedAccessTokens", () => { const accessToken = accessTokens.mint(authToken); expect(accessToken).not.toContain(authToken); - expect(accessToken).toEqual( - Buffer.from(JSON.stringify(hash)).toString("base64") - ); + expect(accessToken).toEqual(hash); expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken); @@ -157,17 +152,12 @@ describe("EncryptedAccessTokens", () => { describe("when the token is a valid Hash but doesnt decrypt", () => { it("should return undefined", () => { - const hash = { - encryptedData: "valid hash", - iv: "vi", - }; + const hash = "valid hash"; encryption.decrypt.mockImplementation(() => { throw "Boooooom decryption failed!!!"; }); expect( - accessTokens.authTokenFor( - Buffer.from(JSON.stringify(hash)).toString("base64") - ) + accessTokens.authTokenFor(hash) ).toBeUndefined(); }); }); diff --git a/tests/encryption.test.ts b/tests/encryption.test.ts index 4dfb142..9066679 100644 --- a/tests/encryption.test.ts +++ b/tests/encryption.test.ts @@ -1,12 +1,45 @@ -import encryption from '../src/encryption'; - -describe("encrypt", () => { - const e = encryption("secret squirrel"); +import { cryptoEncryption, jwsEncryption } from '../src/encryption'; +describe("jwsEncryption", () => { it("can encrypt and decrypt", () => { + const e = jwsEncryption("secret squirrel"); + const value = "bobs your uncle" const hash = e.encrypt(value) - expect(hash.encryptedData).not.toEqual(value); + expect(hash).not.toContain(value); expect(e.decrypt(hash)).toEqual(value); }); -}) \ No newline at end of file + + it("returns different values for different secrets", () => { + const e1 = jwsEncryption("e1"); + const e2 = jwsEncryption("e2"); + + const value = "bobs your uncle" + const h1 = e1.encrypt(value) + const h2 = e2.encrypt(value) + + expect(h1).not.toEqual(h2); + }); +}) + +describe("cryptoEncryption", () => { + it("can encrypt and decrypt", () => { + const e = cryptoEncryption("secret squirrel"); + + const value = "bobs your uncle" + const hash = e.encrypt(value) + expect(hash).not.toContain(value); + expect(e.decrypt(hash)).toEqual(value); + }); + + it("returns different values for different secrets", () => { + const e1 = cryptoEncryption("e1"); + const e2 = cryptoEncryption("e2"); + + const value = "bobs your uncle" + const h1 = e1.encrypt(value) + const h2 = e2.encrypt(value) + + expect(h1).not.toEqual(h2); + }); +}) diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index b9466d4..348bf4f 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -55,7 +55,7 @@ import { AccessTokens } from "../src/access_tokens"; import dayjs from "dayjs"; import url, { URLBuilder } from "../src/url_builder"; import { iconForGenre } from "../src/icon"; -import { jwtTokenSigner } from "../src/encryption"; +import { jwtSigner } from "../src/encryption"; const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); @@ -597,7 +597,7 @@ describe("wsdl api", () => { [bonobUrlWithoutContextPath, bonobUrlWithContextPath].forEach((bonobUrl) => { describe(`bonob with url ${bonobUrl}`, () => { - const tokenSigner = jwtTokenSigner(`smapi-test-secret-${uuid()}`); + const tokenSigner = jwtSigner(`smapi-test-secret-${uuid()}`); const jwtSign = tokenSigner.sign; const authToken = `authToken-${uuid()}`; diff --git a/yarn.lock b/yarn.lock index e21d807..b722a48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1193,6 +1193,15 @@ __metadata: languageName: node linkType: hard +"@types/jws@npm:^3.2.4": + version: 3.2.4 + resolution: "@types/jws@npm:3.2.4" + dependencies: + "@types/node": "*" + checksum: 43427a5b00ef0c5e60df6bc8e59c4153220c757fdbde2b79a1852e86083bdc76b660b3c0d89ae9e5902dab00bf6ae9f25a551a375ca233b6e02fa1e4e3fff6c4 + languageName: node + linkType: hard + "@types/keyv@npm:^3.1.1": version: 3.1.2 resolution: "@types/keyv@npm:3.1.2" @@ -1792,6 +1801,7 @@ __metadata: "@types/fs-extra": ^9.0.13 "@types/jest": ^27.0.1 "@types/jsonwebtoken": ^8.5.5 + "@types/jws": ^3.2.4 "@types/mocha": ^9.0.0 "@types/morgan": ^1.9.3 "@types/node": ^16.7.13 @@ -1811,6 +1821,7 @@ __metadata: image-js: ^0.33.0 jest: ^27.1.0 jsonwebtoken: ^8.5.1 + jws: ^4.0.0 libxmljs2: ^0.28.0 morgan: ^1.10.0 node-html-parser: ^4.1.4 @@ -4697,6 +4708,17 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^2.0.0": + version: 2.0.0 + resolution: "jwa@npm:2.0.0" + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: ^5.0.1 + checksum: 8f00b71ad5fe94cb55006d0d19202f8f56889109caada2f7eeb63ca81755769ce87f4f48101967f398462e3b8ae4faebfbd5a0269cb755dead5d63c77ba4d2f1 + languageName: node + linkType: hard + "jws@npm:^3.2.2": version: 3.2.2 resolution: "jws@npm:3.2.2" @@ -4707,6 +4729,16 @@ __metadata: languageName: node linkType: hard +"jws@npm:^4.0.0": + version: 4.0.0 + resolution: "jws@npm:4.0.0" + dependencies: + jwa: ^2.0.0 + safe-buffer: ^5.0.1 + checksum: d68d07aa6d1b8cb35c363a9bd2b48f15064d342a5d9dc18a250dbbce8dc06bd7e4792516c50baa16b8d14f61167c19e851fd7f66b59ecc68b7f6a013759765f7 + languageName: node + linkType: hard + "keyv@npm:^3.0.0": version: 3.1.0 resolution: "keyv@npm:3.1.0"