Skip to content

Commit

Permalink
Refactor signing api, support injected custom suite
Browse files Browse the repository at this point in the history
  • Loading branch information
dmitrizagidulin authored and dlongley committed Jan 1, 2019
1 parent 7a3e9b0 commit a2f6191
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 164 deletions.
173 changes: 110 additions & 63 deletions lib/jsonld-signatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,77 +87,124 @@ api.suites = suites;
/**
* Signs a JSON-LD document using a digital signature.
*
* @param input the JSON-LD document to be signed.
* @param [options] options to use:
* algorithm the algorithm to use, eg: 'Ed25519Signature2018',
* 'RsaSignature2018'.
* [privateKeyPem] A PEM-encoded private key.
* [privateKeyBase58] A base85-encoded (Bitcoin/IPFS alphabet)
* private key.
* [creator] the URL to the paired public key.
* [date] an optional date to override the signature date with.
* [domain] an optional domain to include in the signature.
* [nonce] an optional nonce to include in the signature.
* [expansionMap] a custom expansion map that is passed
* to the JSON-LD processor; by default a function that will
* throw an error when unmapped properties are detected in the
* input, use `false` to turn this off and allow unmapped
* properties to be dropped or use a custom function.
* [proof] a JSON-LD document with options to use for the `proof`
* node (e.g. `proofPurpose` or any other custom fields can be
* provided here using a context different from security-v2).
* {Object} [purpose] define proofPurpose and additional options.
* Not providing this will result in the legacy behavior
* of checking only signatures without a proofPurpose. Not
* recommended... this can lead to confused deputy attacks.
* {string} proofPurpose the proofPurpose to use.
* (e.g. CredentialIssuance)
* {...*} additional named parameters that will be passed to the
* proofPurpose handler.
* [documentLoader(url, [callback(err, remoteDoc)])] the document
* loader.
* @param callback(err, signedDocument) called once the operation completes.
* @param input {object|string} Object to be signed, either a string URL
* (resolved to an object by `jsonld.expand()`) or a plain object (JSON-LD
* doc).
* @param options {object} Options hashmap
*
* Either a `suite` or an `algorithm` option is required:
*
* @return a Promise that resolves to the signed document.
* @param [options.suite] {LinkedDataSignature} Injected signature suite
* @param [options.algorithm] {string} Algorithm to use,
* eg: 'Ed25519Signature2018', 'RsaSignature2018', used to construct the
* suite if not passed in.
*
* Optional key material (if not passed along in `suite` above):
*
* @param [privateKeyPem] {string} A PEM-encoded private key, for RSA sigs
* @param [privateKeyBase58] {string} A base85-encoded (Bitcoin/IPFS alphabet)
* private key, for Ed25519 sigs.
* @param [options.creator] {string} A key id URL to the paired public key.
*
* Optional (but highly recommended) Proof Purpose params:
*
* @param [options.purpose] {object} define proofPurpose and additional options.
* WARNING: Not providing this will result in the legacy behavior of checking
* only signatures without a proofPurpose, which can lead to confused deputy
* attacks.
* @param [options.proof] {object} a JSON-LD document with options to use for
* the `proof` node (e.g. `proofPurpose` or any other custom fields can be
* provided here using a context different from security-v2).
* @param [options.proofPurpose] {string} proofPurpose the proofPurpose to use.
* (e.g. 'CredentialIssuance')
* @param [options.purposeParameters] {object} additional named parameters that
* will be passed to the proofPurpose handler.
*
* Advanced optional parameters and overrides:
*
* @param [options.date] {string|Date} Initialized to `now` if not passed
* @param [options.domain] {string} Domain to include in the signature
* @param [options.nonce] {string} Nonce to include in the signature.
* @param [options.documentLoader] {function} Custom document loader,
* documentLoader(url, [callback(err, remoteDoc)])
* @param [options.expansionMap] {function} A custom expansion map that is
* passed to the JSON-LD processor; by default a function that will throw
* an error when unmapped properties are detected in the input, use `false`
* to turn this off and allow unmapped properties to be dropped or use a
* custom function.
* @param [options.callback] {function} Optional Node style callback,
* `callback(err, signedDocument)` called once the operation completes, if not
* using Promises.
*
* @return {Promise<object>} Resolves with the signed input document, with
* the signature in the top-level `proof` property.
*/
api.sign = util.callbackify(async function(input, options) {
options = options || {};
api.sign = util.callbackify(async function sign(input, options = {}) {
options = _addEmbeddedContextDocumentLoader(options);

// no default algorithm; it must be specified
if(!('algorithm' in options)) {
throw new TypeError('"options.algorithm" must be specified.');
}
const suite = options.suite ||
getSuite({algorithm: options.algorithm, injector});

const SUPPORTED_ALGORITHMS = _getSupportedAlgorithms();
const {purpose, proofPurposeHandler, purposeParameters} =
getProofPurposeHandler({options, injector});

const {algorithm} = options;
if(SUPPORTED_ALGORITHMS.indexOf(algorithm) === -1) {
throw new Error(
'Unsupported algorithm "' + algorithm + '"; ' +
'"options.algorithm" must be one of: ' +
JSON.stringify(SUPPORTED_ALGORITHMS));
}
return suite.sign(
input,
{...options, purpose, proofPurposeHandler, purposeParameters});
});

options = _addEmbeddedContextDocumentLoader(options);
/**
* @param algorithm {string}
* @param injector {Injector}
* @throws {Error} On unsupported algorithm
* @private
* @returns {Suite} Suite instance for given algorithm
*/
function getSuite({algorithm, injector}) {
// no default algorithm; it must be specified
if(!algorithm) {
throw new TypeError('"options.algorithm" must be specified.');
}

// instantiate a proofPurpose handler
const {purpose, purposeParameters} = options;
let proofPurposeHandler;
if(purpose) {
const ProofPurposeHandler = api.proofPurposes.use(purpose);
if(!ProofPurposeHandler) {
throw new Error(`Unsupported proof purpose handler "${purpose}".`);
const SUPPORTED_ALGORITHMS = _getSupportedAlgorithms();

if(SUPPORTED_ALGORITHMS.indexOf(algorithm) === -1) {
throw new Error(
'Unsupported algorithm "' + algorithm + '"; ' +
'"options.algorithm" must be one of: ' +
JSON.stringify(SUPPORTED_ALGORITHMS));
}
proofPurposeHandler = new ProofPurposeHandler(injector);

// TODO: won't work with static analysis?
// use signature suite
//const Suite = require('./suites/' + algorithm);
const Suite = suites[algorithm];
return new Suite(injector);
}

/**
* @param options
* @param [options.purpose]
* @param [options.purposeParameters]
* @param injector
* @throws {Error} On unsupported proof purpose handler
* @private
* @returns {{purpose: *, proofPurposeHandler: *, purposeParameters: *}}
*/
function getProofPurposeHandler({options, injector}) {
// instantiate a proofPurpose handler
const {purpose, purposeParameters} = options;
let proofPurposeHandler;
if(purpose) {
const ProofPurposeHandler = api.proofPurposes.use(purpose);
if(!ProofPurposeHandler) {
throw new Error(`Unsupported proof purpose handler "${purpose}".`);
}
proofPurposeHandler = new ProofPurposeHandler(injector);
}
return {purpose, proofPurposeHandler, purposeParameters};
}
// TODO: won't work with static analysis?
// use signature suite
//const Suite = require('./suites/' + algorithm);
const Suite = suites[algorithm];
return new Suite(injector).sign(
input,
{...options, purpose, proofPurposeHandler, purposeParameters});
});

/**
* Verifies a JSON-LD digitally-signed object.
Expand Down Expand Up @@ -203,7 +250,7 @@ api.sign = util.callbackify(async function(input, options) {
*
* @return a Promise that resolves to the verification result.
*/
api.verify = util.callbackify(async function(input, options) {
api.verify = util.callbackify(async function verify(input, options) {
// set default options
options = {...options};

Expand Down
45 changes: 19 additions & 26 deletions lib/suites/Ed25519Signature2018.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,28 @@ const LinkedDataSignature = require('./LinkedDataSignature');
const util = require('../util');

module.exports = class Ed25519Signature2018 extends LinkedDataSignature {
constructor(injector, algorithm = 'Ed25519Signature2018') {
super(injector, algorithm);
/**
* @param injector
* @param [options={}] See docstring for `LinkedDataSignature.constructor`
*
* @param [options.privateKeyBase58] {string}
*/
constructor(injector, options = {}) {
super(injector, {algorithm: 'Ed25519Signature2018', ...options});
this.requiredKeyType = 'Ed25519VerificationKey2018';
this.privateKeyBase58 = options.privateKeyBase58;
}

/**
* @param verifyData {object}
*
* @param options {object} Contains key material, either a `signer` object
* or a base-58 encoded private key.
* @param verifyData {object} Input to be signed
*
* @param [options={}]
* @param [options.privateKeyBase58] {string}
* @param [options.signer] {object}
*
* @returns {Promise<string>} Signature as an encoded detached JWS
* @returns {Promise<string>} Resolves to signature, encoded as a detached
* JWS signature.
*/
async createSignatureValue(verifyData, options) {
async createSignatureValue(verifyData, options = {}) {
const forge = this.injector.use('forge');

// TODO: should abstract JWS signing bits out for reuse elsewhere
Expand Down Expand Up @@ -52,26 +57,14 @@ module.exports = class Ed25519Signature2018 extends LinkedDataSignature {

let encodedSignature;
const {nodejs} = this.injector.env;
if(options.signer) {
// a custom signing API has been provided
// a Uint8Array is passed to the signer
const {data} = util.createJws({encodedHeader, forge, nodejs, verifyData});
let message;
if(nodejs) {
// convert Buffer to Uint8Array
message = new Uint8Array(data.buffer);
} else {
// convert binary encoded string to Uint8Array
message = forge.util.binary.raw.decode(data);
}
encodedSignature = await options.signer.sign({message});
} else if(nodejs) {
if(nodejs) {
// optimize using node libraries
const chloride = require('chloride');
const bs58 = require('bs58');

// decode private key
const privateKey = bs58.decode(options.privateKeyBase58);
const privateKey = bs58
.decode(this.privateKeyBase58 || options.privateKeyBase58);

// build signing input per above comment
const {data} = util.createJws(
Expand All @@ -82,8 +75,8 @@ module.exports = class Ed25519Signature2018 extends LinkedDataSignature {
} else {
// browser or other environment
// decode private key
const privateKey = forge.util.binary.base58.decode(
options.privateKeyBase58);
const privateKey = forge.util.binary.base58
.decode(this.privateKeyBase58 || options.privateKeyBase58);
// build signing input per above comment
const message = util.createJws(
{encodedHeader, forge, nodejs, verifyData});
Expand Down
4 changes: 2 additions & 2 deletions lib/suites/GraphSignature2012.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ const constants = require('../constants');
const LinkedDataSignature2015 = require('./LinkedDataSignature2015');

module.exports = class GraphSignature2012 extends LinkedDataSignature2015 {
constructor(injector, algorithm = 'GraphSignature2012') {
super(injector, algorithm);
constructor(injector, {algorithm = 'GraphSignature2012'} = {}) {
super(injector, {algorithm});
}

async canonize(input, options) {
Expand Down
55 changes: 18 additions & 37 deletions lib/suites/LinkedDataSignature.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@ const Helper = require('../Helper');
// more DRY, especially wrt. plugins having reimplement functionality

module.exports = class LinkedDataSignature {
constructor(injector, algorithm) {
/**
* @param injector {Injector}
* @param algorithm {string} Provided by subclass
* @param [creator] {string} A key id URL to the paired public key.
*/
constructor(injector, {algorithm, creator}) {
this.injector = injector;
this.algorithm = algorithm;
this.creator = creator;
this.helper = new Helper(injector);
}

Expand Down Expand Up @@ -93,23 +99,16 @@ module.exports = class LinkedDataSignature {
* (resolved to an object by `jsonld.expand()`) or a plain object (JSON-LD
* doc).
*
* @param options
* @param options.algorithm {string} algorithm to use,
* eg: 'Ed25519Signature2018' or 'RsaSignature2018'.
* @param [options={}]
*
* @param [options.creator] {string} Creator Key ID (url), if not already
* passed in to suite constructor
*
* @param [options.proof] {object}
* @param [options.proofPurposeHandler]
* @param [options.purposeParameters]
*
* One of (mutually exclusive):
* @param [options.creator] {string} Creator Key ID (url)
* @param [options.signer] {object}
*
* Signer API:
* @param [options.signer.creator] {string} Creator Key ID (url)
* @param [options.signer.sign] {function} Injected `sign()` function
*
* Key material (if not passing in a `signer` option):
* Optional key material (if not set in suite constructor):
* @param [options.privateKeyPem] {string} A PEM-encoded private key
* (relevant for RSA signatures)
* @param [options.privateKeyBase58] {string} A base85-encoded (Bitcoin/IPFS
Expand All @@ -134,28 +133,16 @@ module.exports = class LinkedDataSignature {
options = {...options};

// validate common options
if(options.creator !== undefined && typeof options.creator !== 'string') {
throw new TypeError('"options.creator" must be a URL string.');
const creator = this.creator || options.creator;
if(creator !== undefined && typeof creator !== 'string') {
throw new TypeError('"creator" must be a URL string.');
}
if(options.domain !== undefined && typeof options.domain !== 'string') {
throw new TypeError('"options.domain" must be a string.');
}
if(options.nonce !== undefined && typeof options.nonce !== 'string') {
throw new TypeError('"options.nonce" must be a string.');
}
const {signer} = options;
if(signer) {
if(!(signer.sign && typeof signer.sign === 'function')) {
throw new TypeError('"options.signer.sign" must be a function.');
}
if(!(signer.creator && typeof signer.creator === 'string')) {
throw new TypeError('"options.signer.creator" must be a string.');
}
if(options.creator) {
throw new TypeError(
'"options.creator" and "options.signer" are mutually exclusive.');
}
}

// disallow dropping properties when expanding by default
if(options.expansionMap !== false) {
Expand Down Expand Up @@ -195,27 +182,21 @@ module.exports = class LinkedDataSignature {
options.date = util.w3cDate(options.date);
}

// ensure algorithm is set
proof.type = options.algorithm;
proof.type = this.algorithm; // ensure algorithm is set

// add API overrides
if(options.date !== undefined) {
proof.created = options.date;
}
if(options.creator !== undefined) {
proof.creator = options.creator;
if(creator !== undefined) {
proof.creator = creator;
}
if(options.domain !== undefined) {
proof.domain = options.domain;
}
if(options.nonce !== undefined) {
proof.nonce = options.nonce;
}
// FIXME: how many of the other options above can be supplied by the
// custom signer?
if(signer) {
proof.creator = signer.creator;
}

// add fields from proofPurpose
// the proof going into the handler is compacted in the SECURITY_CONTEXT
Expand Down
Loading

0 comments on commit a2f6191

Please sign in to comment.