From f613b6314c063853a1b342982f373902506d042f Mon Sep 17 00:00:00 2001 From: Ulad Kasach Date: Thu, 23 Feb 2023 23:28:17 -0500 Subject: [PATCH] feat(contract): expose all endpoints required for secure hmackey auth --- .vscode/settings.json | 6 + package-lock.json | 182 ++++++++------ package.json | 9 +- readme.md | 108 ++++---- src/contract/index.ts | 9 + src/domain/SimpleSignableRequest.ts | 6 + src/domain/SimpleSignatureMetadata.ts | 35 +++ src/index.ts | 1 + src/logic/hash/toHashSha256.ts | 10 + .../getRequestSignatureFromHeaders.test.ts | 61 +++++ .../headers/getRequestSignatureFromHeaders.ts | 14 ++ src/logic/keys/issueClientKeyPair.test.ts | 48 ++++ src/logic/keys/issueClientKeyPair.ts | 44 ++++ ...assertRequestSignatureAuthenticity.test.ts | 230 ++++++++++++++++++ .../assertRequestSignatureAuthenticity.ts | 103 ++++++++ ...teSecureRequestSignatureHmacDigest.test.ts | 22 ++ ...computeSecureRequestSignatureHmacDigest.ts | 44 ++++ .../createSecureRequestSignature.test.ts | 41 ++++ .../createSecureRequestSignature.ts | 58 +++++ .../decodeRequestSignatureMetadata.ts | 19 ++ .../encodeRequestSignatureMetadata.ts | 5 + .../signatures/isRequestSignature.test.ts | 37 +++ src/logic/signatures/isRequestSignature.ts | 19 ++ .../errors/UnauthableRequestSignatureError.ts | 14 ++ .../UnauthenticRequestSignatureError.ts | 15 ++ 25 files changed, 1005 insertions(+), 135 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/contract/index.ts create mode 100644 src/domain/SimpleSignableRequest.ts create mode 100644 src/domain/SimpleSignatureMetadata.ts create mode 100644 src/logic/hash/toHashSha256.ts create mode 100644 src/logic/headers/getRequestSignatureFromHeaders.test.ts create mode 100644 src/logic/headers/getRequestSignatureFromHeaders.ts create mode 100644 src/logic/keys/issueClientKeyPair.test.ts create mode 100644 src/logic/keys/issueClientKeyPair.ts create mode 100644 src/logic/signatures/assertRequestSignatureAuthenticity.test.ts create mode 100644 src/logic/signatures/assertRequestSignatureAuthenticity.ts create mode 100644 src/logic/signatures/computeSecureRequestSignatureHmacDigest.test.ts create mode 100644 src/logic/signatures/computeSecureRequestSignatureHmacDigest.ts create mode 100644 src/logic/signatures/createSecureRequestSignature.test.ts create mode 100644 src/logic/signatures/createSecureRequestSignature.ts create mode 100644 src/logic/signatures/decodeRequestSignatureMetadata.ts create mode 100644 src/logic/signatures/encodeRequestSignatureMetadata.ts create mode 100644 src/logic/signatures/isRequestSignature.test.ts create mode 100644 src/logic/signatures/isRequestSignature.ts create mode 100644 src/utils/errors/UnauthableRequestSignatureError.ts create mode 100644 src/utils/errors/UnauthenticRequestSignatureError.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e606190 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cSpell.words": [ + "millis", + "Unauthable" + ] +} diff --git a/package-lock.json b/package-lock.json index 32f6a71..ab75892 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "simple-hmackey-auth", "version": "0.0.1", "dependencies": { + "domain-objects": "0.10.3", + "joi": "17.8.3", "uuid": "9.0.0" }, "devDependencies": { @@ -21,8 +23,8 @@ "@typescript-eslint/parser": "5.46.1", "core-js": "3.26.1", "cz-conventional-changelog": "3.3.0", - "declapract": "^0.10.9", - "declapract-typescript-ehmpathy": "^0.20.14", + "declapract": "0.10.9", + "declapract-typescript-ehmpathy": "0.20.14", "depcheck": "1.4.3", "eslint": "8.30.0", "eslint-config-airbnb-typescript": "17.0.0", @@ -34,8 +36,7 @@ "prettier": "2.8.1", "ts-jest": "29.0.3", "ts-node": "10.9.1", - "typescript": "4.9.4", - "uuid": "^3.3.3" + "typescript": "4.9.4" }, "engines": { "node": ">=8.0.0" @@ -1408,14 +1409,12 @@ "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" }, "node_modules/@hapi/topo": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dev": true, "dependencies": { "@hapi/hoek": "^9.0.0" } @@ -4302,7 +4301,6 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "dev": true, "dependencies": { "@hapi/hoek": "^9.0.0" } @@ -4310,14 +4308,12 @@ "node_modules/@sideway/formula": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, "node_modules/@sinclair/typebox": { "version": "0.25.24", @@ -4553,7 +4549,6 @@ "resolved": "https://registry.npmjs.org/@types/joi/-/joi-17.2.3.tgz", "integrity": "sha512-dGjs/lhrWOa+eO0HwgxCSnDm5eMGCsXuvLglMghJq32F6q5LyyNuXb41DHzrg501CKNOSSAHmfB7FDGeUnDmzw==", "deprecated": "This is a stub types definition. joi provides its own type definitions, so you do not need this installed.", - "dev": true, "dependencies": { "joi": "*" } @@ -4573,14 +4568,12 @@ "node_modules/@types/lodash": { "version": "4.14.191", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", - "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==", - "dev": true + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==" }, "node_modules/@types/lodash.omit": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/@types/lodash.omit/-/lodash.omit-4.5.7.tgz", "integrity": "sha512-6q6cNg0tQ6oTWjSM+BcYMBhan54P/gLqBldG4AuXd3nKr0oeVekWNS4VrNEu3BhCSDXtGapi7zjhnna0s03KpA==", - "dev": true, "dependencies": { "@types/lodash": "*" } @@ -4667,8 +4660,7 @@ "node_modules/@types/yup": { "version": "0.29.14", "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz", - "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==", - "dev": true + "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.46.1", @@ -6922,6 +6914,16 @@ "node": ">=8.0.0" } }, + "node_modules/declapract-typescript-ehmpathy/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/declapract/node_modules/domain-objects": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/domain-objects/-/domain-objects-0.7.6.tgz", @@ -6938,6 +6940,19 @@ "node": ">=8.0.0" } }, + "node_modules/declapract/node_modules/joi": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.4.0.tgz", + "integrity": "sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.0", + "@sideway/formula": "^3.0.0", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/declapract/node_modules/ts-node": { "version": "8.6.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.6.2.tgz", @@ -6970,15 +6985,6 @@ "node": ">=8.0.0" } }, - "node_modules/declapract/node_modules/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", - "dev": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -7345,6 +7351,21 @@ "node": ">=0.10.0" } }, + "node_modules/domain-objects": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/domain-objects/-/domain-objects-0.10.3.tgz", + "integrity": "sha512-dRAB+bUZ/OOv5cUezvapZZVsD2FzfwD3Z7qa4yUD3SO8A+t4IVIqSxTi+z/g32KThjRKr7TAexXQNj6WmeJjsQ==", + "dependencies": { + "@types/joi": "^17.2.3", + "@types/lodash.omit": "^4.5.6", + "@types/yup": "^0.29.6", + "lodash.omit": "^4.5.0", + "lodash.pick": "^4.4.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -11933,15 +11954,14 @@ } }, "node_modules/joi": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.4.0.tgz", - "integrity": "sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg==", - "dev": true, + "version": "17.8.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.8.3.tgz", + "integrity": "sha512-q5Fn6Tj/jR8PfrLrx4fpGH4v9qM6o+vDUfD4/3vxxyg34OmKcNqYZ1qn2mpLza96S8tL0p0rIw2gOZX+/cTg9w==", "dependencies": { "@hapi/hoek": "^9.0.0", "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.0", - "@sideway/formula": "^3.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, @@ -12204,14 +12224,12 @@ "node_modules/lodash.omit": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", - "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", - "dev": true + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==" }, "node_modules/lodash.pick": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", - "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==", - "dev": true + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==" }, "node_modules/lodash.template": { "version": "4.5.0", @@ -16772,13 +16790,11 @@ "dev": true }, "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "dev": true, + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", "bin": { - "uuid": "bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -18784,14 +18800,12 @@ "@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" }, "@hapi/topo": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dev": true, "requires": { "@hapi/hoek": "^9.0.0" } @@ -21001,7 +21015,6 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "dev": true, "requires": { "@hapi/hoek": "^9.0.0" } @@ -21009,14 +21022,12 @@ "@sideway/formula": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" }, "@sideway/pinpoint": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, "@sinclair/typebox": { "version": "0.25.24", @@ -21240,7 +21251,6 @@ "version": "17.2.3", "resolved": "https://registry.npmjs.org/@types/joi/-/joi-17.2.3.tgz", "integrity": "sha512-dGjs/lhrWOa+eO0HwgxCSnDm5eMGCsXuvLglMghJq32F6q5LyyNuXb41DHzrg501CKNOSSAHmfB7FDGeUnDmzw==", - "dev": true, "requires": { "joi": "*" } @@ -21260,14 +21270,12 @@ "@types/lodash": { "version": "4.14.191", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", - "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==", - "dev": true + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==" }, "@types/lodash.omit": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/@types/lodash.omit/-/lodash.omit-4.5.7.tgz", "integrity": "sha512-6q6cNg0tQ6oTWjSM+BcYMBhan54P/gLqBldG4AuXd3nKr0oeVekWNS4VrNEu3BhCSDXtGapi7zjhnna0s03KpA==", - "dev": true, "requires": { "@types/lodash": "*" } @@ -21354,8 +21362,7 @@ "@types/yup": { "version": "0.29.14", "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz", - "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==", - "dev": true + "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==" }, "@typescript-eslint/eslint-plugin": { "version": "5.46.1", @@ -22975,6 +22982,19 @@ "lodash.pick": "^4.4.0" } }, + "joi": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.4.0.tgz", + "integrity": "sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.0", + "@sideway/formula": "^3.0.0", + "@sideway/pinpoint": "^2.0.0" + } + }, "ts-node": { "version": "8.6.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.6.2.tgz", @@ -22993,12 +23013,6 @@ "resolved": "https://registry.npmjs.org/type-fns/-/type-fns-0.7.0.tgz", "integrity": "sha512-yADWicFwdz/GA4KTTUQ5cWIpXd7k5/4oh9aUhN5YAaRiZCao8XK5egRrHth4uYQVFzdD8Aq4S2haZue7AFfwmw==", "dev": true - }, - "uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", - "dev": true } } }, @@ -23046,6 +23060,12 @@ "resolved": "https://registry.npmjs.org/type-fns/-/type-fns-0.7.0.tgz", "integrity": "sha512-yADWicFwdz/GA4KTTUQ5cWIpXd7k5/4oh9aUhN5YAaRiZCao8XK5egRrHth4uYQVFzdD8Aq4S2haZue7AFfwmw==", "dev": true + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true } } }, @@ -23329,6 +23349,18 @@ "esutils": "^2.0.2" } }, + "domain-objects": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/domain-objects/-/domain-objects-0.10.3.tgz", + "integrity": "sha512-dRAB+bUZ/OOv5cUezvapZZVsD2FzfwD3Z7qa4yUD3SO8A+t4IVIqSxTi+z/g32KThjRKr7TAexXQNj6WmeJjsQ==", + "requires": { + "@types/joi": "^17.2.3", + "@types/lodash.omit": "^4.5.6", + "@types/yup": "^0.29.6", + "lodash.omit": "^4.5.0", + "lodash.pick": "^4.4.0" + } + }, "dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -26750,15 +26782,14 @@ "dev": true }, "joi": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.4.0.tgz", - "integrity": "sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg==", - "dev": true, + "version": "17.8.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.8.3.tgz", + "integrity": "sha512-q5Fn6Tj/jR8PfrLrx4fpGH4v9qM6o+vDUfD4/3vxxyg34OmKcNqYZ1qn2mpLza96S8tL0p0rIw2gOZX+/cTg9w==", "requires": { "@hapi/hoek": "^9.0.0", "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.0", - "@sideway/formula": "^3.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, @@ -26966,14 +26997,12 @@ "lodash.omit": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", - "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", - "dev": true + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==" }, "lodash.pick": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", - "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==", - "dev": true + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==" }, "lodash.template": { "version": "4.5.0", @@ -30411,10 +30440,9 @@ "dev": true }, "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" }, "v8-compile-cache-lib": { "version": "3.0.1", diff --git a/package.json b/package.json index 4077adc..94fe636 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "postversion": "git push origin HEAD --tags --no-verify" }, "dependencies": { + "domain-objects": "0.10.3", + "joi": "17.8.3", "uuid": "9.0.0" }, "devDependencies": { @@ -70,8 +72,8 @@ "@typescript-eslint/parser": "5.46.1", "core-js": "3.26.1", "cz-conventional-changelog": "3.3.0", - "declapract": "^0.10.9", - "declapract-typescript-ehmpathy": "^0.20.14", + "declapract": "0.10.9", + "declapract-typescript-ehmpathy": "0.20.14", "depcheck": "1.4.3", "eslint": "8.30.0", "eslint-config-airbnb-typescript": "17.0.0", @@ -83,8 +85,7 @@ "prettier": "2.8.1", "ts-jest": "29.0.3", "ts-node": "10.9.1", - "typescript": "4.9.4", - "uuid": "^3.3.3" + "typescript": "4.9.4" }, "config": { "commitizen": { diff --git a/readme.md b/readme.md index f711d2c..154c9ef 100644 --- a/readme.md +++ b/readme.md @@ -21,26 +21,26 @@ In otherwords, it's built to provide [a pit of success](https://blog.codinghorro HMAC Key authentication is a great way to implement authentication and authorization for SDK applications -Using HMAC to sign requests increases the security of api-key authentication by ensuring that the `client-private-key` is not exposed through usage and that requests can't be replayed -- only the owner of the `client-private-key` can make requests against the server (blocks replay attacks) -- the `client-private-key` can not be leaked through usage, since it is never sent on the requests +HMAC Key authentication provides us the following guarantees on authenticated requests +- request integrity: the data sent by the client to the server has not tampered with +- request origination: the request comes to the server from a trusted client +- request originality: the request was not captured by an intruder and being replayed Well known examples of this pattern in production: - [AWS](http://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html) - [Twilio](https://www.twilio.com/docs/usage/security#validating-requests) - [Google](https://cloud.google.com/storage/docs/authentication/hmackeys) - References: - [HMAC: Keyed-Hashing for Message Authentication](https://www.rfc-editor.org/rfc/rfc2104) - [What is HMAC Authentication and why is it useful?](https://www.wolfe.id.au/2012/10/20/what-is-hmac-authentication-and-why-is-it-useful/) - [API Security: HMAC+Key vs JWT](https://softwareengineering.stackexchange.com/questions/297417/rest-api-security-hmac-key-hashing-vs-jwt) - [HMAC](https://en.wikipedia.org/wiki/HMAC) +- [How and when do I use HMAC?](https://security.stackexchange.com/questions/20129/how-and-when-do-i-use-hmac) Note: if you're looking to implement authentication and authorization for user facing applications, [JSON Web Tokens (JWTs)](https://github.com/whodisio/simple-jwt-auth) may be a better fit due to their [stateless and decentralized](https://softwareengineering.stackexchange.com/a/444092/146747) nature - --- # Install @@ -82,57 +82,41 @@ const { } = await issueClientKeyPair(); ``` -### Create an auth request signature +### Create a secure request signature Creates a request signature that can be securely authed by the issuer. Useful any time you need to make an authable request (e.g., client side) -```ts -import { createRequestSignature } from 'simple-hmackey-auth'; - -const signature = await createRequestSignature({ - clientPrivateKey, - request: { - host: 'https://your.domain.com', - endpoint: '/your/endpoint/...', - headers, - body, - }, -}); -``` - - -### Create authorization header - -Creates an [authorization header](https://tools.ietf.org/html/rfc6750) which encodes all the data required by the issuer to auth the request, which you can add to your requests. +Make sure to include any data that affects the outcome of the request in the request input to this function. The signature only ensures the integrity of the request data you told it about. ```ts -import { createAuthableAuthorizationHeader } from 'simple-hmackey-auth'; +import { createSecureRequestSignature } from 'simple-hmackey-auth'; -const { authorization } = await createAuthorizationHeader({ +const signature = await createSecureRequestSignature({ clientPublicKey, clientPrivateKey, request: { host: 'https://your.domain.com', endpoint: '/your/endpoint/...', headers, - body, + payload, }, }); ``` -To send authenticated requests, simply add that header to your requests. - +### Assert request signature is authentic -### Assert authentic request signature +Checks whether the signature was authentic via request origination and request integrity. Useful any time you need to make sure the request was authentic (e.g., server side) -Checks whether the signature was authentic, from this client and for this request. Useful any time you need to make sure the request was authentic (e.g., server side) +Make sure to include any data that affects the outcome of the request in the request input to this function. The signature only ensures the integrity of the request data you told it about. ```ts -import { assertAuthenticRequestSignature } from 'simple-hmackey-auth'; +import { assertRequestSignatureAuthenticity } from 'simple-hmackey-auth'; -await assertAuthenticRequestSignature({ +await assertRequestSignatureAuthenticity({ signature, getClientPrivateKeyHash: async ({ clientPublicKey }) => {/** a method you define to lookup the private key hash from your database using the public key */}, + setOriginalUsageOfNonce: async ({ nonce }) => {/** a method you define to record the first usage of the nonce and throw an error if it has already been used to stop replay attacks */} + millisUntilExpiration: 5 * 60 * 1000, // the number of milliseconds allowed to elapse from the time the request was sent before we reject it to stop replay attacks request: { host: 'https://your.domain.com', endpoint: '/your/endpoint/..., @@ -142,8 +126,6 @@ await assertAuthenticRequestSignature({ }); ``` -Note: throws an `UnauthenticRequestSignature` error if the request was not authentic and explains what was unauthentic about it in the error message. - ### Get signature from headers This grabs the signature from the standard [authorization header](https://tools.ietf.org/html/rfc6750) header for you. Useful whenever you need to grab a signature from an HTTP request. @@ -158,44 +140,62 @@ Request signatures are typically passed to apis through the `Authorization` head # Docs -### `fn:assertAuthenticRequestSignature({ signature: string, clientPublicKey: string, clientPrivateKeyHash: string | null, request: SignableRequest })` +### `fn:assertRequestSignatureAuthenticity({ signature: string, getClientPrivateKeyHash: ({ clientPublicKey }) => Promise, request: SignableRequest })` Use this function when you want to authenticate a request that was made to you. We check the authenticity of the request in the following ways: +- request integrity + - by verifying the signature against the request data, we prove that the data was not tampered with +- request origination + - by verifying the signature against the shared secret's hash, we prove that the owner of the client-private-key made the request + - by verifying the client-public-key identifies a client-secret-key-hash in your database, we prove that you issued the key used to sign the request and that you were the intended audience for the request +- request originality + - by verifying the nonce has not already been seen for a request, we prove that this is the original request and not a replay attack + - by verifying the millis-since-epoch of the request is recent enough, we add another mechanism of preventing replay attacks + +This method will throw errors in the following cases +- an `UnauthableRequestSignatureError` is thrown if the request signature does not have the data required to check for authenticity +- an `UnauthenticRequestSignatureError` is thrown if we have successfully checked the request signature and found that the request is unauthentic -- the signature is valid - - by verifying the signature - - check that we can verify the signature comes from the identified client, with the public key - - check that the request header and payload have not been tampered with, with the signature - - check that the token uses an asymmetric signing key, for secure decentralized authentication - - by verifying the timestamps - - check that the request was not possibly a replay or delay attack, for secure authentication - - by verifying the nonce - - check that the request was not a replay attack, for secure authentication -- the signing key is valid - - by getting a client-private-key-hash for the client-public-key - - checks implicitly that you did issue this keypair, since it was in your database, ensuring only keys you issued can be used to sign requests - - checks implicitly that the key is not expired, since it was not removed from the database ⚠️, ensuring only active keys can be used to sign requests Example: ```ts -import { assertAuthenticRequestSignature } from 'simple-hmackey-auth'; -const claims = assertAuthenticRequestSignature({ +import { assertRequestSignatureAuthenticity } from 'simple-hmackey-auth';a + +const claims = assertRequestSignatureAuthenticity({ /** * the request signature you're checking the request for authenticity against */ - signature, + signature: string; /** * a method you define which can lookup the client-private-key-hash using the client-public-key */ - getRequestSignatureFromHeaders, + getClientPrivateKeyHash: ({}: { clientPublicKey: string }) => Promise; + + /** + * a method you define which records that a nonce has been used and throws an error if this is not the first time + * + * note + * - if you choose to not define this method, your api will be vulnerable to replay attacks up to the millisUntilExpiration duration + * - if your function does not correctly assert that the nonce has not been used before, your api will be vulnerable to replay attacks up to the millisUntilExpiration duration + */ + setOriginalUsageOfNonce: ({}: { nonce: string }) => Promise; + + /** + * the number of milliseconds that could have passed since the timestamp on the request until we decide the request is expired + * + * note + * - the longer this duration is, the more opportunity an attacker has to replay a request + * - the default duration is 5 minutes + */ + millisUntilExpiration: number; /** * the request we will be checking against the signature to check it was not tampered with */ - request, + request: SimpleSignableRequest; }); ``` diff --git a/src/contract/index.ts b/src/contract/index.ts new file mode 100644 index 0000000..742c957 --- /dev/null +++ b/src/contract/index.ts @@ -0,0 +1,9 @@ +// methods +export { assertRequestSignatureAuthenticity } from '../logic/signatures/assertRequestSignatureAuthenticity'; +export { createSecureRequestSignature } from '../logic/signatures/createSecureRequestSignature'; +export { getRequestSignatureFromHeaders } from '../logic/headers/getRequestSignatureFromHeaders'; + +// errors +export { UnauthenticRequestSignatureError } from '../utils/errors/UnauthenticRequestSignatureError'; +export { UnauthableRequestSignatureError } from '../utils/errors/UnauthableRequestSignatureError'; +export { SimpleHmacKeyAuthError } from '../utils/errors/SimpleHmacKeyAuthError'; diff --git a/src/domain/SimpleSignableRequest.ts b/src/domain/SimpleSignableRequest.ts new file mode 100644 index 0000000..4d77079 --- /dev/null +++ b/src/domain/SimpleSignableRequest.ts @@ -0,0 +1,6 @@ +export interface SimpleSignableRequest { + host: string; + endpoint: string; + headers: Record; + payload: Record; +} diff --git a/src/domain/SimpleSignatureMetadata.ts b/src/domain/SimpleSignatureMetadata.ts new file mode 100644 index 0000000..ef4af30 --- /dev/null +++ b/src/domain/SimpleSignatureMetadata.ts @@ -0,0 +1,35 @@ +import { DomainObject } from 'domain-objects'; +import Joi from 'joi'; + +const schema = Joi.object().keys({ + clientPublicKey: Joi.string().required(), + millisSinceEpoch: Joi.number().required(), + nonce: Joi.string().required(), +}); + +/** + * the readable, public metadata included in the signature which is used to authenticate the signature digest + */ +export interface SimpleSignatureMetadata { + /** + * the client public key is used by the authorizer to lookup the clientPrivateKeyHash to authenticate the signature - and to identify the client after authentication + */ + clientPublicKey: string; + + /** + * the millisSinceEpoch is used by the authorizer to check that the request is not being replayed, another way of eliminating replay attack vulnerabilities + */ + millisSinceEpoch: number; + + /** + * the nonce is used by the authorizer to check that the request is not being replayed, eliminating replay attack vulnerabilities + */ + nonce: string; +} + +export class SimpleSignatureMetadata + extends DomainObject + implements SimpleSignatureMetadata +{ + public static schema = schema; +} diff --git a/src/index.ts b/src/index.ts index e69de29..c7b3221 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1 @@ +export * from './contract/index'; diff --git a/src/logic/hash/toHashSha256.ts b/src/logic/hash/toHashSha256.ts new file mode 100644 index 0000000..dc3269a --- /dev/null +++ b/src/logic/hash/toHashSha256.ts @@ -0,0 +1,10 @@ +import crypto from 'crypto'; + +/** + * a simple function which converts a string into an sha256 hash + * + * note + * - this can only be run on node + */ +export const toHashSha256 = async (message: string) => + crypto.createHash('sha256').update(message).digest('hex'); diff --git a/src/logic/headers/getRequestSignatureFromHeaders.test.ts b/src/logic/headers/getRequestSignatureFromHeaders.test.ts new file mode 100644 index 0000000..a346f91 --- /dev/null +++ b/src/logic/headers/getRequestSignatureFromHeaders.test.ts @@ -0,0 +1,61 @@ +import { uuid } from '../../deps'; +import { SimpleSignatureMetadata } from '../../domain/SimpleSignatureMetadata'; +import { toHashSha256 } from '../hash/toHashSha256'; +import { encodeRequestSignatureMetadata } from '../signatures/encodeRequestSignatureMetadata'; +import { getRequestSignatureFromHeaders } from './getRequestSignatureFromHeaders'; + +const getExampleSignature = async () => + [ + encodeRequestSignatureMetadata( + new SimpleSignatureMetadata({ + clientPublicKey: 'pub_test', + millisSinceEpoch: 821, + nonce: uuid(), + }), + ), + await toHashSha256('b'), + ].join('.'); + +describe('getRequestSignatureFromHeaders', () => { + it('should return null if there is no authorization header', () => { + const signature = getRequestSignatureFromHeaders({ + headers: {}, + }); + expect(signature).toEqual(null); + }); + it('should return null the authorization header contains something that does not look like a request signature', () => { + const signature = getRequestSignatureFromHeaders({ + headers: { + authorization: 'not_a_signature', + }, + }); + expect(signature).toEqual(null); + }); + it('should return the signature if it looks like a signature', async () => { + const exampleSignature = await getExampleSignature(); + const signature = getRequestSignatureFromHeaders({ + headers: { + authorization: exampleSignature, + }, + }); + expect(signature).toEqual(exampleSignature); + }); + it('should return the signature if it looks like a signature, even if its prefixed by something else', async () => { + const exampleSignature = await getExampleSignature(); + const signature = getRequestSignatureFromHeaders({ + headers: { + authorization: ['Bearer', exampleSignature].join(' '), + }, + }); + expect(signature).toEqual(exampleSignature); + }); + it('should return the signature if it looks like a signature, even if its prefixed by something else', async () => { + const exampleSignature = await getExampleSignature(); + const signature = getRequestSignatureFromHeaders({ + headers: { + authorization: ['HMAC', exampleSignature].join(' '), + }, + }); + expect(signature).toEqual(exampleSignature); + }); +}); diff --git a/src/logic/headers/getRequestSignatureFromHeaders.ts b/src/logic/headers/getRequestSignatureFromHeaders.ts new file mode 100644 index 0000000..1503cf1 --- /dev/null +++ b/src/logic/headers/getRequestSignatureFromHeaders.ts @@ -0,0 +1,14 @@ +import { isRequestSignature } from '../signatures/isRequestSignature'; + +export const getRequestSignatureFromHeaders = ({ + headers, +}: { + headers: Record; +}): string | null => { + // grab the authorization header field + const authorization = headers.authorization ?? headers.Authorization ?? null; // headers are case-insensitive, by spec: https://stackoverflow.com/a/5259004/3068233 + if (!authorization) return null; + const potentiallyARequestSignature = authorization.split(' ').slice(-1)[0]; // the last part of the header is probably the signature + if (!isRequestSignature(potentiallyARequestSignature)) return null; // check that it looks like a signature, since other strings can be passed here + return potentiallyARequestSignature; +}; diff --git a/src/logic/keys/issueClientKeyPair.test.ts b/src/logic/keys/issueClientKeyPair.test.ts new file mode 100644 index 0000000..5220f12 --- /dev/null +++ b/src/logic/keys/issueClientKeyPair.test.ts @@ -0,0 +1,48 @@ +import { toHashSha256 } from '../hash/toHashSha256'; +import { issueClientKeyPair } from './issueClientKeyPair'; + +describe('issueClientKeyPair', () => { + describe('client public key', () => { + it('should generate a public key that is distinguishable as public', async () => { + const { clientPublicKey } = await issueClientKeyPair(); + expect(clientPublicKey.startsWith('pub_')).toBe(true); + }); + it('should be unique each time', async () => { + const { clientPublicKey: clientPublicKeyA } = await issueClientKeyPair(); + const { clientPublicKey: clientPublicKeyB } = await issueClientKeyPair(); + const { clientPublicKey: clientPublicKeyC } = await issueClientKeyPair(); + expect(clientPublicKeyA).not.toEqual(clientPublicKeyB); + expect(clientPublicKeyB).not.toEqual(clientPublicKeyC); + }); + }); + describe('client private key', () => { + it('should generate a private key that is distinguishable as public', async () => { + const { clientPrivateKey } = await issueClientKeyPair(); + expect(clientPrivateKey.startsWith('pri_')).toBe(true); + }); + it('should be unique each time', async () => { + const { clientPrivateKey: clientPrivateKeyA } = + await issueClientKeyPair(); + const { clientPrivateKey: clientPrivateKeyB } = + await issueClientKeyPair(); + const { clientPrivateKey: clientPrivateKeyC } = + await issueClientKeyPair(); + expect(clientPrivateKeyA).not.toEqual(clientPrivateKeyB); + expect(clientPrivateKeyB).not.toEqual(clientPrivateKeyC); + }); + }); + describe('client private key hash', () => { + it('should generate a hash which does not expose the client private key', async () => { + const { clientPrivateKey, clientPrivateKeyHash } = + await issueClientKeyPair(); + expect(clientPrivateKeyHash).not.toEqual(clientPrivateKey); + }); + it('should generate a hash which we can compare against the private key for equality', async () => { + const { clientPrivateKey, clientPrivateKeyHash } = + await issueClientKeyPair(); + expect(clientPrivateKeyHash).toEqual( + await toHashSha256(clientPrivateKey), + ); + }); + }); +}); diff --git a/src/logic/keys/issueClientKeyPair.ts b/src/logic/keys/issueClientKeyPair.ts new file mode 100644 index 0000000..f19c96c --- /dev/null +++ b/src/logic/keys/issueClientKeyPair.ts @@ -0,0 +1,44 @@ +import { uuid } from '../../deps'; +import { toHashSha256 } from '../hash/toHashSha256'; + +/** + * creates a key-pair that can be given to the client to sign requests with + */ +export const issueClientKeyPair = async (): Promise<{ + /** + * the client-public-key identifies the keypair + * - should be sent to client + * - should be saved by issuer + */ + clientPublicKey: string; + + /** + * the client-private-key is the secret used by the client to sign requests + * - should be sent to client + * - should be irrecoverably forgotten by issuer + */ + clientPrivateKey: string; + + /** + * the client-private-key hash is a hash of the private-key that the issuer will use to auth requests + * - is not needed by the client + * - should be saved by the issuer, indexed by the client-public-key + */ + clientPrivateKeyHash: string; +}> => { + // define the public key, based on a hashed uuid + const clientPublicKey = ['pub', await toHashSha256(uuid())].join('_'); + + // define the private key, also based on a hashed uuid + const clientPrivateKey = ['pri', await toHashSha256(uuid())].join('_'); + + // and hash the private key, so the issuer can save it in their database + const clientPrivateKeyHash = await toHashSha256(clientPrivateKey); + + // and return the sets + return { + clientPublicKey, + clientPrivateKey, + clientPrivateKeyHash, + }; +}; diff --git a/src/logic/signatures/assertRequestSignatureAuthenticity.test.ts b/src/logic/signatures/assertRequestSignatureAuthenticity.test.ts new file mode 100644 index 0000000..d9491bf --- /dev/null +++ b/src/logic/signatures/assertRequestSignatureAuthenticity.test.ts @@ -0,0 +1,230 @@ +import { uuid } from '../../deps'; +import { SimpleSignableRequest } from '../../domain/SimpleSignableRequest'; +import { SimpleSignatureMetadata } from '../../domain/SimpleSignatureMetadata'; +import { UnauthableRequestSignatureError } from '../../utils/errors/UnauthableRequestSignatureError'; +import { UnauthenticRequestSignatureError } from '../../utils/errors/UnauthenticRequestSignatureError'; +import { toHashSha256 } from '../hash/toHashSha256'; +import { assertRequestSignatureAuthenticity } from './assertRequestSignatureAuthenticity'; +import { computeSecureRequestSignatureHmacDigest } from './computeSecureRequestSignatureHmacDigest'; +import { encodeRequestSignatureMetadata } from './encodeRequestSignatureMetadata'; + +const exampleRequest: SimpleSignableRequest = { + host: 'https://some.website.com', + endpoint: '/invoice/send', + headers: {}, + payload: { + invoiceUuid: uuid(), + }, +}; + +describe('assertRequestSignatureAuthenticity', () => { + it('should throw an error if the signature does not have exactly two parts to it', async () => { + try { + await assertRequestSignatureAuthenticity({ + signature: 'a.b.c.', + getClientPrivateKeyHash: async () => '__HASH__', + setOriginalUsageOfNonce: async () => {}, + request: exampleRequest, + }); + fail(); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error).toBeInstanceOf(UnauthableRequestSignatureError); + expect(error.message).toContain( + 'signature does not look like a request signature', + ); + } + }); + it('should throw an error if the signature digest is not 64 char', async () => { + try { + await assertRequestSignatureAuthenticity({ + signature: 'a.b', + getClientPrivateKeyHash: async () => '__HASH__', + setOriginalUsageOfNonce: async () => {}, + request: exampleRequest, + }); + fail(); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error).toBeInstanceOf(UnauthableRequestSignatureError); + expect(error.message).toContain( + 'signature does not look like a request signature', + ); + } + }); + it('should throw an error if the metadata can not be decoded from the signature', async () => { + try { + await assertRequestSignatureAuthenticity({ + signature: ['a', await toHashSha256('b')].join('.'), + getClientPrivateKeyHash: async () => '__HASH__', + setOriginalUsageOfNonce: async () => {}, + request: exampleRequest, + }); + fail(); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error).toBeInstanceOf(UnauthableRequestSignatureError); + expect(error.message).toContain( + 'signature does not look like a request signature', + ); + } + }); + it('should throw an error if the millis since epoch on the request is in the future', async () => { + try { + await assertRequestSignatureAuthenticity({ + signature: [ + encodeRequestSignatureMetadata( + new SimpleSignatureMetadata({ + clientPublicKey: 'pub_test', + millisSinceEpoch: new Date().getTime() + 100, + nonce: uuid(), + }), + ), + await toHashSha256('b'), + ].join('.'), + getClientPrivateKeyHash: async () => '__HASH__', + setOriginalUsageOfNonce: async () => {}, + request: exampleRequest, + }); + fail(); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error).toBeInstanceOf(UnauthableRequestSignatureError); + expect(error.message).toContain( + 'the request claims to have been made in the future', + ); + expect(error.message).toContain( + 'this request is unauthable as otherwise it would open a replay attack vulnerability', + ); + } + }); + it('should throw an error if the elapsed duration since originally sent exceeds the threshold', async () => { + try { + await assertRequestSignatureAuthenticity({ + signature: [ + encodeRequestSignatureMetadata( + new SimpleSignatureMetadata({ + clientPublicKey: 'pub_test', + millisSinceEpoch: new Date().getTime() - 1000, + nonce: uuid(), + }), + ), + await toHashSha256('b'), + ].join('.'), + getClientPrivateKeyHash: async () => '__HASH__', + setOriginalUsageOfNonce: async () => {}, + millisUntilExpiration: 900, + request: exampleRequest, + }); + fail(); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error).toBeInstanceOf(UnauthenticRequestSignatureError); + expect(error.message).toContain( + 'the time elapsed since this request was originally requested is greater than the expiration threshold', + ); + } + }); + it('should throw an error if the nonce had been used before', async () => { + try { + await assertRequestSignatureAuthenticity({ + signature: [ + encodeRequestSignatureMetadata( + new SimpleSignatureMetadata({ + clientPublicKey: 'pub_test', + millisSinceEpoch: new Date().getTime() - 1000, + nonce: uuid(), + }), + ), + await toHashSha256('b'), + ].join('.'), + getClientPrivateKeyHash: async () => '__HASH__', + setOriginalUsageOfNonce: async () => { + throw new Error('nonce was used'); // any error from this function will trigger nonce usage failure + }, + request: exampleRequest, + }); + fail(); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error).toBeInstanceOf(UnauthenticRequestSignatureError); + expect(error.message).toContain( + 'could not guarantee this is the original usage of this nonce', + ); + } + }); + it('should throw an error if the client private key hash could not be found', async () => { + try { + await assertRequestSignatureAuthenticity({ + signature: [ + encodeRequestSignatureMetadata( + new SimpleSignatureMetadata({ + clientPublicKey: 'pub_test', + millisSinceEpoch: new Date().getTime() - 1000, + nonce: uuid(), + }), + ), + await toHashSha256('b'), + ].join('.'), + getClientPrivateKeyHash: async () => { + throw new Error('could not find it'); + }, + setOriginalUsageOfNonce: async () => {}, + request: exampleRequest, + }); + fail(); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error).toBeInstanceOf(UnauthenticRequestSignatureError); + expect(error.message).toContain( + 'could not get client private key hash for this client public key', + ); + } + }); + it('should throw an error if the request signature digest does not match what is expected for the request', async () => { + try { + await assertRequestSignatureAuthenticity({ + signature: [ + encodeRequestSignatureMetadata( + new SimpleSignatureMetadata({ + clientPublicKey: 'pub_test', + millisSinceEpoch: new Date().getTime() - 1000, + nonce: uuid(), + }), + ), + await toHashSha256('b'), + ].join('.'), + getClientPrivateKeyHash: async () => '__HASH__', + setOriginalUsageOfNonce: async () => {}, + request: exampleRequest, + }); + fail(); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error).toBeInstanceOf(UnauthenticRequestSignatureError); + expect(error.message).toContain( + 'the request signature digest received is different than expected', + ); + } + }); + it('should not throw an error if the request signature is authentic for this request', async () => { + const metadata = new SimpleSignatureMetadata({ + clientPublicKey: 'pub_test', + millisSinceEpoch: new Date().getTime() - 1000, + nonce: uuid(), + }); + await assertRequestSignatureAuthenticity({ + signature: [ + encodeRequestSignatureMetadata(metadata), + await computeSecureRequestSignatureHmacDigest({ + ...metadata, + clientPrivateKeyHash: '__HASH__', + request: exampleRequest, + }), + ].join('.'), + getClientPrivateKeyHash: async () => '__HASH__', + setOriginalUsageOfNonce: async () => {}, + request: exampleRequest, + }); + }); +}); diff --git a/src/logic/signatures/assertRequestSignatureAuthenticity.ts b/src/logic/signatures/assertRequestSignatureAuthenticity.ts new file mode 100644 index 0000000..d5d2505 --- /dev/null +++ b/src/logic/signatures/assertRequestSignatureAuthenticity.ts @@ -0,0 +1,103 @@ +import { SimpleSignableRequest } from '../../domain/SimpleSignableRequest'; +import { UnauthableRequestSignatureError } from '../../utils/errors/UnauthableRequestSignatureError'; +import { UnauthenticRequestSignatureError } from '../../utils/errors/UnauthenticRequestSignatureError'; +import { computeSecureRequestSignatureHmacDigest } from './computeSecureRequestSignatureHmacDigest'; +import { decodeRequestSignatureMetadata } from './decodeRequestSignatureMetadata'; +import { isRequestSignature } from './isRequestSignature'; + +export const assertRequestSignatureAuthenticity = async ({ + signature, + getClientPrivateKeyHash, + setOriginalUsageOfNonce, + millisUntilExpiration = 5 * 60 * 1000, + request, +}: { + /** + * the request signature you're checking the request for authenticity against + */ + signature: string; + + /** + * a method you define which can lookup the client-private-key-hash using the client-public-key + */ + getClientPrivateKeyHash: ({}: { clientPublicKey: string }) => Promise; + + /** + * a method you define which records that a nonce has been used or throws an error if this is not the first time + * + * note + * - if you choose to not define this method, your api will be vulnerable to replay attacks up to the millisUntilExpiration duration + * - if your function does not correctly assert that the nonce has not been used before, your api will be vulnerable to replay attacks up to the millisUntilExpiration duration + */ + setOriginalUsageOfNonce: ({}: { nonce: string }) => Promise; + + /** + * the number of milliseconds that could have passed since the timestamp on the request until we decide the request is expired + * + * note + * - the longer this duration is, the more opportunity an attacker has to replay a request + * - the default duration is 5 minutes + */ + millisUntilExpiration?: number; + + /** + * the request we will be checking against the signature to check it was not tampered with + */ + request: SimpleSignableRequest; +}): Promise => { + // check that the signature looks like a request signature + if (!isRequestSignature(signature)) + throw new UnauthableRequestSignatureError( + 'signature does not look like a request signature', + ); + + // grab the metadata from the signature + const metadata = decodeRequestSignatureMetadata(signature); + + // check that the timestamp on the request is in the past. otherwise, the client sending these requests has an error and thinks it is in the future -> exposing a replay attack vulnerability + if (metadata.millisSinceEpoch > new Date().getTime()) + throw new UnauthableRequestSignatureError( + 'the request claims to have been made in the future. likely, the clock on the requestor is running fast. this request is unauthable as otherwise it would open a replay attack vulnerability', + ); + + // check that the request is not expired + const millisElapsedSinceOriginallyRequested = + new Date().getTime() - metadata.millisSinceEpoch; + if (millisElapsedSinceOriginallyRequested > millisUntilExpiration) + throw new UnauthenticRequestSignatureError( + 'the time elapsed since this request was originally requested is greater than the expiration threshold', + ); + + // check that the nonce has not been used before + if (setOriginalUsageOfNonce) + await setOriginalUsageOfNonce({ nonce: metadata.nonce }).catch(() => { + throw new UnauthenticRequestSignatureError( + 'could not guarantee this is the original usage of this nonce', + ); + }); + + // lookup the client private key hash from the client public key + const clientPrivateKeyHash = await getClientPrivateKeyHash({ + clientPublicKey: metadata.clientPublicKey, + }).catch(() => { + throw new UnauthenticRequestSignatureError( + 'could not get client private key hash for this client public key', + ); + }); + + // compute the hmac digest for the claimed request data + const digestExpected = await computeSecureRequestSignatureHmacDigest({ + clientPrivateKeyHash, + clientPublicKey: metadata.clientPublicKey, + millisSinceEpoch: metadata.millisSinceEpoch, + nonce: metadata.nonce, + request, + }); + const digestReceived = signature.split('.')[1]!; + if (digestExpected !== digestReceived) + throw new UnauthenticRequestSignatureError( + 'the request signature digest received is different than expected. if you think this is a mistake, please check that the inputs you are asserting the authenticity of match what the client had signed. for example, a common source of error comes from the headers being modified by intermediate tools by the time it reaches the server.', + ); + + // if the all of the above pass, the signature is authentic 👍 +}; diff --git a/src/logic/signatures/computeSecureRequestSignatureHmacDigest.test.ts b/src/logic/signatures/computeSecureRequestSignatureHmacDigest.test.ts new file mode 100644 index 0000000..a21f79e --- /dev/null +++ b/src/logic/signatures/computeSecureRequestSignatureHmacDigest.test.ts @@ -0,0 +1,22 @@ +import { uuid } from '../../deps'; +import { computeSecureRequestSignatureHmacDigest } from './computeSecureRequestSignatureHmacDigest'; + +describe('computeSecureRequestSignatureHmacDigest', () => { + it('should be able to compute a digest', async () => { + const digest = await computeSecureRequestSignatureHmacDigest({ + clientPrivateKeyHash: uuid(), + clientPublicKey: uuid(), + nonce: uuid(), + millisSinceEpoch: new Date().getTime(), + request: { + host: 'https://some.website.com', + endpoint: '/invoice/send', + headers: {}, + payload: { + invoiceUuid: uuid(), + }, + }, + }); + expect(digest.length).toEqual(64); + }); +}); diff --git a/src/logic/signatures/computeSecureRequestSignatureHmacDigest.ts b/src/logic/signatures/computeSecureRequestSignatureHmacDigest.ts new file mode 100644 index 0000000..c087332 --- /dev/null +++ b/src/logic/signatures/computeSecureRequestSignatureHmacDigest.ts @@ -0,0 +1,44 @@ +import { SimpleSignableRequest } from '../../domain/SimpleSignableRequest'; +import { toHashSha256 } from '../hash/toHashSha256'; + +/** + * computes the hmac digest intended for use in a secure request's signature + * + * def + * - > digest: The output of a hash function (e.g., hash(data) = digest). Also known as a message digest + * - https://csrc.nist.gov/glossary/term/hash_digest + * + * ref + * - https://stackoverflow.com/questions/3696857/whats-the-difference-between-message-digest-message-authentication-code-and-h + */ +export const computeSecureRequestSignatureHmacDigest = async ({ + clientPublicKey, + clientPrivateKeyHash, + nonce, + millisSinceEpoch, + request, +}: { + clientPublicKey: string; + clientPrivateKeyHash: string; + nonce: string; + millisSinceEpoch: number; + request: SimpleSignableRequest; +}) => + await toHashSha256( + JSON.stringify({ + // include the public key for extra precaution. there's no security vulnerability this prevents, but there's no reason not to + clientPublicKey, + + // include the private key, in order to make this an HMAC + clientPrivateKeyHash, + + // include the nonce, to ensure the integrity of the readable nonce which is used to prevent replay attacks + nonce, + + // include the milliseconds since epoch timestamp, to ensure the integrity of the readable milliseconds since epoch timestamp which is also used to prevent replay attacks + millisSinceEpoch, + + // and of course, include the request that we're signing for the integrity of + request, + }), + ); diff --git a/src/logic/signatures/createSecureRequestSignature.test.ts b/src/logic/signatures/createSecureRequestSignature.test.ts new file mode 100644 index 0000000..d93e266 --- /dev/null +++ b/src/logic/signatures/createSecureRequestSignature.test.ts @@ -0,0 +1,41 @@ +import { uuid } from '../../deps'; +import { SimpleSignatureMetadata } from '../../domain/SimpleSignatureMetadata'; +import { createSecureRequestSignature } from './createSecureRequestSignature'; +import { decodeRequestSignatureMetadata } from './decodeRequestSignatureMetadata'; + +describe('createRequestSignature', () => { + it('should be able to create a request signature', async () => { + const signature = await createSecureRequestSignature({ + clientPublicKey: ['pub', uuid()].join('_'), + clientPrivateKey: ['pri', uuid()].join('_'), + request: { + host: 'https://some.website.com', + endpoint: '/invoice/send', + headers: {}, + payload: { + invoiceUuid: uuid(), + }, + }, + }); + expect(signature.length).toBeGreaterThan(64); + }); + it('should be possible to extract metadata from the signature', async () => { + const signature = await createSecureRequestSignature({ + clientPublicKey: ['pub', uuid()].join('_'), + clientPrivateKey: ['pri', uuid()].join('_'), + request: { + host: 'https://some.website.com', + endpoint: '/invoice/send', + headers: {}, + payload: { + invoiceUuid: uuid(), + }, + }, + }); + const metadata = decodeRequestSignatureMetadata(signature); + expect(metadata).toHaveProperty('clientPublicKey'); + expect(metadata).toHaveProperty('millisSinceEpoch'); + expect(metadata).toHaveProperty('nonce'); + expect(metadata).toBeInstanceOf(SimpleSignatureMetadata); + }); +}); diff --git a/src/logic/signatures/createSecureRequestSignature.ts b/src/logic/signatures/createSecureRequestSignature.ts new file mode 100644 index 0000000..6123625 --- /dev/null +++ b/src/logic/signatures/createSecureRequestSignature.ts @@ -0,0 +1,58 @@ +import { SimpleHmacKeyAuthError } from '../../contract'; +import { uuid } from '../../deps'; +import { SimpleSignableRequest } from '../../domain/SimpleSignableRequest'; +import { SimpleSignatureMetadata } from '../../domain/SimpleSignatureMetadata'; +import { toHashSha256 } from '../hash/toHashSha256'; +import { computeSecureRequestSignatureHmacDigest } from './computeSecureRequestSignatureHmacDigest'; +import { encodeRequestSignatureMetadata } from './encodeRequestSignatureMetadata'; + +/** + * creates a request signature that can be securely authenticated by the issuer + */ +export const createSecureRequestSignature = async ({ + clientPublicKey, + clientPrivateKey, + request, +}: { + clientPublicKey: string; + clientPrivateKey: string; + request: SimpleSignableRequest; +}) => { + // sanity check that the shape of the public key is correct + if (!clientPublicKey.startsWith('pub_')) + throw new SimpleHmacKeyAuthError('client public key must start with pub_'); + + // sanity check that the shape of the private key is correct + if (!clientPrivateKey.startsWith('pri_')) + throw new SimpleHmacKeyAuthError('client private key must start with pri_'); + + // define a timestamp for when this request was made, which can be used by server to prevent replay and delay attacks + const millisSinceEpoch = new Date().getTime(); + + // define a nonce for this request, which can be used by server to prevent replay attacks + const nonce = uuid(); + + // compute the hmac digest for this request's signature + const digest = await computeSecureRequestSignatureHmacDigest({ + clientPrivateKeyHash: await toHashSha256(clientPrivateKey), + clientPublicKey, + millisSinceEpoch, + nonce, + request, + }); + + // define the readable metadata to be included in the signature + const metadata = encodeRequestSignatureMetadata( + new SimpleSignatureMetadata({ + clientPublicKey, + millisSinceEpoch, + nonce, + }), + ); + + // create the signature from the digest and readable keys + const signature = [metadata, digest].join('.'); + + // return the signature + return signature; +}; diff --git a/src/logic/signatures/decodeRequestSignatureMetadata.ts b/src/logic/signatures/decodeRequestSignatureMetadata.ts new file mode 100644 index 0000000..97346a7 --- /dev/null +++ b/src/logic/signatures/decodeRequestSignatureMetadata.ts @@ -0,0 +1,19 @@ +import { SimpleSignatureMetadata } from '../../domain/SimpleSignatureMetadata'; +import { UnauthableRequestSignatureError } from '../../utils/errors/UnauthableRequestSignatureError'; + +export const decodeRequestSignatureMetadata = ( + signature: string, +): SimpleSignatureMetadata => { + try { + return new SimpleSignatureMetadata( + JSON.parse( + Buffer.from(signature.split('.')[0]!, 'base64url').toString('ascii'), + ), + ); + } catch (error) { + if (!(error instanceof Error)) throw error; + throw new UnauthableRequestSignatureError( + `could not decode request signature metadata: ${error.message}`, + ); + } +}; diff --git a/src/logic/signatures/encodeRequestSignatureMetadata.ts b/src/logic/signatures/encodeRequestSignatureMetadata.ts new file mode 100644 index 0000000..711e323 --- /dev/null +++ b/src/logic/signatures/encodeRequestSignatureMetadata.ts @@ -0,0 +1,5 @@ +import { SimpleSignatureMetadata } from '../../domain/SimpleSignatureMetadata'; + +export const encodeRequestSignatureMetadata = ( + metadata: SimpleSignatureMetadata, +) => Buffer.from(JSON.stringify(metadata)).toString('base64url'); diff --git a/src/logic/signatures/isRequestSignature.test.ts b/src/logic/signatures/isRequestSignature.test.ts new file mode 100644 index 0000000..93ce9bf --- /dev/null +++ b/src/logic/signatures/isRequestSignature.test.ts @@ -0,0 +1,37 @@ +import { uuid } from '../../deps'; +import { SimpleSignatureMetadata } from '../../domain/SimpleSignatureMetadata'; +import { toHashSha256 } from '../hash/toHashSha256'; +import { encodeRequestSignatureMetadata } from './encodeRequestSignatureMetadata'; +import { isRequestSignature } from './isRequestSignature'; + +describe('isRequestSignature', () => { + it('should return false if there are not exactly two parts', () => { + const decision = isRequestSignature('a.b.c'); + expect(decision).toEqual(false); + }); + it('should return false if the digest is not 64 char long', () => { + const decision = isRequestSignature('a.b'); + expect(decision).toEqual(false); + }); + it('should return false if the metadata can not be decoded', async () => { + const decision = isRequestSignature( + ['a', await toHashSha256('b')].join('.'), + ); + expect(decision).toEqual(false); + }); + it('should return true for a real request signature', async () => { + const decision = isRequestSignature( + [ + encodeRequestSignatureMetadata( + new SimpleSignatureMetadata({ + clientPublicKey: 'pub_test', + millisSinceEpoch: 821, + nonce: uuid(), + }), + ), + await toHashSha256('b'), + ].join('.'), + ); + expect(decision).toEqual(true); + }); +}); diff --git a/src/logic/signatures/isRequestSignature.ts b/src/logic/signatures/isRequestSignature.ts new file mode 100644 index 0000000..b103ccc --- /dev/null +++ b/src/logic/signatures/isRequestSignature.ts @@ -0,0 +1,19 @@ +import { decodeRequestSignatureMetadata } from './decodeRequestSignatureMetadata'; + +export const isRequestSignature = (signature: string) => { + // if it doesn't have two parts, its not a request signature + if (signature.split('.').length !== 2) return false; + + // if the digest is not exactly 64 characters long, its not a request signature (sha256 is 64 char) + if (signature.split('.')[1]?.length !== 64) return false; + + // if we could not decode request signature metadata, its not a request signature + try { + decodeRequestSignatureMetadata(signature); + } catch { + return false; + } + + // if we can do all of the above, then its a request signature + return true; +}; diff --git a/src/utils/errors/UnauthableRequestSignatureError.ts b/src/utils/errors/UnauthableRequestSignatureError.ts new file mode 100644 index 0000000..8c7b78c --- /dev/null +++ b/src/utils/errors/UnauthableRequestSignatureError.ts @@ -0,0 +1,14 @@ +import { SimpleHmacKeyAuthError } from './SimpleHmacKeyAuthError'; + +/** + * thrown when we cant check the authenticity of a request signature + * - unauthable = we don't have the data required to check for authenticity + */ +export class UnauthableRequestSignatureError extends SimpleHmacKeyAuthError { + constructor(reason: string) { + const message = ` +Unauthable request signature detected! ${reason} + `.trim(); + super(message); + } +} diff --git a/src/utils/errors/UnauthenticRequestSignatureError.ts b/src/utils/errors/UnauthenticRequestSignatureError.ts new file mode 100644 index 0000000..c6f7e29 --- /dev/null +++ b/src/utils/errors/UnauthenticRequestSignatureError.ts @@ -0,0 +1,15 @@ +import { SimpleHmacKeyAuthError } from './SimpleHmacKeyAuthError'; + +/** + * thrown when an authable request signature is found to be unauthentic + * - authable = we have all of the data needed to check for authenticity + * - unauthentic = we know this request should not be trusted + */ +export class UnauthenticRequestSignatureError extends SimpleHmacKeyAuthError { + constructor(reason: string) { + const message = ` +Unauthentic request signature detected! ${reason} + `.trim(); + super(message); + } +}