From fd60edf475fad69e32fb13a76e56d99a79604bb6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 21 Jul 2022 09:48:16 -0300 Subject: contractTerms was missing, looks like in commit f11483b5 a move was intended --- packages/taler-util/src/contractTerms.test.ts | 122 +++++++++++++ packages/taler-util/src/contractTerms.ts | 236 ++++++++++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 packages/taler-util/src/contractTerms.test.ts create mode 100644 packages/taler-util/src/contractTerms.ts (limited to 'packages') diff --git a/packages/taler-util/src/contractTerms.test.ts b/packages/taler-util/src/contractTerms.test.ts new file mode 100644 index 000000000..74cae4ca7 --- /dev/null +++ b/packages/taler-util/src/contractTerms.test.ts @@ -0,0 +1,122 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Imports. + */ +import test from "ava"; +import { ContractTermsUtil } from "./contractTerms.js"; + +test("contract terms canon hashing", (t) => { + const cReq = { + foo: 42, + bar: "hello", + $forgettable: { + foo: true, + }, + }; + + const c1 = ContractTermsUtil.saltForgettable(cReq); + const c2 = ContractTermsUtil.saltForgettable(cReq); + t.assert(typeof cReq.$forgettable.foo === "boolean"); + t.assert(typeof c1.$forgettable.foo === "string"); + t.assert(c1.$forgettable.foo !== c2.$forgettable.foo); + + const h1 = ContractTermsUtil.hashContractTerms(c1); + + const c3 = ContractTermsUtil.scrub(JSON.parse(JSON.stringify(c1))); + + t.assert(c3.foo === undefined); + t.assert(c3.bar === cReq.bar); + + const h2 = ContractTermsUtil.hashContractTerms(c3); + + t.deepEqual(h1, h2); +}); + +test("contract terms canon hashing (nested)", (t) => { + const cReq = { + foo: 42, + bar: { + prop1: "hello, world", + $forgettable: { + prop1: true, + }, + }, + $forgettable: { + bar: true, + }, + }; + + const c1 = ContractTermsUtil.saltForgettable(cReq); + + t.is(typeof c1.$forgettable.bar, "string"); + t.is(typeof c1.bar.$forgettable.prop1, "string"); + + const forgetPath = (x: any, s: string) => + ContractTermsUtil.forgetAll(x, (p) => p.join(".") === s); + + // Forget bar first + const c2 = forgetPath(c1, "bar"); + + // Forget bar.prop1 first + const c3 = forgetPath(forgetPath(c1, "bar.prop1"), "bar"); + + // Forget everything + const c4 = ContractTermsUtil.scrub(c1); + + const h1 = ContractTermsUtil.hashContractTerms(c1); + const h2 = ContractTermsUtil.hashContractTerms(c2); + const h3 = ContractTermsUtil.hashContractTerms(c3); + const h4 = ContractTermsUtil.hashContractTerms(c4); + + t.is(h1, h2); + t.is(h1, h3); + t.is(h1, h4); + + // Doesn't contain salt + t.false(ContractTermsUtil.validateForgettable(cReq)); + + t.true(ContractTermsUtil.validateForgettable(c1)); + t.true(ContractTermsUtil.validateForgettable(c2)); + t.true(ContractTermsUtil.validateForgettable(c3)); + t.true(ContractTermsUtil.validateForgettable(c4)); +}); + +test("contract terms reference vector", (t) => { + const j = { + k1: 1, + $forgettable: { + k1: "SALT", + }, + k2: { + n1: true, + $forgettable: { + n1: "salt", + }, + }, + k3: { + n1: "string", + }, + }; + + const h = ContractTermsUtil.hashContractTerms(j); + + t.deepEqual( + h, + "VDE8JPX0AEEE3EX1K8E11RYEWSZQKGGZCV6BWTE4ST1C8711P7H850Z7F2Q2HSSYETX87ERC2JNHWB7GTDWTDWMM716VKPSRBXD7SRR", + ); +}); diff --git a/packages/taler-util/src/contractTerms.ts b/packages/taler-util/src/contractTerms.ts new file mode 100644 index 000000000..fa162e719 --- /dev/null +++ b/packages/taler-util/src/contractTerms.ts @@ -0,0 +1,236 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { canonicalJson } from "./helpers.js"; +import { Logger } from "./logging.js"; +import { kdf } from "./kdf.js"; +import { + decodeCrock, + encodeCrock, + getRandomBytes, + hash, + stringToBytes, +} from "./talerCrypto.js"; + +const logger = new Logger("contractTerms.ts"); + + + + +export namespace ContractTermsUtil { + + export function forgetAllImpl( + anyJson: any, + path: string[], + pred: PathPredicate, + ): any { + const dup = JSON.parse(JSON.stringify(anyJson)); + if (Array.isArray(dup)) { + for (let i = 0; i < dup.length; i++) { + dup[i] = forgetAllImpl(dup[i], [...path, `${i}`], pred); + } + } else if (typeof dup === "object" && dup != null) { + if (typeof dup.$forgettable === "object") { + for (const x of Object.keys(dup.$forgettable)) { + if (!pred([...path, x])) { + continue; + } + if (!dup.$forgotten) { + dup.$forgotten = {}; + } + if (!dup.$forgotten[x]) { + const membValCanon = stringToBytes( + canonicalJson(scrub(dup[x])) + "\0", + ); + const membSalt = stringToBytes(dup.$forgettable[x] + "\0"); + const h = kdf(64, membValCanon, membSalt, new Uint8Array([])); + dup.$forgotten[x] = encodeCrock(h); + } + delete dup[x]; + delete dup.$forgettable[x]; + } + if (Object.keys(dup.$forgettable).length === 0) { + delete dup.$forgettable; + } + } + for (const x of Object.keys(dup)) { + if (x.startsWith("$")) { + continue; + } + dup[x] = forgetAllImpl(dup[x], [...path, x], pred); + } + } + return dup; + } + + + export type PathPredicate = (path: string[]) => boolean; + + /** + * Scrub all forgettable members from an object. + */ + export function scrub(anyJson: any): any { + return forgetAllImpl(anyJson, [], () => true); + } + + /** + * Recursively forget all forgettable members of an object, + * where the path matches a predicate. + */ + export function forgetAll(anyJson: any, pred: PathPredicate): any { + return forgetAllImpl(anyJson, [], pred); + } + + /** + * Generate a salt for all members marked as forgettable, + * but which don't have an actual salt yet. + */ + export function saltForgettable(anyJson: any): any { + const dup = JSON.parse(JSON.stringify(anyJson)); + if (Array.isArray(dup)) { + for (let i = 0; i < dup.length; i++) { + dup[i] = saltForgettable(dup[i]); + } + } else if (typeof dup === "object" && dup !== null) { + if (typeof dup.$forgettable === "object") { + for (const k of Object.keys(dup.$forgettable)) { + if (dup.$forgettable[k] === true) { + dup.$forgettable[k] = encodeCrock(getRandomBytes(32)); + } + } + } + for (const x of Object.keys(dup)) { + if (x.startsWith("$")) { + continue; + } + dup[x] = saltForgettable(dup[x]); + } + } + return dup; + } + + const nameRegex = /^[0-9A-Za-z_]+$/; + + /** + * Check that the given JSON object is well-formed with regards + * to forgettable fields and other restrictions for forgettable JSON. + */ + export function validateForgettable(anyJson: any): boolean { + if (typeof anyJson === "string") { + return true; + } + if (typeof anyJson === "number") { + return ( + Number.isInteger(anyJson) && + anyJson >= Number.MIN_SAFE_INTEGER && + anyJson <= Number.MAX_SAFE_INTEGER + ); + } + if (typeof anyJson === "boolean") { + return true; + } + if (anyJson === null) { + return true; + } + if (Array.isArray(anyJson)) { + return anyJson.every((x) => validateForgettable(x)); + } + if (typeof anyJson === "object") { + for (const k of Object.keys(anyJson)) { + if (k.match(nameRegex)) { + if (validateForgettable(anyJson[k])) { + continue; + } else { + return false; + } + } + if (k === "$forgettable") { + const fga = anyJson.$forgettable; + if (!fga || typeof fga !== "object") { + return false; + } + for (const fk of Object.keys(fga)) { + if (!fk.match(nameRegex)) { + return false; + } + if (!(fk in anyJson)) { + return false; + } + const fv = anyJson.$forgettable[fk]; + if (typeof fv !== "string") { + return false; + } + } + } else if (k === "$forgotten") { + const fgo = anyJson.$forgotten; + if (!fgo || typeof fgo !== "object") { + return false; + } + for (const fk of Object.keys(fgo)) { + if (!fk.match(nameRegex)) { + return false; + } + // Check that the value has actually been forgotten. + if (fk in anyJson) { + return false; + } + const fv = anyJson.$forgotten[fk]; + if (typeof fv !== "string") { + return false; + } + try { + const decFv = decodeCrock(fv); + if (decFv.length != 64) { + return false; + } + } catch (e) { + return false; + } + // Check that salt has been deleted after forgetting. + if (anyJson.$forgettable?.[k] !== undefined) { + return false; + } + } + } else { + return false; + } + } + return true; + } + return false; + } + + /** + * Check that no forgettable information has been forgotten. + * + * Must only be called on an object already validated with validateForgettable. + */ + export function validateNothingForgotten(contractTerms: any): boolean { + throw Error("not implemented yet"); + } + + /** + * Hash a contract terms object. Forgettable fields + * are scrubbed and JSON canonicalization is applied + * before hashing. + */ + export function hashContractTerms(contractTerms: unknown): string { + const cleaned = scrub(contractTerms); + const canon = canonicalJson(cleaned) + "\0"; + const bytes = stringToBytes(canon); + return encodeCrock(hash(bytes)); + } +} -- cgit v1.2.3