import { initializeWasm, CredentialSchema, DefaultSchemaParsingOpts } from '@docknetwork/crypto-wasm-ts';
import jsonld from 'jsonld';
import { SECURITY_CONTEXT_URL } from 'jsonld-signatures';
import { u8aToU8a } from '@polkadot/util';
import stringify from 'json-stringify-deterministic';
import { withExtendedStaticProperties } from '../../../inheritance';
import CustomLinkedDataSignature from './CustomLinkedDataSignature';
const SUITE_CONTEXT_URL = 'https://www.w3.org/2018/credentials/v1';
export const DEFAULT_PARSING_OPTS = {
useDefaults: false,
};
/**
* Defines commons for the `@docknetwork/crypto-wasm-ts` signatures.
*/
export default withExtendedStaticProperties(
['KeyPair', 'CredentialBuilder', 'Credential', 'proofType'],
class DockCryptoSignature extends CustomLinkedDataSignature {
/**
* Default constructor
* @param options {SignatureSuiteOptions} options for constructing the signature suite
* @param type
* @param LDKeyClass
* @param url
*/
constructor(options = {}, type, LDKeyClass, url) {
const {
verificationMethod, signer, keypair, verifier,
} = options;
super({
type,
LDKeyClass,
contextUrl: SUITE_CONTEXT_URL,
alg: type,
signer,
verifier,
useProofValue: true,
});
this.proof = {
'@context': [
{
sec: 'https://w3id.org/security#',
proof: {
'@id': 'sec:proof',
'@type': '@id',
'@container': '@graph',
},
},
url,
],
type,
};
this.requireCredentialSchema = true;
this.verificationMethod = verificationMethod;
if (keypair) {
if (verificationMethod === undefined) {
this.verificationMethod = keypair.id;
}
this.key = keypair;
}
}
/** Create serialized credential for signing and verification
* @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 {function} options.documentLoader - The document loader to use.
* @param {function} options.expansionMap - NOT SUPPORTED; do not use.
*
* @returns {Promise<Uint8Array[]>}.
*/
async createVerifyData(options) {
await initializeWasm();
// If this function is being called for credential verification or not
const forVerification = !!(options.proof && options.proof.proofValue);
let serializedCredential;
let credSchema;
if (forVerification) {
if (options.document.cryptoVersion) {
// Newer credentials will have cryptoVersion field set
[serializedCredential, credSchema] = this.constructor.convertCredentialForVerification(options);
} else {
// Legacy. Cannot use `convertCredentialForVerification` because JSON.stringify is not deterministic
// and credentialSchema string becomes different. See https://stackoverflow.com/a/43049877
[serializedCredential, credSchema] = this.constructor.convertCredential(options);
}
} else {
// Serialize the data for signing
[serializedCredential, credSchema] = await this.constructor.convertCredentialToSerializedForSigning(options);
}
// Encode messages, retrieve names/values array
const nameValues = credSchema.encoder.encodeMessageObject(
serializedCredential,
false,
);
return nameValues[1];
}
/**
* Convert given JSON-LD credential to TS-anoncred's credential. This would not have the signature.
* @param document - JSON-LD credential
* @param explicitProof
* @param signingOptions
* @returns {Array} - Returns [serialized cred object, cred schema]
*/
static convertCredential({
document,
proof: explicitProof /* documentLoader */,
signingOptions = { requireAllFieldsFromSchema: false },
}) {
const [trimmedProof] = this.getTrimmedProofAndValue(document, explicitProof);
const [credSchema] = this.extractSchema(document);
const credBuilder = new this.CredentialBuilder();
// Set legacy version
credBuilder.version = '0.2.0';
credBuilder.schema = credSchema;
// Extract top level fields from the document aside from these
const {
cryptoVersion: _cryptoVersion,
credentialSchema: _credentialSchema,
credentialSubject,
credentialStatus,
...topLevelFields
} = {
...document,
proof: trimmedProof,
};
credBuilder.subject = credentialSubject;
credBuilder.credStatus = credentialStatus;
// Add all other top level fields to the credential
Object.keys(topLevelFields)
.sort()
.forEach((k) => {
credBuilder.setTopLevelField(k, topLevelFields[k]);
});
// To work with JSON-LD credentials/presentations, we must always reveal the context and type
// NOTE: that they are encoded as JSON strings here to reduce message count and so its *always known*
credBuilder.setTopLevelField(
'@context',
JSON.stringify(document['@context']),
);
credBuilder.setTopLevelField('type', JSON.stringify(document.type));
// Allow for relaxed schema generation, then embed the generated schema directly into the credential
const builtAnoncreds = credBuilder.updateSchemaIfNeeded(signingOptions);
// Re-assign the embedded schema to the document schema object
// this is a bit hacky, but its the only way right now
if (document.credentialSchema) {
const fullSchema = builtAnoncreds.credentialSchema;
Object.assign(document.credentialSchema, typeof fullSchema === 'string' ? JSON.parse(fullSchema) : fullSchema);
}
// Return the built anoncreds credential and the schema associated
return [builtAnoncreds, credBuilder.schema];
}
/**
* Convert given JSON-LD credential to TS-anoncred's credential for adding to a presentation. This would have the signature.
* @param document - JSON-LD credential
* @param explicitProof
* @returns {Credential}
*/
static convertCredentialForPresBuilding({
document,
proof: explicitProof /* documentLoader */,
}) {
const [trimmedProof, proofVal] = this.getTrimmedProofAndValue(document, explicitProof);
const [credSchema, wasExactSchema] = this.extractSchema(document);
const credJson = {
...document,
proof: trimmedProof,
};
// Old credentials don't include the version number but it was set while signing. So add it here if missing.
if (credJson.cryptoVersion === undefined) {
credJson.cryptoVersion = '0.2.0';
}
this.formatMandatoryFields(credJson, document);
const cred = this.Credential.fromJSON(credJson, CustomLinkedDataSignature.fromJsigProofValue(proofVal));
if (!wasExactSchema) {
cred.schema = CredentialSchema.generateAppropriateSchema(credJson, credSchema);
}
return cred;
}
/**
* Convert given JSON-LD credential to TS-anoncred's credential. This would have the signature.
* @param document - JSON-LD credential
* @param explicitProof
* @returns {Array}
*/
static convertCredentialForVerification({
document,
proof: explicitProof /* documentLoader */,
}) {
const [trimmedProof] = this.getTrimmedProofAndValue(document, explicitProof);
const s = this.extractSchema(document);
let credSchema = s[0];
const wasLegacySchema = s[1];
const credJson = {
...document,
proof: trimmedProof,
};
this.formatMandatoryFields(credJson, document);
if (!wasLegacySchema) {
// Older credentials didn't include the version field in the final credential but they did while signing
credJson.cryptoVersion = '0.2.0';
if (credJson.credentialSchema === undefined) {
credJson.credentialSchema = JSON.stringify(credSchema.toJSON());
}
credSchema = CredentialSchema.generateAppropriateSchema(credJson, credSchema);
}
const schemaJson = credSchema.toJSON();
// Older versions used JSON.stringify but newer use deterministic conversion
credJson.credentialSchema = wasLegacySchema ? stringify(schemaJson) : JSON.stringify(schemaJson);
if (document.credentialSchema) {
Object.assign(document.credentialSchema, schemaJson);
}
return [credJson, credSchema];
}
/**
* Convert given JSON-LD credential to TS-anoncred's credential. This would not have the signature.
* @param document - JSON-LD credential
* @param explicitProof
* @returns {Promise<Array>}
*/
static async convertCredentialToSerializedForSigning({
document,
proof,
documentLoader,
}) {
const [trimmedProof] = this.getTrimmedProofAndValue(document, proof);
const [credSchema] = await this.extractSchemaForSigning(document, documentLoader);
const credBuilder = new this.CredentialBuilder();
credBuilder.schema = credSchema;
// Extract top level fields from the document aside from these
const {
credentialSchema: _credentialSchema,
credentialSubject,
credentialStatus,
...topLevelFields
} = {
...document,
proof: trimmedProof,
};
credBuilder.subject = credentialSubject;
credBuilder.credStatus = credentialStatus;
// Add all other top level fields to the credential
Object.keys(topLevelFields)
.sort()
.forEach((k) => {
credBuilder.setTopLevelField(k, topLevelFields[k]);
});
// To work with JSON-LD credentials/presentations, we must always reveal the context and type
// NOTE: that they are encoded as JSON strings here to reduce message count and so its *always known*
credBuilder.setTopLevelField(
'@context',
JSON.stringify(document['@context']),
);
credBuilder.setTopLevelField('type', JSON.stringify(document.type));
const serializedCred = credBuilder.serializeForSigning();
credBuilder.schema = CredentialSchema.generateAppropriateSchema(serializedCred, credSchema);
// Update `document` so that generated credential has `credentialSchema` and `cryptoVersion` set
const updatedSchemaJson = credBuilder.schema.toJSON();
serializedCred.credentialSchema = stringify(updatedSchemaJson);
Object.assign(document.credentialSchema, updatedSchemaJson);
// eslint-disable-next-line no-param-reassign
document.cryptoVersion = serializedCred.cryptoVersion;
return [serializedCred, credBuilder.schema];
}
/**
* To work with JSON-LD credentials/presentations, we must always reveal the context, type
* @param credJson
* @param document
*/
static formatMandatoryFields(credJson, document) {
// NOTE: that they are encoded as JSON strings here to reduce message count and so its *always known*
// eslint-disable-next-line no-param-reassign
credJson['@context'] = JSON.stringify(document['@context']);
// eslint-disable-next-line no-param-reassign
credJson.type = JSON.stringify(document.type);
}
/**
* Remove actual value of proof (signature) from the object and return the trimmed object and the proof value
* @param document
* @param explicitProof
* @returns {Array}
*/
static getTrimmedProofAndValue(document, explicitProof) {
const proof = explicitProof || document.proof;
if (proof.type !== this.proofType[0]) {
throw new Error(
`Invalid \`proof.type\`, expected ${this.proofType[0]}, received ${proof.type}`,
);
}
// `jws`,`signatureValue`,`proofValue` must not be included in the proof
const trimmedProof = {
'@context': document['@context'] || SECURITY_CONTEXT_URL,
...proof,
};
const proofVal = trimmedProof.proofValue;
delete trimmedProof.jws;
delete trimmedProof.signatureValue;
delete trimmedProof.proofValue;
return [trimmedProof, proofVal];
}
/**
* Extract schema from the document and load the schema from its id/reference if needed.
* @param document
* @param documentLoader
* @returns {Promise<Array>}
*/
static async extractSchemaForSigning(document, documentLoader) {
let credSchema;
// Should be false for legacy cases or when the schema is generated by CredentialBuilder
let wasExactSchema = true;
if (document.credentialSchema && document.credentialSchema.id) {
/**
* Fetch a schema given a schema id. Currenly only fetching it from Dock blockchain
* @param schemaId
* @returns {Promise<Object>}
*/
// eslint-disable-next-line no-inner-declarations
async function getSchema(schemaId) {
const { document: schema } = await documentLoader(schemaId);
// schema[0] is the schema/blob id if stored on chain
if (schemaId.startsWith('blob:dock:')) {
return schema[1];
}
return schema;
}
// If we already have a schema to use, add that first and then generate relaxed values later on
credSchema = await CredentialSchema.fromJSONWithPotentiallyExternalSchema({
// Passing all the default parsing options. Ideally `document.credentialSchema` should contain these
parsingOptions: DefaultSchemaParsingOpts,
...document.credentialSchema,
}, getSchema);
} else {
credSchema = this.extractSchemaWhenIdNotSet(document);
wasExactSchema = false;
}
return [credSchema, wasExactSchema];
}
static extractSchema(document) {
let credSchema;
// Should be false for legacy cases
let wasExactSchema = true;
if (document.credentialSchema && document.credentialSchema.id) {
// If we already have a schema to use, add that first and then generate relaxed values later on
credSchema = CredentialSchema.fromJSON({
// Passing all the default parsing options. Ideally `document.credentialSchema` should contain these
parsingOptions: DefaultSchemaParsingOpts,
...document.credentialSchema,
});
} else {
credSchema = this.extractSchemaWhenIdNotSet(document);
wasExactSchema = false;
}
return [credSchema, wasExactSchema];
}
static extractSchemaWhenIdNotSet(document) {
let credSchema;
if (document.credentialSchema) {
// schema object exists but no ID means the SDK is signalling for the suite to generate a schema
credSchema = new CredentialSchema(CredentialSchema.essential());
} else {
// Else, no schema was found so just use the essentials and v0.0.1 schema version
// NOTE: version is important here and MUST be 0.0.1 otherwise it will invalidate BBS+ credentials
// that were issued before a change. This is required because the version value is not known in credentials
// where no credentialSchema object is defined
credSchema = new CredentialSchema(
CredentialSchema.essential(),
// Passing old parsing options and version
{
useDefaults: false,
defaultMinimumInteger: -((2 ** 32) - 1),
defaultDecimalPlaces: 0,
},
false,
{ version: '0.0.1' },
);
}
return credSchema;
}
/**
* @param document {object} to be signed.
* @param proof {object}
* @param documentLoader {function}
*/
static 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 result = await jsonld.frame(
verificationMethod,
{
'@context': SECURITY_CONTEXT_URL,
'@embed': '@always',
id: verificationMethod,
},
{
documentLoader,
compactToRelative: false,
expandContext: SECURITY_CONTEXT_URL,
},
);
if (!result) {
throw new Error(`Verification method ${verificationMethod} not found.`);
}
// ensure verification method has not been revoked
if (result.revoked !== undefined) {
throw new Error('The verification method has been revoked.');
}
return result;
}
/**
* @param document {object} to be signed.
* @param proof {object}
* @param documentLoader {function}
*/
async getVerificationMethod({ proof, documentLoader }) {
return this.constructor.getVerificationMethod({
proof,
documentLoader,
});
}
/**
* Generate object with `sign` method
* @param keypair
* @param verificationMethod
* @returns {object}
*/
static signerFactory(keypair, verificationMethod) {
const { KeyPair } = this;
const paramGetter = this.paramGenerator;
return {
id: verificationMethod,
async sign({ data }) {
if (!keypair || !keypair.privateKeyBuffer) {
throw new Error('No private key to sign with.');
}
const msgCount = data.length;
const sigParams = paramGetter(
msgCount,
KeyPair.defaultLabelBytes,
);
const sk = KeyPair.adaptKey(
new KeyPair.SecretKey(u8aToU8a(keypair.privateKeyBuffer)),
data.length,
);
const signature = KeyPair.Signature.generate(data, sk, sigParams);
return signature.value;
},
};
}
static get paramGenerator() {
return this.KeyPair.SignatureParams.getSigParamsOfRequiredSize;
}
ensureSuiteContext() {
// no-op
}
},
);