mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
jws encryption support (#74)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
|
|||||||
32
yarn.lock
32
yarn.lock
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user