Source: utils/cd.js

// Claim deduction from verifiable presentations.

import deepEqual from 'deep-equal';
import jsonld from 'jsonld';
import { validate, prove, infer } from 'rify';
import { assert } from '@polkadot/util';
import { expandedCredentialProperty } from './vc/constants';
import { fromJsonldjsCg, merge } from './claimgraph';
import {
  canonRules, canonProof, canonClaimGraph, decanonClaimGraph, decanonProof,
} from './canonicalize';
import { assertValidNode, assertType } from './common';

export const expandedLogicProperty = 'https://www.dock.io/rdf2020#logicV1';
export const expandedProofProperty = 'https://w3id.org/security#proof';
export const expandedIssuerProperty = 'https://www.w3.org/2018/credentials#issuer';

/**
 * Returns a list of all RDF statements proven by the presentation. DOES NOT VERIFY THE
 * PRESENTATION. Verification must be performed for the results of this function to be trustworthy.
 * The return value may contaim duplicate claims.
 *
 * This function throws an error if:
 * a provided proof is not applicable given the ruleset,
 * a provided proof makes unverified assumptions,
 * a provided proof is malformed.
 *
 * @param expandedPresentation - a VCDM presentation as expanded json-ld
 * @param rules - ordered list of axioms which will be accepted within proofs of composite claims
 * @returns {Promise<Object[]>}
 */
export async function acceptCompositeClaims(expandedPresentation, rules) {
  assert(
    rules !== undefined,
    'An axiom list must be provided. Hint: rules may be "[]" to reject all.',
  );

  const cg = await presentationToEEClaimGraph(expandedPresentation);
  const proof = extractProof(expandedPresentation);
  const implied = getImplications(cg, proof, rules);

  // return (claimgraph U implied)
  return [...cg, ...implied];
}

/**
 * Convert a list of expanded credentials which have already been verified into an RDF claim graph.
 * The resulting claimgraph is in Explicit Ethos form.
 *
 * @param expandedCredentials - A list of expanded credentials, each is expected to be a separate
 *                              @graph.
 * @returns {Promise<Object[]>}
 */
async function credsToEEClaimGraph(expandedCredentials) {
  const ees = await Promise.all(expandedCredentials.map(credToEECG));
  return merge(ees);
}

/**
 * Convert a single expanded credential which has already been verified into an RDF claim graph.
 *
 * @returns {Promise<Object[]>}
 */
async function credToEECG(expandedCredential) {
  assert(
    expandedCredential['@graph'] !== undefined,
    'Expected each credential to expand to its own @graph',
  );
  const cred = { ...unwrapE(expandedCredential['@graph']) };

  // This line relies on the assumption that if the credential passed verification then the
  // issuer property was not forged.
  assert(cred[expandedIssuerProperty] !== undefined, 'encountered credential without an issuer');
  const issuer = cred[expandedIssuerProperty][0]['@id'];
  assertType(issuer, 'string');
  assert(!issuer.startsWith('_:'), 'issuer is assumed not to be a blank node');

  // remove the proof
  delete cred[expandedProofProperty];

  // convert to claimgraph
  const cg = fromJsonldjsCg(await jsonld.toRDF(cred));

  for (const claim of cg) {
    // Credentials containing a "@graph" declaration will cause an assertion error here.
    // And rightfully so. Here's why:
    // Each statement in a cred is passed to the reasoner as a quad with `issuer` as the graph.
    // It would be potentially incorrect to overwrite a non-default graph.
    // Leaving a non-default graph as-is would be unsafe.
    // While it would be valid to simply delete quads with non-default graphs, it would be confusing
    // to users if the content of their credential was swallowed without warning.
    assert(deepEqual(claim[3], { DefaultGraph: true }), 'subgraph found in credential');
    claim[3] = { Iri: issuer };
  }

  return cg;
}

/**
 * Returns a list of all RDF statements in the presentation. DOES NOT VERIFY THE
 * PRESENTATION. Verification must be performed for the results of this function to be trustworthy.
 *
 * @param expandedPresentation - a VCDM presentation as expanded json-ld
 * @returns {Promise<Object[]>}
 */
export async function presentationToEEClaimGraph(expandedPresentation) {
  const ep = unwrapE(expandedPresentation);

  // get ordered list of all credentials
  const creds = jsonld.getValues(ep, expandedCredentialProperty);

  // convert them to a single claimgraph
  return credsToEEClaimGraph(creds);
}

export class UnverifiedAssumption extends Error {
  constructor(unverifiedAssumption) {
    super('Proof relies on assumption that are not in the input.');
    this.unverifiedAssumption = unverifiedAssumption;
  }
}

/**
 * Extracts any included proofs of composite claims from a presentation.
 * The presentation must be in expanded form.
 * Returns the proofs, concatenated together.
 *
 * @param expandedPresentation - a VCDM presentation as expanded json-ld
 * @returns {Promise<Object[]>}
 */
export function extractProof(expandedPresentation) {
  return jsonld.getValues(unwrapE(expandedPresentation), expandedLogicProperty)
    .map(fromJSONLiteral)
    .flat(1);
}

/**
 * Given a claimgraph of true assumptions, and the allowed logical rules, return the claims that are
 * proven true by proof. If the proof is not applicable given the ruleset, or makes unverified
 * assumptions, throw an error. The return value may contain duplicate claims.
 *
 * @param claimgraph - known true assumptions (usually extracted from a presentation or a cred)
 * @param proof - proof of composite claims (usually comes from calling proveh())
 * @param rules - ordered list of axioms
 * @returns {Object[]}
 */
export function getImplications(claimgraph, proof, rules) {
  const valid = validateh(rules, proof);

  // Check that all assumptions made by the proof were provided in the claimgraph of assumptions
  for (const assumption of valid.assumed) {
    if (!claimgraph.some((claim) => claimEq(claim, assumption))) {
      throw new UnverifiedAssumption([...assumption]);
    }
  }

  return valid.implied;
}

/**
 * Given the assumptions encoded in the provided presentation, prove a list of composite claims.
 * T return proof of composite claims as a jsonld json literal which can be attached directly to a
 * presentation as {expandedLogicProperty} before [signing and ]submitting.
 *
 * This function throws an error if the requested composite claims are unprovable.
 *
 * @param expandedPresentation - a VCDM presentation as expanded json-ld
 * @param compositeClaims - claims to prove, provide in claimgraph format
 * @param rules - ordered list of axioms which will be accepted within proofs of composite claims
 * @returns {Promise<Object>} - proof is returned as a json literal
 */
export async function proveCompositeClaims(expandedPresentation, compositeClaims, rules) {
  assert(
    rules !== undefined,
    'An axiom list must be provided. Hint: rules may be "[]" to reject all.',
  );
  const cg = await presentationToEEClaimGraph(expandedPresentation);
  const prevProof = await extractProof(expandedPresentation);
  const newProof = proveh(cg, compositeClaims, rules);
  return toJsonLiteral(prevProof.concat(newProof));
}

// prove-high
// A higher level wrapper around prove that first converts rules, composite claims, and premises to
// the canonical representation as defined by `canon()` in `claimgraph.js`. This wrapper
// deserializes the returned values before passing them back to the caller.
export function proveh(
  premises,
  toProve,
  rules,
) {
  foreachQuadInRules(rules, assertQuad);
  premises.forEach(assertQuad);
  toProve.forEach(assertQuad);
  const proof = prove(
    canonClaimGraph(premises),
    canonClaimGraph(toProve),
    canonRules(rules),
  );
  return decanonProof(proof);
}

// validate-high
// A higher level wrapper around validate that first converts rules and proof to the canonical
// representation as defined by `canon()` in `claimgraph.js`. This wrapper deserializes the
// returned values before passing them back to the caller.
export function validateh(rules, proof) {
  foreachQuadInRules(rules, assertQuad);
  const {
    assumed,
    implied,
  } = validate(
    canonRules(rules),
    canonProof(proof),
  );
  return {
    assumed: decanonClaimGraph(assumed),
    implied: decanonClaimGraph(implied),
  };
}

// infer-high
// A higher level wrapper around infer that first converts premises and rules to the canonical
// representation as defined by `canon()` in `claimgraph.js`. This wrapper deserializes the
// returned values before passing them back to the caller.
export function inferh(premises, rules) {
  premises.forEach(assertQuad);
  foreachQuadInRules(rules, assertQuad);
  const inferred = infer(canonClaimGraph(premises), canonRules(rules));
  return decanonClaimGraph(inferred);
}

function foreachQuadInRules(rules, f) {
  for (const rule of rules) {
    for (const quad of rule.if_all) {
      f(quad);
    }
    for (const quad of rule.then) {
      f(quad);
    }
  }
}

function assertQuad(quad) {
  assert(quad.length === 4, () => `Quads must have length 4, got: ${JSON.stringify(quad)}`);
}

/// check two claims for equality
function claimEq(a, b) {
  for (const claim of [a, b]) {
    if (!(claim instanceof Array) || claim.length !== 4) {
      throw new TypeError();
    }
    for (const node of claim) {
      assertValidNode(node);
    }
  }
  return deepEqual(a, b);
}

// https://w3c.github.io/json-ld-syntax/#json-literals
function fromJSONLiteral(literal) {
  if (literal['@type'] !== '@json') {
    throw new TypeError(`not a json literal: ${literal}`);
  }
  if (literal['@value'] === undefined) {
    throw new TypeError(`json literal has no "@value": ${literal}`);
  }
  return literal['@value'];
}

// https://w3c.github.io/json-ld-syntax/#json-literals
function toJsonLiteral(json) {
  return {
    '@type': '@json',
    '@value': JSON.parse(JSON.stringify(json)),
  };
}

// Unwrap Expanded jsonld
//
// jsonld expansion by jsonld-js results in an array. This module operates on expanded jsonld so
// getting the first and only element from that expanded array is a common task.
//
// We assume that a root level expanded jsonld object will always be an array with exactly one
// element.
function unwrapE(expanded) {
  assert(
    Array.isArray(expanded) && expanded.length === 1,
    'expected expanded jsonld as an array of one element',
  );
  return expanded[0];
}