From 7ac765842c00317bee328442d06782b1e2019975 Mon Sep 17 00:00:00 2001 From: Louis St-Amour Date: Wed, 1 Jul 2020 03:29:49 -0400 Subject: [PATCH 1/4] Added JWT builtins. Signed-off-by: Louis St-Amour --- package-lock.json | 13 +++ package.json | 3 + src/builtins/index.js | 2 + src/builtins/jwt.js | 208 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 src/builtins/jwt.js diff --git a/package-lock.json b/package-lock.json index 41ce5b3..2b507af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -606,6 +606,11 @@ "chalk": "^4.0.0" } }, + "@panva/asn1.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", + "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" + }, "@sinonjs/commons": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.7.2.tgz", @@ -2762,6 +2767,14 @@ "supports-color": "^7.0.0" } }, + "jose": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-1.27.1.tgz", + "integrity": "sha512-VyHM6IJPw0TTGqHVNlPWg16/ASDPAmcChcLqSb3WNBvwWFoWPeFqlmAUCm8/oIG1GjZwAlUDuRKFfycowarcVA==", + "requires": { + "@panva/asn1.js": "^1.0.0" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 0c2cb70..e4a1c67 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ "devDependencies": { "jest": "^26.0.1", "typescript": "^3.9.2" + }, + "dependencies": { + "jose": "^1.27.1" } } diff --git a/src/builtins/index.js b/src/builtins/index.js index 23c51c1..4341297 100644 --- a/src/builtins/index.js +++ b/src/builtins/index.js @@ -1,6 +1,7 @@ const numbers = require("./numbers"); const aggregates = require("./aggregates"); const arrays = require("./arrays"); +const jwt = require("./jwt"); const strings = require("./strings"); const regex = require("./regex"); const types = require("./types"); @@ -10,6 +11,7 @@ module.exports = { ...numbers, ...aggregates, ...arrays, + ...jwt, ...strings, ...regex, ...types, diff --git a/src/builtins/jwt.js b/src/builtins/jwt.js new file mode 100644 index 0000000..0ecc2a0 --- /dev/null +++ b/src/builtins/jwt.js @@ -0,0 +1,208 @@ +var jose = require('jose'); + +var tokenConstraintTypes = { + "cert": tokenConstraintCert, + "secret": (constraints) => tokenConstraintString("secret", constraints), + "alg": (constraints) => tokenConstraintString("alg", constraints), + "iss": (constraints) => tokenConstraintString("iss", constraints), + "aud": (constraints) => tokenConstraintString("aud", constraints), + "time": tokenConstraintTime +}; + +// getKeyFromCertOrJWK returns the public key found in a X.509 certificate or JWK key(s). +// A valid PEM block is never valid JSON (and vice versa), hence can try parsing both. +function getKeyFromCertOrJWK(certificate) { + // Node docs: if the format is 'pem', + // the 'key' may also be an X.509 certificate. + // should throw if it encounters errors... + var key = JWK.asKey({ + key: certificate, + format: 'pem' + }); + // just in case... + if(key.type !== "public") { + throw "failed to extract a public Key from the PEM certificate"; + } + // upstream go requires "materialize" + return key; +} + +// tokenConstraintCert handles the `cert` constraint. +function tokenConstraintCert(constraints) { + constraints = tokenConstraintString("cert", constraints) + constraints.keys = getKeyFromCertOrJWK(constraints["cert"]) + return constraints; +} + +// tokenConstraintTime handles the `time` constraint. +function tokenConstraintTime(constraints) { + if(typeof constraints["time"] !== "number" || isNaN(constraints["time"])) { + throw "time constraint: must be a number"; + } + if(constraints["time"] < 0) { + throw "token time constraint: must not be negative"; + } + return constraints; +} + +// tokenConstraintString handles string constraints. +function tokenConstraintString(name, constraints) { + if(typeof constraints[name] !== "string") { + throw name + " constraint: must be a string"; + } + return constraints; +} + +// parseTokenConstraints parses the constraints argument. +function parseTokenConstraints(constraints) { + if(constraints.constructor.name !== "Object") { + throw("token constraints must be object"); + } + for(var name in constraints) { + if(tokenConstraintTypes.hasOwnProperty(name)) { + var handler = tokenConstraintTypes[name]; + constraints = handler(constraints); + } else { + throw "unknown token validation constraint: " + name; + } + } + return constraints; +} + +// Implements JWT decoding/validation based on RFC 7519 Section 7.2: +// https://tools.ietf.org/html/rfc7519#section-7.2 +// It does no data validation, it merely checks that the given string +// represents a structurally valid JWT. It supports JWTs using JWS compact +// serialization. +function builtinJWTDecode(jwt) { + var t = jose.JWT.decode(token, { complete: true }); + var hexSig = new Buffer(t.signature, 'base64').toString('hex'); + return [ t.header, t.payload, hexSig ]; +} + +function _verify(string, certificate, algorithms) { + try { + JWT.verify(string, certificate, { algorithms: algorithms }); + return true; + } catch(e) { + return false; + } +} + +// validate validates the constraints argument. +function validateConstraints(constraints) { + var keys = 0; + if(constraints.keys !== undefined) { + keys++; + } + if(constraints.secret !== "") { + keys++; + } + if(keys > 1) { + throw "duplicate key constraints"; + } + if(keys < 1) { + throw "no key constraint"; + } +} + +// Implements full JWT decoding, validation and verification. +function builtinJWTDecodeVerify(string, constraints) { + // io.jwt.decode_verify(string, constraints, [valid, header, payload]) + // + // If valid is true then the signature verifies and all constraints are met. + // If valid is false then either the signature did not verify or some constrain + // was not met. + // + // Decoding errors etc are returned as errors. + constraints = parseTokenConstraints(constraints); + validateConstraints(constraints); + + var options = { complete: true }; + if(constraints.alg) { + options.algorithms = [constraints.alg]; + } + if(constraints.iss) { + options.issuer = constraints.iss; + } + if(constraints.time) { + options.now = new Date(time/1e6) // constraints.time is in nanoseconds + } + if(constraints.aud) { + options.audience = constraints.aud; // aud can be array or string + } + + try { + var t = jose.JWT.verify(string, constraints.cert || constraints.secret, options); + var hexSig = new Buffer(t.signature, 'base64').toString('hex'); + + // if constraints.aud is absent then the aud claim must be absent too. + if(!constraints.aud && t.payload.aud) { + return [false, {}, {}]; + } + return [true, t.header, t.payload]; + } catch(e) { + return [false, {}, {}]; + } +}; + +// io.jwt.encode_sign_raw() takes three JSON Objects (strings) +// as parameters and returns their JWS Compact Serialization. +// This builtin should be used by those that want maximum control +// over the signing and serialization procedure. It is important to +// remember that StringOrURI values are compared as case-sensitive +// strings with no transformations or canonicalizations applied. +// Therefore, line breaks and whitespaces are significant. + +// headers, payload and key are JSON objects that represent +// the JWS Protected Header, JWS Payload and JSON Web Key (RFC7517) +// respectively. +function builtinJWTEncodeSignRaw(headers, payload, key) { + var header = JSON.parse(headers); + var key = JWK.asKey(JSON.parse(key)); + return jose.JWS.sign(payload, key, { + ...header, + alg: header.alg, + kid: key.kid || header.kid + }); +} + +// io.jwt.encode_sign() takes three Rego Objects as parameters and +// returns their JWS Compact Serialization. This builtin should be +// used by those that want to use rego objects for signing during +// policy evaluation. + +// Note that with io.jwt.encode_sign the Rego objects are serialized +// to JSON with standard formatting applied whereas the +// io.jwt.encode_sign_raw built-in will not affect whitespace of +// the strings passed in. This will mean that the final encoded token +// may have different string values, but the decoded and parsed JSON +// will match. + +// headers, payload and key are JSON objects that represent +// the JWS Protected Header, JWS Payload and JSON Web Key (RFC7517) +// respectively. +function builtinJWTEncodeSign(headers, payload, key) { + return jose.JWT.sign(payload, key, { + header: headers + }); +} + +module.exports = { + "io.jwt.verify_rs256": (string, certificate) => _verify(string, certificate, ['RS256']), + "io.jwt.verify_rs384": (string, certificate) => _verify(string, certificate, ['RS384']), + "io.jwt.verify_rs512": (string, certificate) => _verify(string, certificate, ['RS512']), + "io.jwt.verify_ps256": (string, certificate) => _verify(string, certificate, ['PS256']), + "io.jwt.verify_ps384": (string, certificate) => _verify(string, certificate, ['PS384']), + "io.jwt.verify_ps512": (string, certificate) => _verify(string, certificate, ['PS512']), + "io.jwt.verify_es256": (string, certificate) => _verify(string, certificate, ['ES256']), + "io.jwt.verify_es384": (string, certificate) => _verify(string, certificate, ['ES384']), + "io.jwt.verify_es512": (string, certificate) => _verify(string, certificate, ['ES512']), + "io.jwt.verify_hs256": (string, certificate) => _verify(string, certificate, ['HS256']), + "io.jwt.verify_hs384": (string, certificate) => _verify(string, certificate, ['HS384']), + "io.jwt.verify_hs512": (string, certificate) => _verify(string, certificate, ['HS512']), + "io.jwt.decode": builtinJWTDecode, + "io.jwt.decode_verify": builtinJWTDecodeVerify, + "io.jwt.encode_sign_raw": builtinJWTEncodeSignRaw, + "io.jwt.encode_sign": builtinJWTEncodeSign, +}; From bdc5334cda6b04db6a39a26b5a1009d771cab99b Mon Sep 17 00:00:00 2001 From: Louis St-Amour Date: Wed, 1 Jul 2020 03:51:17 -0400 Subject: [PATCH 2/4] Bugfix for substring builtin. Signed-off-by: Louis St-Amour --- src/builtins/strings.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/builtins/strings.js b/src/builtins/strings.js index 94b3233..cdb8eae 100644 --- a/src/builtins/strings.js +++ b/src/builtins/strings.js @@ -6,7 +6,20 @@ replace = (s, searchValue, newValue) => s.replace(searchValue, newValue); split = (s, delimiter) => s.split(delimiter); sprintf = (s, values) => s.sprintf(values); startswith = (s, search) => s.startsWith(search); -substring = (s, start, length) => s.substr(start, length); +// substring: output is the portion of string from index start and having +// a length of length. If length is less than zero, length is +// the remainder of the string. If start is greater than the +// length of the string, output is empty. It is invalid to pass +// a negative offset to this function. +substring = (s, start, length) => { + if(start < 0) { + throw "negative offset"; + } + if(length < 0) { + length = undefined; + } + return s.substr(start, length); +} concat = (delimiter, arr) => arr.join(delimiter); module.exports = { From 00302a92c03adaa2240232e659fcc7a17514a192 Mon Sep 17 00:00:00 2001 From: Louis St-Amour Date: Wed, 1 Jul 2020 04:36:44 -0400 Subject: [PATCH 3/4] Added basic implementations of object.union and object.remove - not pretty or perfect. Signed-off-by: Louis St-Amour --- src/builtins/index.js | 2 ++ src/builtins/object.js | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/builtins/object.js diff --git a/src/builtins/index.js b/src/builtins/index.js index 4341297..9aac617 100644 --- a/src/builtins/index.js +++ b/src/builtins/index.js @@ -2,6 +2,7 @@ const numbers = require("./numbers"); const aggregates = require("./aggregates"); const arrays = require("./arrays"); const jwt = require("./jwt"); +const object = require("./object"); const strings = require("./strings"); const regex = require("./regex"); const types = require("./types"); @@ -12,6 +13,7 @@ module.exports = { ...aggregates, ...arrays, ...jwt, + ...object, ...strings, ...regex, ...types, diff --git a/src/builtins/object.js b/src/builtins/object.js new file mode 100644 index 0000000..45a47f4 --- /dev/null +++ b/src/builtins/object.js @@ -0,0 +1,18 @@ +union = function() { + var obj = {}; + for(var i = 0; i < arguments.length; i++) { + obj = Object.assign(obj, arguments[i]); + } + return obj; +} + +remove = function(obj, key) { + var newObj = Object.assign({}, obj); + delete newObj[key]; + return newObj; +} + +module.exports = { + "object.union": union, + "object.remove": remove, + }; From 16ffdd64f09d2db7472c324de108fee3d9344fb3 Mon Sep 17 00:00:00 2001 From: Louis St-Amour Date: Wed, 1 Jul 2020 16:26:05 -0400 Subject: [PATCH 4/4] Started adding test cases, but wow, it's a lot of work to port these from Golang by hand. Signed-off-by: Louis St-Amour --- src/builtins/aggregates.test.js | 0 src/builtins/arrays.test.js | 0 src/builtins/conversions.test.js | 0 src/builtins/jwt.test.js | 0 src/builtins/numbers.test.js | 0 src/builtins/object.js | 24 ++++- src/builtins/object.test.js | 157 +++++++++++++++++++++++++++++++ src/builtins/regex.test.js | 0 src/builtins/strings.test.js | 0 src/builtins/types.test.js | 0 test/numbers.test.js | 14 --- 11 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 src/builtins/aggregates.test.js create mode 100644 src/builtins/arrays.test.js create mode 100644 src/builtins/conversions.test.js create mode 100644 src/builtins/jwt.test.js create mode 100644 src/builtins/numbers.test.js create mode 100644 src/builtins/object.test.js create mode 100644 src/builtins/regex.test.js create mode 100644 src/builtins/strings.test.js create mode 100644 src/builtins/types.test.js delete mode 100644 test/numbers.test.js diff --git a/src/builtins/aggregates.test.js b/src/builtins/aggregates.test.js new file mode 100644 index 0000000..e69de29 diff --git a/src/builtins/arrays.test.js b/src/builtins/arrays.test.js new file mode 100644 index 0000000..e69de29 diff --git a/src/builtins/conversions.test.js b/src/builtins/conversions.test.js new file mode 100644 index 0000000..e69de29 diff --git a/src/builtins/jwt.test.js b/src/builtins/jwt.test.js new file mode 100644 index 0000000..e69de29 diff --git a/src/builtins/numbers.test.js b/src/builtins/numbers.test.js new file mode 100644 index 0000000..e69de29 diff --git a/src/builtins/object.js b/src/builtins/object.js index 45a47f4..81f4b4f 100644 --- a/src/builtins/object.js +++ b/src/builtins/object.js @@ -6,9 +6,27 @@ union = function() { return obj; } -remove = function(obj, key) { - var newObj = Object.assign({}, obj); - delete newObj[key]; +remove = function(object, keys) { + if(!(typeof object === "object" && object && object.constructor === Object)) { + if(object instanceof Set) { + throw 'object.remove: invalid argument(s)'; + } + throw `object.remove: operand 1 must be object but got ${object === null || object === undefined ? 'var' : typeof object === 'object' ? object.constructor.name.toLowerCase() : typeof object}`; + } + var newObj = Object.assign({}, object); + if(keys instanceof Set || keys instanceof Array) { + for(var k of keys) { + delete newObj[k]; + } + } else if(typeof keys === "object" && keys && keys.constructor === Object) { + for(var k in keys) { + if(keys.hasOwnProperty(k)) { + delete newObj[k]; + } + } + } else { + throw `object.remove: operand 2 must be one of {object, string, array} but got ${typeof keys === 'object' ? 'var' : typeof keys}`; + } return newObj; } diff --git a/src/builtins/object.test.js b/src/builtins/object.test.js new file mode 100644 index 0000000..805b6aa --- /dev/null +++ b/src/builtins/object.test.js @@ -0,0 +1,157 @@ +const object = require('./object'); + +const testCases = { "object.remove": [ + { + note: "base", + object: {"a": 1, "b": {"c": 3}}, + keys: new Set(["a"]), + expected: {"b": {"c": 3}}, + }, + { + note: "multiple keys set", + object: {"a": 1, "b": {"c": 3}, "d": 4}, + keys: new Set(["d", "b"]), + expected: {"a": 1}, + }, + { + note: "multiple keys array", + object: {"a": 1, "b": {"c": 3}, "d": 4}, + keys: ["d", "b"], + expected: {"a": 1}, + }, + { + note: "multiple keys object", + object: {"a": 1, "b": {"c": 3}, "d": 4}, + keys: {"d": "", "b": 1}, + expected: {"a": 1}, + }, + { + note: "multiple keys object nested", + object: {"a": {"b": {"c": 2}}, "x": 123}, + keys: {"a": {"b": {"foo": "bar"}}}, + expected: {"x": 123}, + }, + { + note: "empty object", + object: {}, + keys: new Set(["a", "b"]), + expected: {}, + }, + { + note: "empty keys set", + object: {"a": 1, "b": {"c": 3}}, + keys: new Set(), + expected: {"a": 1, "b": {"c": 3}}, + }, + { + note: "empty keys array", + object: {"a": 1, "b": {"c": 3}}, + keys: [], + expected: {"a": 1, "b": {"c": 3}}, + }, + { + note: "empty keys obj", + object: {"a": 1, "b": {"c": 3}}, + keys: {}, + expected: {"a": 1, "b": {"c": 3}}, + }, + { + note: "key doesnt exist", + object: {"a": 1, "b": {"c": 3}}, + keys: new Set(["z"]), + expected: {"a": 1, "b": {"c": 3}}, + }, + { + note: "error invalid object param type set", + object: new Set(["a"]), + keys: new Set(["a"]), + expected: new Error("object.remove: invalid argument(s)"), + }, + // { + // note: "error invalid object param type bool", + // object: false, + // keys: new Set("a"), + // expected: new Error("object.remove: invalid argument(s)"), + // }, + { + note: "error invalid object param type array input", + object: ["a"], + keys: new Set(["a"]), + expected: new Error("object.remove: operand 1 must be object but got array"), + }, + { + note: "error invalid object param type bool input", + object: false, + keys: new Set(["a"]), + expected: new Error("object.remove: operand 1 must be object but got boolean"), + }, + { + note: "error invalid object param type number input", + object: 123, + keys: new Set(["a"]), + expected: new Error("object.remove: operand 1 must be object but got number"), + }, + { + note: "error invalid object param type string input", + object: "foo", + keys: new Set(["a"]), + expected: new Error("object.remove: operand 1 must be object but got string"), + }, + { + note: "error invalid object param type nil input", + object: null, + keys: new Set(["a"]), + expected: new Error("object.remove: operand 1 must be object but got var"), + }, + // { + // note: "error invalid key param type string", + // object: {"a": 1}, + // keys: "a", + // expected: new Error("object.remove: invalid argument(s)"), + // }, + // { + // note: "error invalid key param type boolean", + // object: {"a": 1}, + // keys: false, + // expected: new Error("object.remove: invalid argument(s)"), + // }, + { + note: "error invalid key param type string input", + object: {"a": 1}, + keys: "foo", + expected: new Error("object.remove: operand 2 must be one of {object, string, array} but got string"), + }, + { + note: "error invalid key param type boolean input", + object: {"a": 1}, + keys: true, + expected: new Error("object.remove: operand 2 must be one of {object, string, array} but got boolean"), + }, + { + note: "error invalid key param type number input", + object: {"a": 1}, + keys: 22, + expected: new Error("object.remove: operand 2 must be one of {object, string, array} but got number"), + }, + { + note: "error invalid key param type nil input", + object: {"a": 1}, + keys: null, + expected: new Error("object.remove: operand 2 must be one of {object, string, array} but got var"), + }, +]}; + +for(const [subject, cases] of Object.entries(testCases)) { + describe(`object.${subject}`, () => { + cases.forEach(c => { + test(c.note, () => { + const test = () => object[subject](c.object, c.keys); + if(c.expected instanceof Error) { + expect(test).toThrow(c.expected); + } else { + expect(test()).toEqual(c.expected); + } + }); + }); + }); +} \ No newline at end of file diff --git a/src/builtins/regex.test.js b/src/builtins/regex.test.js new file mode 100644 index 0000000..e69de29 diff --git a/src/builtins/strings.test.js b/src/builtins/strings.test.js new file mode 100644 index 0000000..e69de29 diff --git a/src/builtins/types.test.js b/src/builtins/types.test.js new file mode 100644 index 0000000..e69de29 diff --git a/test/numbers.test.js b/test/numbers.test.js deleted file mode 100644 index 2fcc4b1..0000000 --- a/test/numbers.test.js +++ /dev/null @@ -1,14 +0,0 @@ -const { plus, minus } = require("../src/builtins"); - -describe("numbers", () => { - describe("plus", () => { - it("should add two numbers", () => { - expect(plus(1, 2)).toEqual(3); - }); - }); - describe("minus", () => { - it("should minus two numbers", () => { - expect(minus(2, 1)).toEqual(1); - }); - }); -});