diff options
Diffstat (limited to 'packages/taler-util/src')
| -rw-r--r-- | packages/taler-util/src/contractTerms.test.ts | 122 | ||||
| -rw-r--r-- | packages/taler-util/src/contractTerms.ts | 236 | 
2 files changed, 358 insertions, 0 deletions
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 <http://www.gnu.org/licenses/> + */ + +/** + * 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 <http://www.gnu.org/licenses/> + */ + +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)); +  } +}  | 
