UNPKG

jsonld-signatures

Version:

An implementation of the Linked Data Signatures specifications for JSON-LD in JavaScript.

474 lines (428 loc) 16.1 kB
/*! * Copyright (c) 2017-2024 Digital Bazaar, Inc. All rights reserved. */ 'use strict'; const constants = require('../constants'); const jsonld = require('jsonld'); const rdfCanonize = require('rdf-canonize'); const util = require('../util'); const {sha256digest} = require('../sha256digest'); const LinkedDataProof = require('./LinkedDataProof'); module.exports = class LinkedDataSignature extends LinkedDataProof { /** * Parent class from which the various LinkDataSignature suites (such as * `Ed25519Signature2020`) inherit. * NOTE: Developers are never expected to use this class directly, but to * only work with individual suites. * * @param {object} options - Options hashmap. * @param {string} options.type - Suite name, provided by subclass. * @typedef LDKeyPair * @param {LDKeyPair} LDKeyClass - The crypto-ld key class that this suite * will use to sign/verify signatures. Provided by subclass. Used * during the `verifySignature` operation, to create an instance (containing * a `verifier()` property) of a public key fetched via a `documentLoader`. * * @param {string} contextUrl - JSON-LD context URL that corresponds to this * signature suite. Provided by subclass. Used for enforcing suite context * during the `sign()` operation. * * For `sign()` operations, either a `key` OR a `signer` is required. * For `verify()` operations, you can pass in a verifier (from KMS), or * the public key will be fetched via documentLoader. * * @param {object} [options.key] - An optional key object (containing an * `id` property, and either `signer` or `verifier`, depending on the * intended operation. Useful for when the application is managing keys * itself (when using a KMS, you never have access to the private key, * and so should use the `signer` param instead). * * @param {{sign: Function, id: string}} [options.signer] - Signer object * that has two properties: an async `sign()` method, and an `id`. This is * useful when interfacing with a KMS (since you don't get access to the * private key and its `signer`, the KMS client gives you only the signer * object to use). * * @param {{verify: Function, id: string}} [options.verifier] - Verifier * object that has two properties: an async `verify()` method, and an `id`. * Useful when working with a KMS-provided verifier. * * Advanced optional parameters and overrides: * * @param {object} [options.proof] - A JSON-LD document with options to use * for the `proof` node (e.g. any other custom fields can be provided here * using a context different from security-v2). If not provided, this is * constructed during signing. * @param {string|Date} [options.date] - Signing date to use (otherwise * defaults to `now()`). * @param {boolean} [options.useNativeCanonize] - Whether to use a native * canonize algorithm. * @param {object} [options.canonizeOptions] - Options to pass to * canonize algorithm. */ constructor({ type, proof, LDKeyClass, date, key, signer, verifier, useNativeCanonize, canonizeOptions, contextUrl } = {}) { super({type}); this.LDKeyClass = LDKeyClass; this.contextUrl = contextUrl; this.proof = proof; const vm = _processSignatureParams({key, signer, verifier}); this.verificationMethod = vm.verificationMethod; this.key = vm.key; this.signer = vm.signer; this.verifier = vm.verifier; this.canonizeOptions = canonizeOptions; if(date) { this.date = new Date(date); if(isNaN(this.date)) { throw TypeError(`"date" "${date}" is not a valid date.`); } } this.useNativeCanonize = useNativeCanonize; this._hashCache = null; } /** * @param {object} options - The options to use. * @param {object} options.document - The document to be signed. * @param {ProofPurpose} options.purpose - The proof purpose instance. * @param {Array} options.proofSet - Any existing proof set. * @param {function} options.documentLoader - The document loader to use. * @param {function} options.expansionMap - NOT SUPPORTED; do not use. * * @returns {Promise<object>} Resolves with the created proof object. */ async createProof({ document, purpose, proofSet, documentLoader, expansionMap }) { if(expansionMap) { throw new Error('"expansionMap" not supported.'); } // build proof (currently known as `signature options` in spec) let proof; if(this.proof) { // shallow copy proof = {...this.proof}; } else { // create proof JSON-LD document proof = {}; } // ensure proof type is set proof.type = this.type; // set default `now` date if not given in `proof` or `options` let date = this.date; if(proof.created === undefined && date === undefined) { date = new Date(); } // ensure date is in string format if(date && typeof date !== 'string') { date = util.w3cDate(date); } // add API overrides if(date) { proof.created = date; } proof.verificationMethod = this.verificationMethod; // add any extensions to proof (mostly for legacy support) proof = await this.updateProof( {document, proof, proofSet, purpose, documentLoader}); // allow purpose to update the proof; the `proof` is in the // SECURITY_CONTEXT_URL `@context` -- therefore the `purpose` must // ensure any added fields are also represented in that same `@context` proof = await purpose.update( proof, {document, suite: this, documentLoader}); // create data to sign const verifyData = await this.createVerifyData( {document, proof, proofSet, documentLoader}); // sign data proof = await this.sign({verifyData, document, proof, documentLoader}); return proof; } /** * @param {object} options - The options to use. * @param {object} options.proof - The proof to be updated. * @param {Array} options.proofSet - Any existing proof set. * @param {function} options.expansionMap - NOT SUPPORTED; do not use. * * @returns {Promise<object>} Resolves with the created proof object. */ async updateProof({proof, expansionMap}) { if(expansionMap) { throw new Error('"expansionMap" not supported.'); } // extending classes may do more return proof; } /** * @param {object} options - The options to use. * @param {object} options.proof - The proof to be verified. * @param {object} options.document - The document the proof applies to. * @param {ProofPurpose} options.purpose - The proof purpose instance. * @param {Array} options.proofSet - Any existing proof set. * @param {function} options.documentLoader - The document loader to use. * @param {function} options.expansionMap - NOT SUPPORTED; do not use. * * @returns {Promise<{object}>} Resolves with the verification result. */ async verifyProof({proof, document, proofSet, documentLoader, expansionMap}) { if(expansionMap) { throw new Error('"expansionMap" not supported.'); } try { // create data to verify const verifyData = await this.createVerifyData( {document, proof, proofSet, documentLoader, expansionMap}); // fetch verification method const verificationMethod = await this.getVerificationMethod( {proof, document, documentLoader, expansionMap}); // verify signature on data const verified = await this.verifySignature({ verifyData, verificationMethod, document, proof, documentLoader, expansionMap}); if(!verified) { throw new Error('Invalid signature.'); } return {verified: true, verificationMethod}; } catch(error) { return {verified: false, error}; } } async canonize(input, {documentLoader, expansionMap, skipExpansion}) { if(expansionMap) { throw new Error('"expansionMap" not supported.'); } return _canonize(input, { algorithm: 'RDFC-1.0', // do not resolve any relative URLs or terms, throw errors instead base: null, format: 'application/n-quads', documentLoader, // throw errors if any values would be dropped due to missing // definitions or relative URLs safe: true, skipExpansion, useNative: this.useNativeCanonize, ...this.canonizeOptions }); } async canonizeProof(proof, {document, documentLoader, expansionMap}) { if(expansionMap) { throw new Error('"expansionMap" not supported.'); } // `jws`,`signatureValue`,`proofValue` must not be included in the proof // options proof = { '@context': document['@context'] || constants.SECURITY_CONTEXT_URL, ...proof }; delete proof.jws; delete proof.signatureValue; delete proof.proofValue; return this.canonize(proof, { documentLoader, expansionMap, skipExpansion: false, ...this.canonizeOptions }); } /** * @param {object} options - The options to use. * @param {object} options.document - The document to be signed/verified. * @param {object} options.proof - The proof to be verified. * @param {Array} options.proofSet - Any existing proof set. * @param {function} options.documentLoader - The document loader to use. * @param {function} options.expansionMap - NOT SUPPORTED; do not use. * * @returns {Promise<{Uint8Array}>}. */ async createVerifyData({document, proof, documentLoader, expansionMap}) { if(expansionMap) { throw new Error('"expansionMap" not supported.'); } // get cached document hash let cachedDocHash; const {_hashCache} = this; if(_hashCache && _hashCache.document === document) { cachedDocHash = _hashCache.hash; } else { this._hashCache = { document, // canonize and hash document hash: cachedDocHash = this.canonize(document, {documentLoader, expansionMap}) .then(c14nDocument => sha256digest({string: c14nDocument})) }; } // await both c14n proof hash and c14n document hash const [proofHash, docHash] = await Promise.all([ // canonize and hash proof this.canonizeProof( proof, {document, documentLoader, expansionMap}) .then(c14nProofOptions => sha256digest({string: c14nProofOptions})), cachedDocHash ]); // concatenate hash of c14n proof options and hash of c14n document return util.concat(proofHash, docHash); } /** * @param verifyData {Uint8Array}. * @param document {object} document from which to derive a new document * and proof. * @param proof {object} * @param proofSet {Array} * @param documentLoader {function} * * @returns {Promise<{object}>} The new document with `proof`. */ async derive() { throw new Error('Must be implemented by a derived class.'); } /** * @param document {object} to be signed. * @param proof {object} * @param documentLoader {function} */ async getVerificationMethod({proof, documentLoader}) { let {verificationMethod} = proof; if(typeof verificationMethod === 'object') { verificationMethod = verificationMethod.id; } if(!verificationMethod) { throw new Error('No "verificationMethod" found in proof.'); } // Note: `expansionMap` is intentionally not passed; we can safely drop // properties here and must allow for it const framed = await jsonld.frame(verificationMethod, { '@context': constants.SECURITY_CONTEXT_URL, '@embed': '@always', id: verificationMethod }, {documentLoader, compactToRelative: false, safe: true}); if(!framed) { throw new Error(`Verification method ${verificationMethod} not found.`); } // ensure verification method has not been revoked if(framed.revoked !== undefined) { throw new Error('The verification method has been revoked.'); } return framed; } /** * @param verifyData {Uint8Array}. * @param document {object} to be signed. * @param proof {object} * @param documentLoader {function} * @param expansionMap {function} * * @returns {Promise<{object}>} the proof containing the signature value. */ async sign() { throw new Error('Must be implemented by a derived class.'); } /** * @param verifyData {Uint8Array}. * @param verificationMethod {object}. * @param document {object} to be signed. * @param proof {object} * @param documentLoader {function} * @param expansionMap {function} * * @returns {Promise<boolean>} */ async verifySignature() { throw new Error('Must be implemented by a derived class.'); } /** * Ensures the document to be signed contains the required signature suite * specific `@context`, by either adding it (if `addSuiteContext` is true), * or throwing an error if it's missing. * * @param {object} options - Options hashmap. * @param {object} options.document - JSON-LD document to be signed. * @param {boolean} options.addSuiteContext - Add suite context? */ ensureSuiteContext({document, addSuiteContext}) { const {contextUrl} = this; if(_includesContext({document, contextUrl})) { // document already includes the required context return; } if(!addSuiteContext) { throw new TypeError( `The document to be signed must contain this suite's @context, ` + `"${contextUrl}".`); } // enforce the suite's context by adding it to the document const existingContext = document['@context'] || []; document['@context'] = Array.isArray(existingContext) ? [...existingContext, contextUrl] : [existingContext, contextUrl]; } }; /** * Tests whether a provided JSON-LD document includes a context URL in its * `@context` property. * * @param {object} options - Options hashmap. * @param {object} options.document - A JSON-LD document. * @param {string} options.contextUrl - A context URL. * * @returns {boolean} Returns true if document includes context. */ function _includesContext({document, contextUrl}) { const context = document['@context']; return context === contextUrl || (Array.isArray(context) && context.includes(contextUrl)); } /** * See constructor docstring for param details. * * @returns {{verificationMethod: string, key: LDKeyPair, * signer: {sign: Function, id: string}, * verifier: {verify: Function, id: string}}} - Validated and initialized * key-related parameters. */ function _processSignatureParams({key, signer, verifier}) { // We are explicitly not requiring a key or signer/verifier param to be // present, to support the verify() use case where the verificationMethod // is being fetched by the documentLoader const vm = {}; if(key) { vm.key = key; vm.verificationMethod = key.id; if(typeof key.signer === 'function') { vm.signer = key.signer(); } if(typeof key.verifier === 'function') { vm.verifier = key.verifier(); } if(!(vm.signer || vm.verifier)) { throw new TypeError( 'The "key" parameter must contain a "signer" or "verifier" method.'); } } else { vm.verificationMethod = (signer && signer.id) || (verifier && verifier.id); vm.signer = signer; vm.verifier = verifier; } if(vm.signer) { if(typeof vm.signer.sign !== 'function') { throw new TypeError('A signer API has not been specified.'); } } if(vm.verifier) { if(typeof vm.verifier.verify !== 'function') { throw new TypeError('A verifier API has not been specified.'); } } return vm; } async function _canonize(input, options) { // convert to RDF dataset and do canonicalization const opts = { rdfDirection: 'i18n-datatype', ...options, produceGeneralizedRdf: false, }; delete opts.format; const dataset = await jsonld.toRDF(input, opts); return rdfCanonize.canonize(dataset, options); }