jws encryption support (#74)

This commit is contained in:
Simon J
2021-11-06 09:03:46 +11:00
committed by GitHub
parent eea102891d
commit 9851ee46b3
9 changed files with 121 additions and 50 deletions

View File

@@ -10,6 +10,7 @@
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/fs-extra": "^9.0.13", "@types/fs-extra": "^9.0.13",
"@types/jsonwebtoken": "^8.5.5", "@types/jsonwebtoken": "^8.5.5",
"@types/jws": "^3.2.4",
"@types/morgan": "^1.9.3", "@types/morgan": "^1.9.3",
"@types/node": "^16.7.13", "@types/node": "^16.7.13",
"@types/sharp": "^0.28.6", "@types/sharp": "^0.28.6",
@@ -22,6 +23,7 @@
"fp-ts": "^2.11.1", "fp-ts": "^2.11.1",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"jws": "^4.0.0",
"libxmljs2": "^0.28.0", "libxmljs2": "^0.28.0",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"node-html-parser": "^4.1.4", "node-html-parser": "^4.1.4",

View File

@@ -5,7 +5,6 @@ import crypto from "crypto";
import { Encryption } from "./encryption"; import { Encryption } from "./encryption";
import logger from "./logger"; import logger from "./logger";
import { Clock, SystemClock } from "./clock"; import { Clock, SystemClock } from "./clock";
import { b64Encode, b64Decode } from "./b64";
type AccessToken = { type AccessToken = {
value: string; value: string;
@@ -60,14 +59,11 @@ export class EncryptedAccessTokens implements AccessTokens {
this.encryption = encryption; this.encryption = encryption;
} }
mint = (authToken: string): string => mint = (authToken: string): string => this.encryption.encrypt(authToken);
b64Encode(JSON.stringify(this.encryption.encrypt(authToken)));
authTokenFor(value: string): string | undefined { authTokenFor(value: string): string | undefined {
try { try {
return this.encryption.decrypt( return this.encryption.decrypt(value);
JSON.parse(b64Decode(value))
);
} catch { } catch {
logger.warn("Failed to decrypt access token..."); logger.warn("Failed to decrypt access token...");
return undefined; return undefined;

View File

@@ -16,7 +16,7 @@ import readConfig from "./config";
import sonos, { bonobService } from "./sonos"; import sonos, { bonobService } from "./sonos";
import { MusicService } from "./music_service"; import { MusicService } from "./music_service";
import { SystemClock } from "./clock"; import { SystemClock } from "./clock";
import { jwtTokenSigner } from "./encryption"; import { jwtSigner } from "./encryption";
const config = readConfig(); const config = readConfig();
@@ -88,7 +88,7 @@ const app = server(
applyContextPath: true, applyContextPath: true,
logRequests: true, logRequests: true,
version, version,
tokenSigner: jwtTokenSigner(config.secret) tokenSigner: jwtSigner(config.secret)
} }
); );

View File

@@ -5,10 +5,15 @@ import {
createHash, createHash,
} from "crypto"; } from "crypto";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import jws from "jws";
const ALGORITHM = "aes-256-cbc"; const ALGORITHM = "aes-256-cbc";
const IV = randomBytes(16); const IV = randomBytes(16);
function isError(thing: any): thing is Error {
return thing.name && thing.message
}
export type Signer = { export type Signer = {
sign: (value: string) => string; sign: (value: string) => string;
verify: (token: string) => string; verify: (token: string) => string;
@@ -20,7 +25,8 @@ export const pSigner = (signer: Signer) => ({
try { try {
return resolve(signer.sign(value)); return resolve(signer.sign(value));
} catch(e) { } 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 { try {
return resolve(signer.verify(token)); return resolve(signer.verify(token));
}catch(e) { }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), sign: (value: string) => jwt.sign(value, secret),
verify: (token: string) => { verify: (token: string) => {
try { try {
return jwt.verify(token, secret) as string; return jwt.verify(token, secret) as string;
} catch (e) { } 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 = { export type Encryption = {
encrypt: (value: string) => Hash; encrypt: (value: string) => string;
decrypt: (hash: Hash) => 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") const key = createHash("sha256")
.update(String(secret)) .update(String(secret))
.digest("base64") .digest("base64")
@@ -64,26 +82,26 @@ const encryption = (secret: string): Encryption => {
return { return {
encrypt: (value: string) => { encrypt: (value: string) => {
const cipher = createCipheriv(ALGORITHM, key, IV); const cipher = createCipheriv(ALGORITHM, key, IV);
return { return `${IV.toString("hex")}.${Buffer.concat([
iv: IV.toString("hex"), cipher.update(value),
encryptedData: Buffer.concat([ cipher.final(),
cipher.update(value), ]).toString("hex")}`;
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( const decipher = createDecipheriv(
ALGORITHM, ALGORITHM,
key, key,
Buffer.from(hash.iv, "hex") Buffer.from(parts[0]!, "hex")
); );
return Buffer.concat([ return Buffer.concat([
decipher.update(Buffer.from(hash.encryptedData, "hex")), decipher.update(Buffer.from(parts[1]!, "hex")),
decipher.final(), decipher.final(),
]).toString(); ]).toString();
}, },
}; };
}; };
export default encryption; export default jwsEncryption;

View File

@@ -34,7 +34,7 @@ import { Icon, ICONS, festivals, features } from "./icon";
import _, { shuffle } from "underscore"; import _, { shuffle } from "underscore";
import morgan from "morgan"; import morgan from "morgan";
import { takeWithRepeats } from "./utils"; import { takeWithRepeats } from "./utils";
import { jwtTokenSigner, Signer } from "./encryption"; import { jwtSigner, Signer } from "./encryption";
export const BONOB_ACCESS_TOKEN_HEADER = "bat"; export const BONOB_ACCESS_TOKEN_HEADER = "bat";
@@ -97,7 +97,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
applyContextPath: true, applyContextPath: true,
logRequests: false, logRequests: false,
version: "v?", version: "v?",
tokenSigner: jwtTokenSigner(`bonob-${uuid()}`), tokenSigner: jwtSigner(`bonob-${uuid()}`),
}; };
function server( function server(

View File

@@ -133,10 +133,7 @@ describe("EncryptedAccessTokens", () => {
describe("encrypt and decrypt", () => { describe("encrypt and decrypt", () => {
it("should be able to round trip the token", () => { it("should be able to round trip the token", () => {
const authToken = `the token - ${uuid()}`; const authToken = `the token - ${uuid()}`;
const hash = { const hash = "the encrypted token";
encryptedData: "the encrypted token",
iv: "vi",
};
encryption.encrypt.mockReturnValue(hash); encryption.encrypt.mockReturnValue(hash);
encryption.decrypt.mockReturnValue(authToken); encryption.decrypt.mockReturnValue(authToken);
@@ -144,9 +141,7 @@ describe("EncryptedAccessTokens", () => {
const accessToken = accessTokens.mint(authToken); const accessToken = accessTokens.mint(authToken);
expect(accessToken).not.toContain(authToken); expect(accessToken).not.toContain(authToken);
expect(accessToken).toEqual( expect(accessToken).toEqual(hash);
Buffer.from(JSON.stringify(hash)).toString("base64")
);
expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken); expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken);
@@ -157,17 +152,12 @@ describe("EncryptedAccessTokens", () => {
describe("when the token is a valid Hash but doesnt decrypt", () => { describe("when the token is a valid Hash but doesnt decrypt", () => {
it("should return undefined", () => { it("should return undefined", () => {
const hash = { const hash = "valid hash";
encryptedData: "valid hash",
iv: "vi",
};
encryption.decrypt.mockImplementation(() => { encryption.decrypt.mockImplementation(() => {
throw "Boooooom decryption failed!!!"; throw "Boooooom decryption failed!!!";
}); });
expect( expect(
accessTokens.authTokenFor( accessTokens.authTokenFor(hash)
Buffer.from(JSON.stringify(hash)).toString("base64")
)
).toBeUndefined(); ).toBeUndefined();
}); });
}); });

View File

@@ -1,12 +1,45 @@
import encryption from '../src/encryption'; import { cryptoEncryption, jwsEncryption } from '../src/encryption';
describe("encrypt", () => {
const e = encryption("secret squirrel");
describe("jwsEncryption", () => {
it("can encrypt and decrypt", () => { it("can encrypt and decrypt", () => {
const e = jwsEncryption("secret squirrel");
const value = "bobs your uncle" const value = "bobs your uncle"
const hash = e.encrypt(value) const hash = e.encrypt(value)
expect(hash.encryptedData).not.toEqual(value); expect(hash).not.toContain(value);
expect(e.decrypt(hash)).toEqual(value); expect(e.decrypt(hash)).toEqual(value);
}); });
})
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);
});
})

View File

@@ -55,7 +55,7 @@ import { AccessTokens } from "../src/access_tokens";
import dayjs from "dayjs"; import dayjs from "dayjs";
import url, { URLBuilder } from "../src/url_builder"; import url, { URLBuilder } from "../src/url_builder";
import { iconForGenre } from "../src/icon"; import { iconForGenre } from "../src/icon";
import { jwtTokenSigner } from "../src/encryption"; import { jwtSigner } from "../src/encryption";
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
@@ -597,7 +597,7 @@ describe("wsdl api", () => {
[bonobUrlWithoutContextPath, bonobUrlWithContextPath].forEach((bonobUrl) => { [bonobUrlWithoutContextPath, bonobUrlWithContextPath].forEach((bonobUrl) => {
describe(`bonob with url ${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 jwtSign = tokenSigner.sign;
const authToken = `authToken-${uuid()}`; const authToken = `authToken-${uuid()}`;

View File

@@ -1193,6 +1193,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/keyv@npm:^3.1.1":
version: 3.1.2 version: 3.1.2
resolution: "@types/keyv@npm:3.1.2" resolution: "@types/keyv@npm:3.1.2"
@@ -1792,6 +1801,7 @@ __metadata:
"@types/fs-extra": ^9.0.13 "@types/fs-extra": ^9.0.13
"@types/jest": ^27.0.1 "@types/jest": ^27.0.1
"@types/jsonwebtoken": ^8.5.5 "@types/jsonwebtoken": ^8.5.5
"@types/jws": ^3.2.4
"@types/mocha": ^9.0.0 "@types/mocha": ^9.0.0
"@types/morgan": ^1.9.3 "@types/morgan": ^1.9.3
"@types/node": ^16.7.13 "@types/node": ^16.7.13
@@ -1811,6 +1821,7 @@ __metadata:
image-js: ^0.33.0 image-js: ^0.33.0
jest: ^27.1.0 jest: ^27.1.0
jsonwebtoken: ^8.5.1 jsonwebtoken: ^8.5.1
jws: ^4.0.0
libxmljs2: ^0.28.0 libxmljs2: ^0.28.0
morgan: ^1.10.0 morgan: ^1.10.0
node-html-parser: ^4.1.4 node-html-parser: ^4.1.4
@@ -4697,6 +4708,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "jws@npm:^3.2.2":
version: 3.2.2 version: 3.2.2
resolution: "jws@npm:3.2.2" resolution: "jws@npm:3.2.2"
@@ -4707,6 +4729,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "keyv@npm:^3.0.0":
version: 3.1.0 version: 3.1.0
resolution: "keyv@npm:3.1.0" resolution: "keyv@npm:3.1.0"