diff --git a/.gitignore b/.gitignore index cbe4d09..c3629d7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ node_modules/ package-lock.json +.idea diff --git a/README.md b/README.md index bb513e1..8478e67 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,12 @@ On by default: * `eddsa-rdfc-2022` - https://www.w3.org/TR/vc-di-eddsa/ * `ecdsa-sd-2023` - https://www.w3.org/TR/vc-di-ecdsa/ * `bbs-2023` - https://www.w3.org/TR/vc-di-bbs/ -* `vc-jwt` - https://w3c.github.io/vc-jose-cose/ +* `jose` - https://w3c.github.io/vc-jose-cose/#with-jose Optional: * `Ed25519Signature2020` - https://www.w3.org/TR/vc-di-eddsa/#the-ed25519signature2020-suite +* `sd-jwt` - https://www.w3.org/TR/vc-jose-cose/#with-sd-jwt +* `cose` - https://www.w3.org/TR/vc-jose-cose/#securing-with-cose ```html
Only Certain Tabs Example
       tabs are displayed.

+        data-vc-tabs="jose">
 {
   // set the context, which establishes the special terms we will be using
   // such as 'issuer' and 'alumniOf'.
@@ -129,47 +129,47 @@ 

File Hash Examples

  • raw (`openssl dgst -sha256`): + data-hash-format="openssl dgst -sha256">
  • digestSRI (sha2-256 base64pad): + data-hash-format="sri sha2-256">
  • digestSRI (sha2-384 base64pad): + data-hash-format="sri sha2-384">
  • digestMultibase (sha2-256 base16): + data-hash-format="multihash sha2-256 base16">
  • digestMultibase (sha2-256 base58btc): + data-hash-format="multihash sha2-256 base58btc">
  • digestMultibase (sha2-256 base64-url-nopad): + data-hash-format="multihash sha2-256">
  • digestMultibase (sha2-384 base64-url-nopad): + data-hash-format="multihash sha2-384">
  • digestMultibase (sha3-256 base64-url-nopad): + data-hash-format="multihash sha3-256">
  • digestMultibase (sha3-384 base64-url-nopad): + data-hash-format="multihash sha3-384">
  • diff --git a/index.js b/index.js index 746faa3..d75b912 100644 --- a/index.js +++ b/index.js @@ -1,40 +1,30 @@ -import * as bbs2023Cryptosuite from '@digitalbazaar/bbs-2023-cryptosuite'; -import * as Bls12381Multikey from '@digitalbazaar/bls12-381-multikey'; import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; -import * as ecdsaRdfc2019Cryptosuite from - '@digitalbazaar/ecdsa-rdfc-2019-cryptosuite'; import * as ecdsaSd2023Cryptosuite from '@digitalbazaar/ecdsa-sd-2023-cryptosuite'; import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey'; import * as examples1Context from '@digitalbazaar/credentials-examples-context'; -import * as jose from 'jose'; -import * as mfHasher from 'multiformats/hashes/hasher'; import * as odrlContext from '@digitalbazaar/odrl-context'; -import {base64pad, base64url} from 'multiformats/bases/base64'; import {defaultDocumentLoader, issue} from '@digitalbazaar/vc'; import {extendContextLoader, purposes} from 'jsonld-signatures'; -import {sha3_256, sha3_384} from '@noble/hashes/sha3'; -import {base16} from 'multiformats/bases/base16'; -import {base58btc} from 'multiformats/bases/base58'; +import {getCoseHtml, getJoseHtml, getSdJwtHtml} from './src/html'; import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; import ed25519Context from 'ed25519-signature-2020-context'; import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; -import {Ed25519VerificationKey2020} from - '@digitalbazaar/ed25519-verification-key-2020'; -import {cryptosuite as eddsaRdfc2022CryptoSuite} from - '@digitalbazaar/eddsa-rdfc-2022-cryptosuite'; +import { + Ed25519VerificationKey2020, +} from '@digitalbazaar/ed25519-verification-key-2020'; +import { + cryptosuite as eddsaRdfc2022CryptoSuite, +} from '@digitalbazaar/eddsa-rdfc-2022-cryptosuite'; import examples2Context from './contexts/credentials/examples/v2'; -import {sha256} from '@noble/hashes/sha256'; -import {sha384} from '@noble/hashes/sha512'; +import {getCoseExample} from './src/cose'; +import {getJoseExample} from './src/jose'; +import {getSdJwtExample} from './src/sd-jwt'; +import {privateKey} from './src/common'; // default types -const TAB_TYPES = [ - 'ecdsa-rdfc-2019', - 'eddsa-rdfc-2022', - 'ecdsa-sd-2023', - 'bbs-2023', - 'vc-jwt' -]; +const TAB_TYPES = ['ecdsa-sd-2023', 'eddsa-rdfc-2022', + 'jose', 'sd-jwt', 'cose']; // additional types: Ed25519Signature2020 // purposes used below @@ -56,136 +46,12 @@ const documentLoader = extendContextLoader(async function documentLoader(url) { return { contextUrl: null, documentUrl: url, - document: context + document: context, }; } return defaultDocumentLoader(url); }); -async function createBBSExampleProof() { - const key = await Bls12381Multikey.generateBbsKeyPair({ - algorithm: Bls12381Multikey.ALGORITHMS.BBS_BLS12381_SHA256 - }); - - const proof = new DataIntegrityProof({ - signer: key.signer(), - cryptosuite: bbs2023Cryptosuite.createSignCryptosuite({ - mandatoryPointers: ['/issuer'] - }) - }); - - return { - proof, - key, - label: 'bbs' - }; -} - -async function createEcdsaRdfc2019ExampleProof() { - const key = await EcdsaMultikey - .generate({curve: 'P-256'}); - - // ecdsa-rdfc-2019 - const {cryptosuite: rdfcCryptosuite} = ecdsaRdfc2019Cryptosuite; - const proof = new DataIntegrityProof({ - signer: key.signer(), - cryptosuite: rdfcCryptosuite - }); - - return { - proof, - key, - label: 'ecdsa' - }; -} - -async function createEcdsaSd2023ExampleProof() { - const key = await EcdsaMultikey - .generate({curve: 'P-256'}); - - // ecdsa-sd-2023 - const {createSignCryptosuite} = ecdsaSd2023Cryptosuite; - const proof = new DataIntegrityProof({ - signer: key.signer(), - cryptosuite: createSignCryptosuite({ - mandatoryPointers: ['/issuer'] - }) - }); - - return { - proof, - key, - label: 'ecdsa-sd' - }; -} - -async function createEddsaRdfc2022ExampleProof() { - // Ed25519Signature2020 - const keyPairEd25519VerificationKey2020 = await Ed25519VerificationKey2020 - .generate(); - - const key = await Ed25519Multikey - .from(keyPairEd25519VerificationKey2020); - - // eddsa-rdfc-2022 - const proof = new DataIntegrityProof({ - signer: key.signer(), - cryptosuite: eddsaRdfc2022CryptoSuite - }); - - return { - proof, - key, - label: 'eddsa' - }; -} - -// convert an XML Schema v1.` Datetime value to a UNIX timestamp -function xmlDateTimeToUnixTimestamp(xmlDateTime) { - if(!xmlDateTime) { - return undefined; - } - - return Date.parse(xmlDateTime) / 1000; -} - -// transform the input credential to a JWT -async function transformToJwt({credential, kid, jwk}) { - const header = {alg: 'ES256', typ: 'JWT', kid}; - const payload = { - vc: credential - }; - if(credential.expirationDate) { - payload.exp = xmlDateTimeToUnixTimestamp(credential.expirationDate); - } - if(credential.issuer) { - payload.iss = credential.issuer; - } - if(credential.issuanceDate) { - payload.nbf = xmlDateTimeToUnixTimestamp(credential.issuanceDate); - } - if(credential.id) { - payload.jti = credential.id; - } - if(credential.credentialSubject.id) { - payload.sub = credential.credentialSubject.id; - } - - // create the JWT description - let description = '---------------- JWT header ---------------\n' + - JSON.stringify(header, null, 2); - description += '\n\n--------------- JWT payload ---------------\n' + - '// NOTE: The example below uses a valid VC-JWT serialization\n' + - '// that duplicates the iss, nbf, jti, and sub fields in the\n' + - '// Verifiable Credential (vc) field.\n\n' + - JSON.stringify(payload, null, 2); - const jwt = await new jose.SignJWT(payload) - .setProtectedHeader(header) - .sign(jwk.privateKey); - - return description + '\n\n--------------- JWT ---------------\n\n' + jwt; -} - async function attachProof({credential, suite}) { const credentialCopy = JSON.parse(JSON.stringify(credential)); const options = {credential: credentialCopy, suite, documentLoader}; @@ -241,6 +107,7 @@ function addVcExampleStyles() { cursor: pointer; transition: all 0.3s; } + .vc-tab:hover label { border-left-color: #333; border-top-color: #333; @@ -248,6 +115,156 @@ function addVcExampleStyles() { color: #333; } + .vc-jose-cose-tabbed, .vc-jose-cose-tabbed-jwt, .vc-jose-cose-tabbed-sd-jwt, .vc-jose-cose-tabbed-cose, + .sd-jwt-tabbed { + overflow-x: hidden; + margin: 0 0; + } + + .vc-jose-cose-tabbed h1, .vc-jose-cose-jwt-tabbed h1, .vc-jose-cose-sd-jwt-tabbed h1, .vc-jose-cose-cose-tabbed h1, + .sd-jwt-tabbed h1 { + font-size: 1em; + margin: 0 0; + } + + .vc-jose-cose-tabbed [type="radio"], .vc-jose-cose-tabbed-jwt [type="radio"], .vc-jose-cose-tabbed-sd-jwt [type="radio"], .vc-jose-cose-tabbed-cose [type="radio"], + .sd-jwt-tabbed [type="radio"] { + display: none; + } + + .vc-jose-cose-tabs, .vc-jose-cose-jwt-tabs, .vc-jose-cose-sd-jwt-tabs, .vc-jose-cose-cose-tabs, + .sd-jwt-tabs { + display: flex; + align-items: stretch; + list-style: none; + padding: 0; + border-bottom: 1px solid #ccc; + } + + li.vc-jose-cose-tab, li.vc-jose-cose-jwt-tab, li.vc-jose-cose-sd-jwt-tab, li.vc-jose-cose-cose-tab, + li.sd-jwt-tab { + margin: 0 0; + margin-left: 8px; + } + + .vc-jose-cose-tab>label, .vc-jose-cose-jwt-tab>label, .vc-jose-cose-sd-jwt-tab>label, .vc-jose-cose-cose-tab>label, + .sd-jwt-tab>label { + display: block; + margin-bottom: -1px; + padding: .4em .5em; + border: 1px solid #ccc; + border-top-right-radius: .4em; + border-top-left-radius: .4em; + background: #eee; + color: #666; + cursor: pointer; + transition: all 0.3s; + } + + .vc-jose-cose-tab:hover label, .vc-jose-cose-jwt-tab:hover label, .vc-jose-cose-sd-jwt-tab:hover label, .vc-jose-cose-cose-tab:hover label, + .sd-jwt-tab:hover label { + border-left-color: #333; + border-top-color: #333; + border-right-color: #333; + color: #333; + } + + .vc-jose-cose-tab-content, + .sd-jwt-tab-content { + display: none; + } + + .vc-jose-cose-tabbed [type="radio"]:nth-of-type(1):checked~.vc-jose-cose-tabs .vc-jose-cose-tab:nth-of-type(1) label, + .vc-jose-cose-tabbed [type="radio"]:nth-of-type(2):checked~.vc-jose-cose-tabs .vc-jose-cose-tab:nth-of-type(2) label, + .vc-jose-cose-tabbed [type="radio"]:nth-of-type(3):checked~.vc-jose-cose-tabs .vc-jose-cose-tab:nth-of-type(3) label, + .sd-jwt-tabbed [type="radio"]:nth-of-type(1):checked~.sd-jwt-tabs .sd-jwt-tab:nth-of-type(1) label, + .sd-jwt-tabbed [type="radio"]:nth-of-type(2):checked~.sd-jwt-tabs .sd-jwt-tab:nth-of-type(2) label, + .sd-jwt-tabbed [type="radio"]:nth-of-type(3):checked~.sd-jwt-tabs .sd-jwt-tab:nth-of-type(3) label { + border-bottom-color: #fff; + background: #fff; + color: #222; + } + + .vc-jose-cose-tabbed [type="radio"]:nth-of-type(1):checked~.vc-jose-cose-tab-content:nth-of-type(1), + .vc-jose-cose-tabbed [type="radio"]:nth-of-type(2):checked~.vc-jose-cose-tab-content:nth-of-type(2), + .vc-jose-cose-tabbed [type="radio"]:nth-of-type(3):checked~.vc-jose-cose-tab-content:nth-of-type(3), + .sd-jwt-tabbed [type="radio"]:nth-of-type(1):checked~.sd-jwt-tab-content:nth-of-type(1), + .sd-jwt-tabbed [type="radio"]:nth-of-type(2):checked~.sd-jwt-tab-content:nth-of-type(2), + .sd-jwt-tabbed [type="radio"]:nth-of-type(3):checked~.sd-jwt-tab-content:nth-of-type(3) { + display: block; + } + + .sd-jwt-header, .jwt-header, .vc-jose-cose-jwt .header, .vc-jose-cose-sd-jwt .header, .vc-jose-cose-cose .header { + color: red; + } + .sd-jwt-payload, .jwt-payload, .vc-jose-cose-jwt .payload, .vc-jose-cose-sd-jwt .payload, .vc-jose-cose-cose .payload { + color: green; + } + + .sd-jwt-signature, .jwt-signature, .vc-jose-cose-jwt .signature, .vc-jose-cose-sd-jwt .signature, .vc-jose-cose-cose .signature { + color: blue; + } + + .sd-jwt-disclosure, .vc-jose-cose-jwt .disclosure, .vc-jose-cose-sd-jwt .disclosure, .vc-jose-cose-cose .disclosure { + color: purple; + } + + .sd-jwt-compact, .jwt-compact, .vc-jose-cose-jwt .compact, .vc-jose-cose-sd-jwt .compact, .vc-jose-cose-cose .compact { + background-color: rgba(0,0,0,.03); + } + + .cose-text, .jose-text, .vc-jose-cose-jwt .text, .vc-jose-cose-sd-jwt .text, .vc-jose-cose-cose .text { + font-family: monospace; + color: green; + } + + .disclosure { + margin: 10px 0; + font-size: 12px; + line-height: 1.6; + padding: 5px; + } + + .disclosure h3 { + margin: 0; + font-size: 14px; + padding-left: 5px; + } + + .disclosure .claim-name { + color: #333; + } + + .disclosure .hash, + .disclosure .disclosure-value, + .disclosure .contents { + color: #555; + word-wrap: break-word; + display: inline; + } + + .disclosure p { + margin: 0; + padding-left: 5px; + } + + .disclosure pre { + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + padding-left: 5px; + line-height: 1.6; + display: inline-block; + } + + .header-value { + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + padding-left: 5px; + line-height: 1.6; + font-size: 12px; + } .vc-tab-content { display: none; } @@ -270,128 +287,30 @@ function addContext(url, context) { } async function createVcExamples() { - // process all 'vc-hash' entries - const sha2256Hasher = mfHasher.from({ - name: 'sha2-256', - code: 0x12, - encode: input => sha256(input) - }); - const sha2384Hasher = mfHasher.from({ - name: 'sha2-384', - code: 0x20, - encode: input => sha384(input) - }); - const sha3256Hasher = mfHasher.from({ - name: 'sha3-256', - code: 0x16, - encode: input => sha3_256(input) - }); - const sha3384Hasher = mfHasher.from({ - name: 'sha3-384', - code: 0x15, - encode: input => sha3_384(input) + // generate base keypair and signature suites + // ecdsa-sd-2023 + const keyPairEcdsaMultikeyKeyPair = await EcdsaMultikey + .generate({curve: 'P-256'}); + const {createSignCryptosuite} = ecdsaSd2023Cryptosuite; + const suiteEcdsaMultiKey = new DataIntegrityProof({ + signer: keyPairEcdsaMultikeyKeyPair.signer(), + cryptosuite: createSignCryptosuite({ + mandatoryPointers: ['/issuer'], + }), }); - - const vcHashEntries = document.querySelectorAll('.vc-hash'); - for(const hashEntry of vcHashEntries) { - - // get the hash requirements - const hashUrl = hashEntry.dataset?.hashUrl || 'INVALID_URL'; - const hashFormat = hashEntry.dataset?.hashFormat?.split(/(\s+)/) || []; - let encodedHash = null; - - // select the base encoder (default: base64-url with no padding) - let baseEncoder; - if(hashFormat.includes('sri')) { - baseEncoder = base64pad; - } else if(hashFormat.includes('base16')) { - baseEncoder = base16; - } else if(hashFormat.includes('base58btc')) { - baseEncoder = base58btc; - } else { - baseEncoder = base64url; - } - - // retrieve the file and generate the hash - try { - const response = await fetch(hashUrl); - - // ensure retrieval succeeded - if(response.status !== 200) { - throw new Error('Failed to retrieve ' + hashUrl); - } - const hashData = new Uint8Array(await response.arrayBuffer()); - - // determine the hash algorithm to use and produce the output accordingly - if(hashFormat.includes('openssl') && hashFormat.includes('-sha256')) { - const mfHash = await sha2256Hasher.digest(hashData); - encodedHash = Array.prototype.map.call(mfHash.digest, byte => { - return ('0' + (byte & 0xFF).toString(16)).slice(-2); - }).join(''); - } else if(hashFormat.includes('sri')) { - if(hashFormat.includes('sha2-256')) { - const mfHash = await sha2256Hasher.digest(hashData); - encodedHash = 'sha256-' + baseEncoder.encode(mfHash.digest); - } else if(hashFormat.includes('sha2-384')) { - const mfHash = await sha2384Hasher.digest(hashData); - encodedHash = 'sha384-' + baseEncoder.encode(mfHash.digest); - } - } else if(hashFormat.includes('multihash')) { - if(hashFormat.includes('sha2-256')) { - const mfHash = await sha2256Hasher.digest(hashData).bytes; - encodedHash = baseEncoder.encode(mfHash); - } else if(hashFormat.includes('sha2-384')) { - const mfHash = await sha2384Hasher.digest(hashData).bytes; - encodedHash = baseEncoder.encode(mfHash); - } else if(hashFormat.includes('sha3-256')) { - const mfHash = await sha3256Hasher.digest(hashData).bytes; - encodedHash = baseEncoder.encode(mfHash); - } else if(hashFormat.includes('sha3-384')) { - const mfHash = await sha3384Hasher.digest(hashData).bytes; - encodedHash = baseEncoder.encode(mfHash); - } - } - - // set the encodedHash value - hashEntry.innerText = encodedHash || 'Unsupported hash format: \'' + - hashEntry.dataset?.hashFormat + '\''; - } catch(e) { - console.error('respec-vc error: Failed to create cryptographic hash.', - e, hashEntry); - hashEntry.innerText = 'Error generating cryptographic hash for ' + - hashUrl; - continue; - } - } - - // process all 'vc' entries - const exampleProofs = []; - - // ecdsa-rdfc-2019 - const ecdsaRdfc2019 = await createEcdsaRdfc2019ExampleProof(); - exampleProofs.push(ecdsaRdfc2019); - // Ed25519Signature2020 const keyPairEd25519VerificationKey2020 = await Ed25519VerificationKey2020 .generate(); + const keyPairEd25519Multikey = await Ed25519Multikey + .from(keyPairEd25519VerificationKey2020); const suiteEd25519Signature2020 = new Ed25519Signature2020({ - key: keyPairEd25519VerificationKey2020 + key: keyPairEd25519VerificationKey2020, }); - // eddsa-rdfc-2022 - const eddsaRdfc2022 = await createEddsaRdfc2022ExampleProof(); - exampleProofs.push(eddsaRdfc2022); - - // ecdsa-sd-2023 - const ecdsaSd2023 = await createEcdsaSd2023ExampleProof(); - exampleProofs.push(ecdsaSd2023); - - // bbs-2023 - const bbs2023 = await createBBSExampleProof(); - exampleProofs.push(bbs2023); - - // vc-jwt - const jwk = await jose.generateKeyPair('ES256'); + const suiteEd25519Multikey = new DataIntegrityProof({ + signer: keyPairEd25519Multikey.signer(), + cryptosuite: eddsaRdfc2022CryptoSuite, + }); // add styles for examples addVcExampleStyles(); @@ -440,10 +359,9 @@ async function createVcExamples() { * * @param {string} suffix - One of the TAB_TYPES values (or `unsigned`). * @param {string} labelText - Human readable label name. - * @param {string} tabText - Text to display on the tab. * @param {contentCallback} callback - Function which returns HTML. */ - function addTab(suffix, labelText, tabText, callback) { + function addTab(suffix, labelText, callback) { const button = document.createElement('input'); button.setAttribute('type', 'radio'); button.setAttribute('id', `vc-tab${vcProofExampleIndex}${suffix}`); @@ -457,16 +375,8 @@ async function createVcExamples() { const label = document.createElement('li'); label.setAttribute('class', 'vc-tab'); - - const tabLabel = document.createElement('label'); - tabLabel.setAttribute('for', button.getAttribute('id')); - - const abbr = document.createElement('abbr'); - abbr.setAttribute('title', labelText); - abbr.innerText = tabText; - - tabLabel.appendChild(abbr); - label.appendChild(tabLabel); + label.innerHTML = + ``; tabLabels.appendChild(label); const content = document.createElement('div'); @@ -483,25 +393,23 @@ async function createVcExamples() { } } - /** - * Add a Data Integrity based proof example tab. - * + /* + * Add a Data Integrity based proof example tab * @global string verificationMethod - * @param {object} suite - Suite object. - * @param {string} tabText - Text to display on the tab. - * @param {string | undefined} key - Optional key to use for the proof. + * @param object suite */ - async function addProofTab(suite, tabText, key) { + async function addProofTab(suite) { let verifiableCredentialProof; const label = suite?.cryptosuite || suite.type; - if(key) { - suite.verificationMethod = 'did:key:' + key.publicKeyMultibase; + if(label === 'ecdsa-sd-2023') { + suite.verificationMethod = 'did:key:' + keyPairEcdsaMultikeyKeyPair + .publicKeyMultibase; } else { suite.verificationMethod = verificationMethod; } - addTab(label, `Secured with Data Integrity - ${label}`, tabText, async () => { + addTab(label, `Secured with Data Integrity (${label})`, async () => { // attach the proof try { verifiableCredentialProof = await attachProof({credential, suite}); @@ -515,44 +423,39 @@ async function createVcExamples() { }); } - function hasTab(identifier) { - return tabTypes.indexOf(identifier) > -1; + // set up the unsigned button + addTab('unsigned', 'Verifiable Credential', () => example.outerHTML); + + if(tabTypes.indexOf(suiteEd25519Signature2020.type) > -1) { + await addProofTab(suiteEd25519Signature2020); + } + if(tabTypes.indexOf(suiteEd25519Multikey.cryptosuite) > -1) { + await addProofTab(suiteEd25519Multikey); + } + if(tabTypes.indexOf(suiteEcdsaMultiKey.cryptosuite) > -1) { + await addProofTab(suiteEcdsaMultiKey); } - // set up the unsigned button - addTab( - 'unsigned', - 'Unsecured credential', - 'Credential', - () => example.outerHTML - ); - - for(const {proof, key, label} of exampleProofs) { - if(hasTab(proof.cryptosuite)) { - await addProofTab(proof, label, key); - } + if(tabTypes.indexOf('jose') > -1) { + addTab('jose', 'Secured with JOSE', async () => { + const joseExample = await getJoseExample(privateKey, credential); + return getJoseHtml({joseExample}); + }); } - if(hasTab(suiteEd25519Signature2020.type)) { - await addProofTab(suiteEd25519Signature2020, 'Ed25519Signature2020'); + if(tabTypes.indexOf('sd-jwt') > -1) { + addTab('sd-jwt', 'Secured with SD-JWT', async () => { + // eslint-disable-next-line max-len + const sdJwtExample = await getSdJwtExample(vcProofExampleIndex, privateKey, credential); + return getSdJwtHtml({sdJwtExample}); + }); } - if(hasTab('vc-jwt')) { - // set up the signed JWT button - addTab('vc-jwt', 'Secured with VC-JWT', 'vc-jwt', - async () => { - // convert to a JWT - let verifiableCredentialJwt; - try { - verifiableCredentialJwt = await transformToJwt({ - credential, kid: verificationMethod, jwk}); - return `
    ${verifiableCredentialJwt.match(/.{1,75}/g).join('\n')}
    `; - } catch(e) { - console.error( - 'respec-vc error: Failed to convert Credential to JWT.', - e, example.innerText); - } - }); + if(tabTypes.indexOf('cose') > -1) { + addTab('cose', 'Secured with COSE', async () => { + const coseExample = await getCoseExample(privateKey, credential); + return getCoseHtml({coseExample}); + }); } // append the tabbed content @@ -569,5 +472,5 @@ async function createVcExamples() { // setup exports on window window.respecVc = { addContext, - createVcExamples + createVcExamples, }; diff --git a/package.json b/package.json index 8058379..969e753 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,11 @@ }, "homepage": "https://github.com/w3c/respec-vc#readme", "dependencies": { + "@digitalbazaar/bbs-2023-cryptosuite": "^1.2.0", + "@digitalbazaar/bls12-381-multikey": "^1.3.0", "@digitalbazaar/credentials-examples-context": "digitalbazaar/credentials-examples-context#main", "@digitalbazaar/data-integrity": "^2.1.0", + "@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": "^1.0.1", "@digitalbazaar/ed25519-multikey": "^1.1.0", "@digitalbazaar/ed25519-signature-2020": "^5.2.0", "@digitalbazaar/ed25519-verification-key-2020": "^4.1.0", @@ -27,9 +30,14 @@ "@digitalbazaar/odrl-context": "digitalbazaar/odrl-context#main", "@digitalbazaar/vc": "digitalbazaar/vc#update-vc-2.0", "@noble/hashes": "^1.4.0", + "@transmute/cose": "^0.1.1", + "@transmute/edn": "^0.0.5", + "@transmute/verifiable-credentials": "^0.2.0", "ed25519-signature-2020-context": "^1.1.0", "jose": "^5.2.4", - "multiformats": "^13.1.1" + "multiformats": "^13.1.1", + "url": "^0.11.3", + "yaml": "^2.3.2" }, "devDependencies": { "@digitalbazaar/bbs-2023-cryptosuite": "^1.2.0", @@ -37,11 +45,16 @@ "@digitalbazaar/ecdsa-multikey": "^1.7.0", "@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": "^1.0.1", "@digitalbazaar/ecdsa-sd-2023-cryptosuite": "^3.2.1", + "buffer": "^6.0.3", + "crypto-browserify": "^3.12.0", "eslint": "^8.57.0", "eslint-config-digitalbazaar": "^5.2.0", "eslint-plugin-jsdoc": "^48.2.3", "eslint-plugin-unicorn": "^52.0.0", "http-server": "^14.1.1", + "process": "^0.11.10", + "stream-browserify": "^3.0.0", + "vm-browserify": "^1.1.2", "webpack": "^5.91.0", "webpack-cli": "^5.1.4" } diff --git a/src/common.js b/src/common.js new file mode 100644 index 0000000..26e25ba --- /dev/null +++ b/src/common.js @@ -0,0 +1,9 @@ +export const privateKey = { + kid: 'ExHkBMW9fmbkvV266mRpuP2sUY_N_EWIN1lapUzO8ro', + alg: 'ES384', + kty: 'EC', + crv: 'P-384', + x: '8XkmqX3pxcYduNID8cK4mYA531PV7szaGh-yk-XIM9OQKZSO94y3xe67nyFvt0QP', + y: 'kWjcg1gBVM752eQzXlMfHwEOzuUB4zCgi2z6X1p03ljAcp6b_FEINR-eONp00gX0', + d: 'SC8W4oMb4vZv_OAftZ19tlTJ6Uk83qpT1pIBjAcVUWNtaE1febiIhgpQSZTZaJl_', +}; diff --git a/src/cose.js b/src/cose.js new file mode 100644 index 0000000..8df87f3 --- /dev/null +++ b/src/cose.js @@ -0,0 +1,107 @@ +import * as cose from '@transmute/cose'; +import * as edn from '@transmute/edn'; +import {holder, issuer} from '@transmute/verifiable-credentials'; + +function buf2hex(buffer) { // buffer is an ArrayBuffer + return [...new Uint8Array(buffer)] + .map(x => x.toString(16).padStart(2, '0')) + .join(''); +} + +const getCredential = async ( + privateKey, + byteSigner, + messageType, + messageJson, +) => { + return issuer({ + alg: privateKey.alg, + type: messageType, + signer: byteSigner, + }).issue({ + claimset: new TextEncoder().encode(JSON.stringify(messageJson, null, 2)), + }); +}; + +const getPresentation = async ( + privateKey, + byteSigner, + messageType, + message, +) => { + const disclosures = (message.verifiableCredential || []).map(enveloped => { + const {id} = enveloped; + const type = id.includes('base64url') ? + id.split(';base64url,')[0].replace('data:', '') : + id.split(';')[0].replace('data:', ''); + const content = id.includes('base64url') ? + new TextEncoder().encode(id.split('base64url,').pop()) : + new TextEncoder().encode(id.split(';').pop()); + return { + type, + credential: content, + }; + }); + return holder({ + alg: privateKey.alg, + type: messageType, + }).issue({ + signer: byteSigner, + presentation: message, + disclosures, + }); +}; + +const getBinaryMessage = async (privateKey, messageType, messageJson) => { + const signer = cose.detached.signer({ + remote: cose.crypto.signer({ + secretKeyJwk: privateKey, + }), + }); + const byteSigner = { + sign: async payload => { + return signer.sign({ + protectedHeader: new Map([[1, -35]]), + unprotectedHeader: new Map(), + payload, + }); + }, + }; + switch(messageType) { + case 'application/vc+ld+json+cose': { + return getCredential(privateKey, byteSigner, messageType, messageJson); + } + case 'application/vp+ld+json+cose': { + return getPresentation(privateKey, byteSigner, messageType, messageJson); + } + default: { + throw new Error('Unknown message type'); + } + } +}; + +export const getCoseExample = async (privateKey, messageJson) => { + const type = Array.isArray(messageJson.type) ? + messageJson.type : [messageJson.type]; + const messageType = type.includes('VerifiableCredential') ? + 'application/vc+ld+json+cose' : 'application/vp+ld+json+cose'; + const message = await getBinaryMessage(privateKey, messageType, messageJson); + const messageHex = buf2hex(message); + const messageBuffer = Buffer.from(messageHex, 'hex'); + const diagnostic = + await edn.render(messageBuffer, 'application/cbor-diagnostic'); + return ` +

    ${messageType.replace('+cose', '')}

    +
    +${JSON.stringify(messageJson, null, 2)}
    +
    +

    application/cbor-diagnostic

    +
    +
    ${diagnostic.trim()}
    +
    +

    ${messageType} (detached payload)

    +
    +${messageHex} +
    + `.trim(); +}; diff --git a/src/fast-uri-polyfill.js b/src/fast-uri-polyfill.js new file mode 100644 index 0000000..19db92c --- /dev/null +++ b/src/fast-uri-polyfill.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line unicorn/prefer-module +module.exports = function(source) { + return source.replace(/require\('node:url'\)/g, 'require(\'url\')'); +}; diff --git a/src/html.js b/src/html.js new file mode 100644 index 0000000..010aaaa --- /dev/null +++ b/src/html.js @@ -0,0 +1,25 @@ +export const getJoseHtml = ({joseExample}) => { + return ` +
    +
    +${joseExample} +
    +
    `; +}; + +export const getSdJwtHtml = ({sdJwtExample}) => { + return ` +
    +${sdJwtExample} +
    +`.trim(); +}; + +export const getCoseHtml = ({coseExample}) => { + return ` +
    +
    +${coseExample} +
    +
    `; +}; diff --git a/src/jose.js b/src/jose.js new file mode 100644 index 0000000..f2dc0a0 --- /dev/null +++ b/src/jose.js @@ -0,0 +1,110 @@ +import { + holder, + issuer, + key, + text, +} from '@transmute/verifiable-credentials'; + +import * as jose from 'jose'; + +const getCredential = async ( + privateKey, + byteSigner, + messageType, + messageJson +) => { + return issuer({ + alg: privateKey.alg, + type: messageType, + signer: byteSigner, + }).issue({ + claimset: new TextEncoder().encode(JSON.stringify(messageJson, null, 2)), + }); +}; + +const getPresentation = async ( + privateKey, + byteSigner, + messageType, + message +) => { + const disclosures = (message.verifiableCredential || []).map(enveloped => { + const {id} = enveloped; + const type = id.includes('base64url') ? id.split(';base64url,')[0]. + replace('data:', '') : id.split(';')[0].replace('data:', ''); + const content = id.includes('base64url') ? + new TextEncoder().encode(id.split('base64url,').pop()) : + new TextEncoder().encode(id.split(';').pop()); + return { + type, + credential: content, + }; + }); + return holder({ + alg: privateKey.alg, + type: messageType, + }).issue({ + signer: byteSigner, + presentation: message, + disclosures, + }); +}; + +const getJoseHtml = token => { + const [header, payload, signature] = token.split('.'); + return ` +
    +${header} +.${payload} +.${signature} +
    `.trim(); +}; + +const getBinaryMessage = async (privateKey, messageType, messageJson) => { + const byteSigner = { + sign: async bytes => { + const jws = await new jose.CompactSign(bytes) + .setProtectedHeader({kid: privateKey.kid, alg: privateKey.alg}) + .sign(await key.importKeyLike({ + type: 'application/jwk+json', + content: new TextEncoder().encode(JSON.stringify(privateKey)), + })); + return text.encoder.encode(jws); + }, + }; + switch(messageType) { + case 'application/vc+ld+json+jwt': { + return getCredential(privateKey, byteSigner, messageType, messageJson); + } + case 'application/vp+ld+json+jwt': { + return getPresentation(privateKey, byteSigner, messageType, messageJson); + } + default: { + throw new Error('Unknown message type'); + } + } +}; + +export const getJoseExample = async (privateKey, messageJson) => { + const type = Array.isArray(messageJson.type) ? + messageJson.type : [messageJson.type]; + const messageType = type.includes('VerifiableCredential') ? + 'application/vc+ld+json+jwt' : 'application/vp+ld+json+jwt'; + const message = await getBinaryMessage(privateKey, messageType, messageJson); + const messageEncoded = new TextDecoder().decode(message); + const decodedHeader = jose.decodeProtectedHeader(messageEncoded); + return ` +

    Protected Headers

    +
    +${JSON.stringify(decodedHeader, null, 2)}
    +
    +

    ${messageType.replace('+jwt', '')}

    +
    +${JSON.stringify(messageJson, null, 2)}
    +
    +

    ${messageType}

    +
    +${getJoseHtml(messageEncoded)} +
    + `.trim(); +}; diff --git a/src/sd-jwt.js b/src/sd-jwt.js new file mode 100644 index 0000000..781952b --- /dev/null +++ b/src/sd-jwt.js @@ -0,0 +1,176 @@ +import * as jose from 'jose'; +import {base64url, issuer, key, text} from '@transmute/verifiable-credentials'; +import crypto from 'crypto'; +import yaml from 'yaml'; + +const calculateHash = value => { + return base64url.encode(crypto.createHash('sha256').update(value).digest()); +}; + +const customJSONStringify = obj => { + return JSON.stringify(obj, null, 2) + .replace(/\n/g, '
    ').replace(/\s/g, ' '); +}; + +const generateDisclosureHtml = (claimName, hash, disclosure, contents) => { + return ` +
    +

    Claim: ${claimName}

    +

    SHA-256 Hash: ${hash}

    +

    Disclosure(s): ${disclosure}

    +

    Contents: ${customJSONStringify(contents)}

    +
    +`; +}; + +const getSdHtml = vc => { + const [token, ...disclosure] = vc.split('~'); + const [header, payload, signature] = token.split('.'); + const disclosures = disclosure.map(d => { + return `~${d}`; + }).join(''); + return ` +
    +${header} +.${payload} +.${signature} +${disclosures} +
    `; +}; + +const getHeadersHtml = vc => { + const [token] = vc.split('~'); + const [header] = token.split('.'); + const decoded = new TextDecoder().decode(base64url.decode(header)); + const headerJson = JSON.parse(decoded); + return `
    ${customJSONStringify(headerJson)}
    `; +}; + +const getPayloadHtml = vc => { + const [token] = vc.split('~'); + const [, payload] = token.split('.'); + const decoded = new TextDecoder().decode(base64url.decode(payload)); + const payloadJson = JSON.parse(decoded); + return `
    ${customJSONStringify(payloadJson)}
    `; +}; + +const getDisclosuresHtml = async vc => { + const [, ...disclosures] = vc.split('~'); + const disclosureHtml = disclosures.map(disclosure => { + const decoded = new TextDecoder().decode(base64url.decode(disclosure)); + const decodedDisclosure = JSON.parse(decoded); + const [, ...claimPath] = decodedDisclosure; + claimPath.pop(); + const hash = calculateHash(disclosure); + return generateDisclosureHtml(claimPath, hash, disclosure, + decodedDisclosure); + }); + + return `
    ${disclosureHtml.join('\n')}
    `; +}; + +export const generateIssuerClaims = example => { + return yaml.stringify(example).replace(/id: /g, '!sd id: ') + .replace(/type:/g, '!sd type:'); +}; + +const getCredential = async ( + privateKey, + byteSigner, + messageType, + messageJson, +) => { + return issuer({ + alg: privateKey.alg, + type: messageType, + signer: byteSigner, + }).issue({ + claimset: new TextEncoder().encode(generateIssuerClaims(messageJson)), + }); +}; + +const getPresentation = async ( + privateKey, + byteSigner, + messageType, + messageJson, +) => { + const mediaType = 'application/vc+ld+json+sd-jwt'; + return getCredential(privateKey, byteSigner, mediaType, messageJson); +}; + +export const getBinaryMessage = async ( + privateKey, + messageType, + messageJson, +) => { + const byteSigner = { + sign: async bytes => { + const jws = await new jose.CompactSign(bytes) + .setProtectedHeader({kid: privateKey.kid, alg: privateKey.alg}) + .sign(await key.importKeyLike({ + type: 'application/jwk+json', + content: new TextEncoder().encode(JSON.stringify(privateKey)), + })); + return text.encoder.encode(jws); + }, + }; + switch(messageType) { + case 'application/vc+ld+json+sd-jwt': { + return getCredential(privateKey, byteSigner, messageType, messageJson); + } + case 'application/vp+ld+json+sd-jwt': { + return getPresentation(privateKey, byteSigner, messageType, messageJson); + } + default: { + throw new Error('Unknown message type'); + } + } +}; + +export const getSdJwtExample = async ( + index, + privateKey, + messageJson, + prefix = 'sd-jwt', +) => { + const type = Array.isArray(messageJson.type) ? + messageJson.type : [messageJson.type]; + const messageType = type.includes('VerifiableCredential') ? + 'application/vc+ld+json+sd-jwt' : 'application/vp+ld+json+sd-jwt'; + const binaryMessage = + await getBinaryMessage(privateKey, messageType, messageJson); + const message = new TextDecoder().decode(binaryMessage); + const encoded = getSdHtml(message); + const header = getHeadersHtml(message); + const payload = getPayloadHtml(message); + const disclosures = await getDisclosuresHtml(message); + return ` +
    + + + +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    + ${encoded} +
    +
    + ${header} + ${payload} +
    +
    + ${disclosures} +
    +
    +`; +}; diff --git a/webpack.config.js b/webpack.config.js index 440056e..22bac75 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,6 +1,35 @@ +const path = require('path'); +const webpack = require('webpack'); + module.exports = [{ mode: 'development', entry: './index.js', - plugins: [], - watch: true + plugins: [ + new webpack.ProvidePlugin({ + process: 'process/browser.js', + Buffer: ['buffer', 'Buffer'], + }), + ], + watch: true, + resolve: { + fallback: { + buffer: require.resolve("buffer/"), + crypto: require.resolve("crypto-browserify"), + stream: require.resolve("stream-browserify"), + vm: require.resolve("vm-browserify"), + url: require.resolve("url/"), + }, + }, + module: { + rules: [ + { + test: /node_modules\/fast-uri/, + use: [ + { + loader: path.resolve('src/fast-uri-polyfill.js') + } + ] + } + ] + } }];