diff --git a/lib/helpers.js b/lib/helpers.js index cd48836..ba94e3b 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,8 +1,9 @@ /*! * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. */ -import * as base58 from 'base58-universal'; -import * as base64url from 'base64url-universal'; +// import * as base58 from 'base58-universal'; +// import * as base64url from 'base64url-universal'; +import {renderToNfc, supportsNFC} from './nfcRenderer.js'; import {config} from '@bedrock/web'; import { Ed25519VerificationKey2020 @@ -19,10 +20,10 @@ const supportedSignerTypes = new Map([ ] ]); -const multibaseDecoders = new Map([ - ['u', base64url], - ['z', base58], -]); +// const multibaseDecoders = new Map([ +// ['u', base64url], +// ['z', base58], +// ]); export async function createCapabilities({profileId, request}) { // TODO: validate `request` @@ -178,62 +179,73 @@ export async function openFirstPartyWindow(event) { export const prettify = obj => JSON.stringify(obj, null, 2); export async function toNFCPayload({credential}) { - const nfcRenderingTemplate2024 = _getNFCRenderingTemplate2024({credential}); - const bytes = await _decodeMultibase(nfcRenderingTemplate2024.payload); - return {bytes}; -} - -export function hasNFCPayload({credential}) { - try { - const nfcRenderingTemplate2024 = _getNFCRenderingTemplate2024({credential}); - if(!nfcRenderingTemplate2024) { - return false; - } - - return true; - } catch(e) { - return false; - } + return renderToNfc({credential}); } -function _getNFCRenderingTemplate2024({credential}) { - let {renderMethod} = credential; - if(!renderMethod) { - throw new Error('Credential does not contain "renderMethod".'); - } - - renderMethod = Array.isArray(renderMethod) ? renderMethod : [renderMethod]; - - let nfcRenderingTemplate2024 = null; - for(const rm of renderMethod) { - if(rm.type === 'NfcRenderingTemplate2024') { - nfcRenderingTemplate2024 = rm; - break; - } - continue; - } - - if(nfcRenderingTemplate2024 === null) { - throw new Error('Credential does not support "NfcRenderingTemplate2024".'); - } - - if(!nfcRenderingTemplate2024.payload) { - throw new Error('NfcRenderingTemplate2024 does not contain "payload".'); - } +// export async function toNFCPayload({credential}) { +// const nfcRenderingTemplate2024 = +// _getNFCRenderingTemplate2024({credential}); +// const bytes = await _decodeMultibase(nfcRenderingTemplate2024.payload); +// return {bytes}; +// } - return nfcRenderingTemplate2024; +export function hasNFCPayload({credential}) { + return supportsNFC({credential}); } -async function _decodeMultibase(input) { - const multibaseHeader = input[0]; - const decoder = multibaseDecoders.get(multibaseHeader); - if(!decoder) { - throw new Error(`Multibase header "${multibaseHeader}" not supported.`); - } - - const encodedStr = input.slice(1); - return decoder.decode(encodedStr); -} +// export function hasNFCPayload({credential}) { +// try { +// const nfcRenderingTemplate2024 = +// _getNFCRenderingTemplate2024({credential}); +// if(!nfcRenderingTemplate2024) { +// return false; +// } + +// return true; +// } catch(e) { +// return false; +// } +// } + +// function _getNFCRenderingTemplate2024({credential}) { +// let {renderMethod} = credential; +// if(!renderMethod) { +// throw new Error('Credential does not contain "renderMethod".'); +// } + +// renderMethod = Array.isArray(renderMethod) ? renderMethod : [renderMethod]; + +// let nfcRenderingTemplate2024 = null; +// for(const rm of renderMethod) { +// if(rm.type === 'NfcRenderingTemplate2024') { +// nfcRenderingTemplate2024 = rm; +// break; +// } +// continue; +// } + +// if(nfcRenderingTemplate2024 === null) { +// throw new Error( +// 'Credential does not support "NfcRenderingTemplate2024".'); +// } + +// if(!nfcRenderingTemplate2024.payload) { +// throw new Error('NfcRenderingTemplate2024 does not contain "payload".'); +// } + +// return nfcRenderingTemplate2024; +// } + +// async function _decodeMultibase(input) { +// const multibaseHeader = input[0]; +// const decoder = multibaseDecoders.get(multibaseHeader); +// if(!decoder) { +// throw new Error(`Multibase header "${multibaseHeader}" not supported.`); +// } + +// const encodedStr = input.slice(1); +// return decoder.decode(encodedStr); +// } async function _updateProfileAgentUser({ accessManager, profileAgent, profileAgentContent diff --git a/lib/index.js b/lib/index.js index eb97e37..bdb80d8 100644 --- a/lib/index.js +++ b/lib/index.js @@ -19,13 +19,14 @@ import * as cryptoSuites from './cryptoSuites.js'; import * as exchanges from './exchanges/index.js'; import * as helpers from './helpers.js'; import * as inbox from './inbox.js'; +import * as nfcRenderer from './nfcRenderer.js'; import * as presentations from './presentations.js'; import * as users from './users.js'; import * as validator from './validator.js'; import * as zcap from './zcap.js'; export { ageCredentialHelpers, capabilities, cryptoSuites, exchanges, - helpers, inbox, presentations, users, validator, zcap + helpers, inbox, nfcRenderer, presentations, users, validator, zcap }; export { getCredentialStore, getProfileEdvClient, initialize, profileManager diff --git a/lib/nfcRenderer.js b/lib/nfcRenderer.js new file mode 100644 index 0000000..d6aa1ad --- /dev/null +++ b/lib/nfcRenderer.js @@ -0,0 +1,752 @@ +/** + * VC NFC Renderer Library + * Handles NFC rendering for verifiable credentials. + * Supports both static and dynamic rendering modes. + * + * Field Requirements: + * - TemplateRenderMethod (W3C spec): MUST use "template" field. + * - NfcRenderingTemplate2024 (legacy): MUST use "payload" field. + */ +import * as base58 from 'base58-universal'; +import * as base64url from 'base64url-universal'; + +const multibaseDecoders = new Map([ + ['u', base64url], + ['z', base58] +]); + +// ============ +// Public API +// ============ + +/** + * Check if a verifiable credential supports NFC rendering. + * + * @param {object} options - Options object. + * @param {object} options.credential - The verifiable credential. + * @returns {boolean} - 'true' if NFC is supported. + */ +export function supportsNFC({credential} = {}) { + try { + const renderMethod = _findNFCRenderMethod({credential}); + if(renderMethod !== null) { + return true; + } + // no NFC render method found + return false; + } catch(error) { + return false; + } +} + +/** + * Render a verifiable credential to NFC payload bytes. + * + * Architecture: + * + * 1. Filter: Use renderProperty to extract specific fields + * (optional, for transparency). + * 2. Render: Pass template and filtered data to NFC rendering engine. + * 3. Output: Return decoded bytes from template. + * + * Template Requirement: + * - All NFC rendering requires a template field containing pre-encoded payload. + * - TemplateRenderMethod uses 'template' field (W3C spec). + * - NfcRenderingTemplate2024 uses 'payload' field (legacy). + * + * @param {object} options - Options object. + * @param {object} options.credential - The verifiable credential. + * @returns {Promise} Object with bytes property: {bytes: Uint8Array}. + */ +export async function renderToNfc({credential} = {}) { + // finc NFC-compatible render method + const renderMethod = _findNFCRenderMethod({credential}); + + if(!renderMethod) { + throw new Error( + 'The verifiable credential does not support NFC rendering.' + ); + } + + // require template/payload field + if(!_hasTemplate({renderMethod})) { + throw new Error( + 'NFC rendering requires a template field. ' + + 'The template should contain the pre-encoded NFC payload.' + ); + } + + // Step 1: Filter credential if renderProperty exists + let filteredData = null; + if(renderMethod.renderProperty && renderMethod.renderProperty.length > 0) { + filteredData = _filterCredential({credential, renderMethod}); + } + + // Step 2: Pass both template and filteredData to rendering engine + const bytes = await _decodeTemplateToBytes({renderMethod, filteredData}); + + // Wrap in object for consistent return format + return {bytes}; +} + +// TODO: Delete later. +/** + * Use renderToNFC instead renderToNfc_V0 function. + * Render a verifiable credential to NFC payload bytes. + * + * Supports both static (pre-encoded) and dynamic (runtime extraction) + * rendering modes based on the renderSuite value: + * - "nfc-static": Uses template (W3C spec) or payload (legacy) field. + * - "nfc-dynamic": Extracts data using renderProperty. + * - "nfc": Generic fallback - static takes priority if both exist. + * + * @param {object} options - Options object. + * @param {object} options.credential - The verifiable credential. + * @returns {Promise} Object with bytes property: {bytes: Uint8Array}. + */ +// export async function renderToNfc_V0({credential} = {}) { +// // find NFC-compatible render method +// const renderMethod = _findNFCRenderMethod({credential}); + +// if(!renderMethod) { +// throw new Error( +// 'The verifiable credential does not support NFC rendering.' +// ); +// } + +// // determining rendering mode and route to appropriate handler +// const suite = _getRenderSuite({renderMethod}); + +// if(!suite) { +// throw new Error('Unable to determine render suite for NFC rendering.'); +// } + +// let bytes; + +// switch(suite) { +// case 'nfc-static': +// bytes = await _renderStatic({renderMethod}); +// break; +// case 'nfc-dynamic': +// bytes = await _renderDynamic({renderMethod, credential}); +// break; +// case 'nfc': +// // try static first, fall back to dynamic if renderProperty exists + +// // BEHAVIOR: Static rendering has priority over dynamic rendering. +// // If BOTH template/payload AND renderProperty exist, static is used +// // and renderProperty is ignored (edge case). +// if(_hasStaticPayload({renderMethod})) { +// bytes = await _renderStatic({renderMethod}); +// } else if(renderMethod.renderProperty) { +// // renderProperty exists, proceed with dynamic rendering +// bytes = await _renderDynamic({renderMethod, credential}); +// } else { +// throw new Error( +// 'NFC render method has neither payload nor renderProperty.' +// ); +// } +// break; +// default: +// throw new Error(`Unsupported renderSuite: ${suite}`); +// } + +// // wrap in object for consistent return format +// return {bytes}; +// } + +// ======================== +// Render method detection +// ======================== + +/** + * Check if render method has a template field. + * + * Note: Template field name varies by type: + * - TemplateRenderMethod: uses 'template' field (W3C spec). + * - NfcRenderingTemplate2024: uses 'payload' field (legacy). + * + * @private + * @param {object} options - Options object. + * @param {object} options.renderMethod - The render method object. + * @returns {boolean} - 'true' if template or payload field exists. + */ +function _hasTemplate({renderMethod} = {}) { + // enforce field usage based on render method type + if(renderMethod.type === 'TemplateRenderMethod') { + // W3C Spec format: check for 'template' field + if(renderMethod && renderMethod.template) { + return true; + } + return false; + } + + if(renderMethod.type === 'NfcRenderingTemplate2024') { + // legacy format: check for 'payload' field + if(renderMethod && renderMethod.payload) { + return true; + } + return false; + } + + return false; +} + +/** + * Filter credential data using renderProperty. + * Extracts only the fields specified in renderProperty + * for transparency. + * + * @private + * @param {object} options - Options object. + * @param {object} options.credential - The verifiable credential. + * @param {object} options.renderMethod - The render method object. + * @returns {object} - Filtered data object with extracted fields. + */ +function _filterCredential({credential, renderMethod} = {}) { + const {renderProperty} = renderMethod; + + // check if renderProperty exists and is not empty + if(!renderProperty || renderProperty.length == 0) { + return null; + } + + const filteredData = {}; + + // extract each field specified in renderProperty + for(const pointer of renderProperty) { + const value = _resolveJSONPointer({obj: credential, pointer}); + + // ensure property exists in credential + if(value === undefined) { + throw new Error(`Property not found in credential: ${pointer}`); + } + + // extract field name form pointer for key + // e.g., "/credentialSubject/name" -> "name" + const fieldName = pointer.split('/').pop(); + filteredData[fieldName] = value; + } + + return filteredData; +} + +/** + * Find the NFC-compatible render method in a verifiable credential. + * Checks modern format (TemplateRenderMethod) first, then legacy + * format (NfcRenderingTemplate2024). + * + * @private + * @param {object} options - Options object. + * @param {object} options.credential - The verifiable credential. + * @returns {object|null} The NFC render method or null. + */ +function _findNFCRenderMethod({credential} = {}) { + let renderMethods = credential?.renderMethod; + + if(!renderMethods) { + return null; + } + + // normalize to array for consistent handling + if(!Array.isArray(renderMethods)) { + renderMethods = [renderMethods]; + } + + // search for NFC-compatible render methods + for(const method of renderMethods) { + // check for W3C spec format with nfc renderSuite + if(method.type === 'TemplateRenderMethod') { + const suite = method.renderSuite?.toLowerCase(); + if(suite && suite.startsWith('nfc')) { + return method; + } + } + + // check for legacy format/existing codebase in + // bedrock-web-wallet/lib/helper.js file + if(method.type === 'NfcRenderingTemplate2024') { + return method; + } + } + + return null; +} + +// TODO: Delete later. +/** + * Get the render suite with fallback for legacy formats. + * + * @private + * @param {object} options - Options object. + * @param {object} options.renderMethod - The render method object. + * @returns {string} The render suite identifier. + */ +// function _getRenderSuite({renderMethod} = {}) { +// // use renderSuite if present +// if(renderMethod.renderSuite) { +// return renderMethod.renderSuite.toLowerCase(); +// } + +// // legacy format defaults to static +// if(renderMethod.type === 'NfcRenderingTemplate2024') { +// return 'nfc-static'; +// } + +// // generic fallback +// return 'nfc'; +// } + +// TODO: Delete later. +/** + * Check if render method has a static payload. + * + * @private + * @param {object} options - Options object. + * @param {object} options.renderMethod - The render method object. + * @returns {boolean} - 'true' if has appropriate field for render method type. + */ +// function _hasStaticPayload({renderMethod} = {}) { +// // enforce field usage based on render method type +// if(renderMethod.type === 'NfcRenderingTemplate2024') { +// // legacy format: check for 'payload' field +// if(renderMethod && renderMethod.payload) { +// return true; +// } +// return false; +// } +// if(renderMethod.type === 'TemplateRenderMethod') { +// // W3C Spec format: check for 'template' field +// if(renderMethod && renderMethod.template) { +// return true; +// } +// return false; +// } +// // if(renderMethod.template || renderMethod.payload) { +// // return true; +// // } +// return false; +// } + +// ======================== +// NFC rendering engine +// ======================== + +/** + * Extract and validate template from render method. + * Enforces strict field usage based on render method type. + * + * @private + * @param {object} options - Options object. + * @param {object} options.renderMethod - The render method object. + * @returns {string} - Encoded template string. + * @throws {Error} - If validation fails. + */ +function _extractTemplate({renderMethod} = {}) { + let encoded; + + // check W3C spec format first + if(renderMethod.type === 'TemplateRenderMethod') { + // validate: should not have both fields + if(renderMethod.template && renderMethod.payload) { + throw new Error( + 'TemplateRenderMethod requires "template". ' + + 'It should not have both fields.' + ); + } + + encoded = renderMethod.template; + if(!encoded) { + throw new Error('TemplateRenderMethod requires "template" field.'); + } + // check legacy format + } else if(renderMethod.type === 'NfcRenderingTemplate2024') { + // validate: should not have both fields + if(renderMethod.template && renderMethod.payload) { + throw new Error( + 'NfcRenderingTemplate2024 should not have both template ' + + 'and payload fields.' + ); + } + encoded = renderMethod.payload; + if(!encoded) { + throw new Error('NfcRenderingTemplate2024 requires "payload" field.'); + } + } else { + throw new Error(`Unsupported render method type: ${renderMethod.type}`); + } + + return encoded; +} + +/** + * Decode template to NFC payload bytes from template. + * Extract, validates, and decodes the template field. + * + * @private + * @param {object} options - Options object. + * @param {object} options.renderMethod - The render method object. + * @param {object} options.filteredData - Filtered credential data + * (may be null). + * @returns {Promise} - NFC payload as bytes. + */ +// eslint-disable-next-line no-unused-vars +async function _decodeTemplateToBytes({renderMethod, filteredData} = {}) { + // Note: filteredData is reserved for future template + // processing with variables. Currently not used - + // as templates contain complete binary payloads. + + // extract and validate template/payload field + const encoded = _extractTemplate({renderMethod}); + + // validate template is a string + if(typeof encoded !== 'string') { + throw new Error('Template or payload must be a string.'); + } + + // Rendering: Decode the template to bytes + const bytes = await _decodeTemplate({encoded}); + + return bytes; +} + +async function _decodeTemplate({encoded} = {}) { + // data URI format + if(encoded.startsWith('data:')) { + return _decodeDataUri({dataUri: encoded}); + } + + // multibase format (base58 'z' or base64url 'u') + if(encoded[0] === 'z' || encoded[0] === 'u') { + return _decodeMultibase({input: encoded}); + } + + throw new Error( + 'Unknown template encoding format. ' + + 'Supported formats: data URI (data:...) or multibase (z..., u...)' + ); +} + +// ======================== +// Static rendering +// ======================== + +// TODO: Delete later. +/** + * Render static NFC payload. + * + * @param {object} options - Options object. + * @param {object} options.renderMethod - The render method object. + * @returns {Promise} - NFC payload as bytes. + */ +// async function _renderStatic({renderMethod} = {}) { + +// // enforce field usage based on render method type +// let encoded; + +// // get the payload from template or payload field +// // const encoded = renderMethod.template || renderMethod.payload; +// if(renderMethod.type === 'NfcRenderingTemplate2024') { +// if(renderMethod.template && renderMethod.payload) { +// throw new Error('NfcRenderingTemplate2024 should not have' + +// ' both template and payload fields.' +// ); +// } +// // legacy format: ONLY accept 'payload' field +// encoded = renderMethod.payload; +// if(!encoded) { +// throw new Error('NfcRenderingTemplate2024 requires "payload" field.'); +// } +// } else if(renderMethod.type === 'TemplateRenderMethod') { +// // W3C Spec format: ONLY accept 'template' field +// if(renderMethod.template && renderMethod.payload) { +// throw new Error('TemplateRenderMethod requires "template"' + +// ' and should not have both fields.' +// ); +// } +// encoded = renderMethod.template; +// if(!encoded) { +// throw new Error('TemplateRenderMethod requires "template" field.'); +// } +// } else { +// // This should never happen given _findNFCRenderMethod() logic +// throw new Error(`Unsupported render method type: ${renderMethod.type}`); +// } + +// if(typeof encoded !== 'string') { +// throw new Error('Template or payload must be a string.'); +// } + +// // decoded based on format +// if(encoded.startsWith('data:')) { +// // data URI format +// return _decodeDataUri({dataUri: encoded}); +// } +// if(encoded[0] === 'z' || encoded[0] === 'u') { +// // multibase format +// return _decodeMultibase({input: encoded}); +// } +// throw new Error('Unknown payload encoding format'); +// } + +// ======================== +// Dynamic rendering +// ======================== + +// TODO: Delete later +/** + * Render dynamic NFC payload by extracting data from a verifiable + * credential. + * + * @param {object} options - Options object. + * @param {object} options.renderMethod - The render method object. + * @param {object} options.credential - The verifiable credential. + * @returns {Promise} - NFC payload as bytes. + */ +// async function _renderDynamic( +// {renderMethod, credential} = {}) { + +// // validate renderProperty exists +// if(!renderMethod.renderProperty) { +// throw new Error('Dynamic NFC rendering requires renderProperty.'); +// } + +// // normalize to array for consistent handling +// const propertyPaths = Array.isArray(renderMethod.renderProperty) ? +// renderMethod.renderProperty : [renderMethod.renderProperty]; + +// if(propertyPaths.length === 0) { +// throw new Error('renderProperty cannot be empty.'); +// } + +// // extract values from a verifiable credential using JSON pointers +// const extractedValues = []; + +// for(const path of propertyPaths) { +// const value = _resolveJSONPointer({obj: credential, pointer: path}); + +// if(value === undefined) { +// throw new Error(`Property not found in credential: ${path}`); +// } + +// extractedValues.push({path, value}); +// } + +// // build the NFC payload from extracted values +// return _buildDynamicPayload( +// {extractedValues}); +// } + +// TODO: Delete later. +/** + * Build NFC payload from extracted credential values. + * + * @private + * @param {object} options - Options object. + * @param {Array} options.extractedValues - Extracted values with paths. + * @returns {Uint8Array} - NFC payload as bytes. + */ +// function _buildDynamicPayload({extractedValues} = {}) { + +// // simple concatenation of UTF-8 encoded values +// const chunks = []; + +// for(const item of extractedValues) { +// const valueBytes = _encodeValue({value: item.value}); +// chunks.push(valueBytes); +// } + +// // concatenate all chunks into single payload +// const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); +// const result = new Uint8Array(totalLength); + +// let offset = 0; +// for(const chunk of chunks) { +// result.set(chunk, offset); +// offset += chunk.length; +// } + +// return result; +// } + +// TODO: Delete later. +/** + * Encode a value to bytes. + * + * @private + * @param {object} options - Options object. + * @param {*} options.value - The value to encode. + * @returns {Uint8Array} The encoded bytes. + */ +// function _encodeValue({value} = {}) { +// if(typeof value === 'string') { +// // UTF-8 encode strings +// return new TextEncoder().encode(value); +// } +// if(typeof value === 'number') { +// // convert number to string then encode +// return new TextEncoder().encode(String(value)); +// } +// if(typeof value === 'object') { +// // JSON stringify objects +// return new TextEncoder().encode(JSON.stringify(value)); +// } +// // fallback: convert to string +// return new TextEncoder().encode(String(value)); +// } + +// ======================== +// Decoding utilities +// ======================== + +/** + * Decode a data URI to bytes. + * Validates media type is application/octet-stream.. + * + * @private + * @param {object} options - Options object. + * @param {string} options.dataUri - Data URI string. + * @returns {Uint8Array} Decoded bytes. + * @throws {Error} If data URI is invalid or has wrong media type. + */ +function _decodeDataUri({dataUri} = {}) { + // parse data URI format: data:mime/type;encoding,data + const match = dataUri.match(/^data:([^;]+);([^,]+),(.*)$/); + + if(!match) { + throw new Error('Invalid data URI format.'); + } + + const mimeType = match[1]; + const encoding = match[2]; + const data = match[3]; + + // validate media type is application/octet-stream + if(mimeType !== 'application/octet-stream') { + throw new Error( + 'Invalid data URI media type. ' + + 'NFC templates must use "application/octet-stream" media type. ' + + `Found: "${mimeType}"` + ); + } + + // decode based on encoding + if(encoding === 'base64') { + return _base64ToBytes({base64String: data}); + } + if(encoding === 'base64url') { + return base64url.decode(data); + } + throw new Error(`Unsupported data URI encoding: ${encoding}`); +} + +/** + * Decode multibase-encoded string. + * + * @private + * @param {object} options - Options object. + * @param {string} options.input - Multibase encoded string. + * @returns {Uint8Array} Decoded bytes. + */ +function _decodeMultibase({input} = {}) { + const header = input[0]; + const encodedData = input.slice(1); + + const decoder = multibaseDecoders.get(header); + if(!decoder) { + throw new Error(`Unsupported multibase header: ${header}`); + } + + return decoder.decode(encodedData); +} + +/** + * Decode standard base64 to bytes. + * + * @private + * @param {object} options - Options object. + * @param {string} options.base64String - Base64 encoded string. + * @returns {Uint8Array} Decoded bytes. + */ +function _base64ToBytes({base64String} = {}) { + // use atob in browser, Buffer in Node + if(typeof atob !== 'undefined') { + const binaryString = atob(base64String); + const bytes = new Uint8Array(binaryString.length); + for(let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + + // Node.js environment + return Buffer.from(base64String, 'base64'); +} + +// ======================== +// JSON pointer utilities +// ======================== + +/** + * Resolve a JSON pointer in an object per RFC 6901. + * + * @private + * @param {object} options - Options object. + * @param {object} options.obj - The object to traverse. + * @param {string} options.pointer - JSON pointer string. + * @returns {*} The value at the pointer location or undefined. + */ +function _resolveJSONPointer({obj, pointer} = {}) { + // handle empty pointer (refers to entire document) + if(pointer === '' || pointer === '/') { + return obj; + } + + // remove leading slash + let path = pointer; + if(path.startsWith('/')) { + path = path.slice(1); + } + + // split into segments + const segments = path.split('/'); + + // traverse the object + let current = obj; + + for(const segment of segments) { + // decode special characters per RFC 6901: ~1 = /, ~0 = ~ + const decoded = segment + .replace(/~1/g, '/') + .replace(/~0/g, '~'); + + // handle array indices + if(Array.isArray(current)) { + const index = parseInt(decoded, 10); + if(isNaN(index) || index < 0 || index >= current.length) { + return undefined; + } + current = current[index]; + } else if(typeof current === 'object' && current !== null) { + current = current[decoded]; + } else { + return undefined; + } + + // return early if undefined + if(current === undefined) { + return undefined; + } + } + + return current; +} + +// ============ +// Exports +// ============ + +export default { + supportsNFC, + renderToNfc +}; diff --git a/test/web/15-nfc-renderer.js b/test/web/15-nfc-renderer.js new file mode 100644 index 0000000..4c43808 --- /dev/null +++ b/test/web/15-nfc-renderer.js @@ -0,0 +1,1467 @@ +import * as webWallet from '@bedrock/web-wallet'; +// console.log(webWallet.nfcRenderer); +// console.log(typeof webWallet.nfcRenderer.supportsNFC); + +describe('NFC Renderer', function() { + describe('supportsNFC()', function() { + // Test to verify if a credential supports NFC rendering. + it('should return true for credential with nfc renderSuite and template', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(true); + } + ); + + it('should return true even when template is missing ' + + '(detection, not validation', + async () => { + // Note: supportsNFC() only detects NFC capability, it doesn't validate. + // This credential will fail in renderToNfc() due to missing template. + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123', + name: 'John Doe' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + renderProperty: ['/credentialSubject/name'] + // missing template - will fail in renderToNfc() + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(true); + } + ); + + it('should return true for credential with generic nfc renderSuite', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(true); + } + ); + + // Legacy format uses 'payload' field instead of 'template' + it('should return true for legacy NfcRenderingTemplate2024 type', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'NfcRenderingTemplate2024', + payload: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(true); + } + ); + + it('should return true for credential with renderMethod array', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: [ + { + type: 'SvgRenderingTemplate2023', + template: 'some-svg-data' + }, + { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' + } + ] + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(true); + } + ); + + it('should return false for credential without renderMethod', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(false); + } + ); + + it('should return false for credential with non-NFC renderMethod', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'SvgRenderingTemplate2023', + template: 'some-svg-data' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(false); + } + ); + + it('should detect NFC renderSuite case-insensitively', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + // uppercase + renderSuite: 'NFC', + template: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(true); + } + ); + }); + + describe('renderToNfc() - Template Decoding', function() { + it('should successfully render static NFC with multibase-encoded template', + async () => { + // Base58 multibase encoded "Hello NFC" (z = base58btc prefix) + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'z2drAj5bAkJFsTPKmBvG3Z' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + should.exist(result.bytes); + result.bytes.should.be.an.instanceof(Uint8Array); + result.bytes.length.should.be.greaterThan(0); + } + ); + + it('should successfully render static NFC with base64url-encoded template', + async () => { + // Base64URL encoded "Test Data" + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'uVGVzdCBEYXRh' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + should.exist(result.bytes); + result.bytes.should.be.an.instanceof(Uint8Array); + } + ); + + it('should successfully render static NFC with data URI format', + async () => { + // Data URI with base64 encoded "NFC Data" + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,TkZDIERhdGE=' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + should.exist(result.bytes); + result.bytes.should.be.an.instanceof(Uint8Array); + // Verify decoded content + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('NFC Data'); + } + ); + + // Field validation: TemplateRenderMethod uses 'template', not 'payload' + it('should fail when TemplateRenderMethod has both template and payload', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // "Hello NFC" + template: 'z2drAj5bAkJFsTPKmBvG3Z', + // "Different" + payload: 'uRGlmZmVyZW50' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('template'); + } + ); + + // Field validation: NfcRenderingTemplate2024 uses 'payload', not 'template' + it('should fail when NfcRenderingTemplate2024 uses template field', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'NfcRenderingTemplate2024', + // wrong field - it should be payload + template: 'z2drAj5bAkJFsTPKmBvG3Z' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('payload'); + } + ); + + // Template is required for all NFC rendering + it('should fail TemplateRenderMethod has no template field', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc' + // No template field + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('template'); + } + ); + + it('should fail when template encoding is invalid', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'xInvalidEncoding123' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('encoding format'); + } + ); + + it('should work with legacy NfcRenderingTemplate2024 using payload field', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'NfcRenderingTemplate2024', + // Using 'payload', not 'template' + payload: 'z2drAj5bAkJFsTPKmBvG3Z' + } + }; + + let result; + let err; + + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(error) { + err = error; + } + + should.not.exist(err); + should.exist(result); + should.exist(result.bytes); + result.bytes.should.be.an.instanceof(Uint8Array); + } + ); + + it('should decode template even when renderProperty is present', + async () => { + // Template contains "Hello NFC" + // renderProperty indicates what fields are disclosed (for transparency) + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + greeting: 'Hello' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // "Hello NFC" as base64 in data URI format + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + // For transparency + renderProperty: ['/credentialSubject/greeting'] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + should.exist(result.bytes); + + // Should decode template, renderProperty is for transparency only + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Hello NFC'); + } + ); + + it('should fail when renderProperty references non-existent field', + async () => { + // Template is valid, but renderProperty validation fails + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + name: 'Alice' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'z2drAj5bAkJFsTPKmBvG3Z', + // Doesn't exist! + renderProperty: ['/credentialSubject/nonExistent'] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('Property not found'); + } + ); + }); + + describe('renderToNfc() - renderProperty Validation', function() { + it('should fail when only renderProperty exists without template', + async () => { + // In unified architecture, template is always required + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123', + name: 'Alice Smith' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + renderProperty: ['/credentialSubject/name'] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('template'); + } + ); + + it('should validate renderProperty field exists before decoding template', + async () => { + // renderProperty validates credential has the field + // Then template is decoded (not the credential field!) + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123', + name: 'Alice Smith' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // "Hello NFC" encoded + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + // Validates field exists + renderProperty: [ + '/credentialSubject/name', + ] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + should.exist(result.bytes); + // Should decode template, NOT extract "Alice Smith" + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Hello NFC'); + decoded.should.not.equal('Alice Smith'); + } + ); + + // TODO: Delete later + // it('should handle numeric values in dynamic rendering', + // async () => { + // const credential = { + // '@context': ['https://www.w3.org/ns/credentials/v2'], + // type: ['VerifiableCredential'], + // credentialSubject: { + // id: 'did:example:123', + // age: 25 + // }, + // renderMethod: { + // type: 'TemplateRenderMethod', + // renderSuite: 'nfc-dynamic', + // renderProperty: ['/credentialSubject/age'] + // } + // }; + + // let result; + // let err; + // try { + // result = await webWallet.nfcRenderer.renderToNfc({credential}); + // } catch(e) { + // err = e; + // } + + // should.not.exist(err); + // should.exist(result); + // should.exist(result.bytes); + // const decoded = new TextDecoder().decode(result.bytes); + // decoded.should.equal('25'); + + // } + // ); + + // TODO: Delete later + // it('should handle object values in dynamic rendering', + // async () => { + // const credential = { + // '@context': ['https://www.w3.org/ns/credentials/v2'], + // type: ['VerifiableCredential'], + // credentialSubject: { + // id: 'did:example:123', + // address: { + // street: '123 Main St', + // city: 'Boston' + // } + // }, + // renderMethod: { + // type: 'TemplateRenderMethod', + // renderSuite: 'nfc-dynamic', + // renderProperty: ['/credentialSubject/address'] + // } + // }; + + // let result; + // let err; + // try { + // result = await webWallet.nfcRenderer.renderToNfc({credential}); + // } catch(e) { + // err = e; + // } + + // should.not.exist(err); + // should.exist(result); + // should.exist(result.bytes); + // // Should be JSON stringified + // const decoded = new TextDecoder().decode(result.bytes); + // const parsed = JSON.parse(decoded); + // parsed.street.should.equal('123 Main St'); + // parsed.city.should.equal('Boston'); + // } + // ); + + // TODO: Delete later + // it('should handle array access in JSON pointer', + // async () => { + // const credential = { + // '@context': ['https://www.w3.org/ns/credentials/v2'], + // type: ['VerifiableCredential'], + // credentialSubject: { + // id: 'did:example:123', + // skills: ['JavaScript', 'Python', 'Rust'] + // }, + // renderMethod: { + // type: 'TemplateRenderMethod', + // renderSuite: 'nfc-dynamic', + // renderProperty: ['/credentialSubject/skills/0'] + // } + // }; + + // let result; + // let err; + // try { + // result = await webWallet.nfcRenderer.renderToNfc({credential}); + // } catch(e) { + // err = e; + // } + + // should.not.exist(err); + // should.exist(result); + // should.exist(result.bytes); + // const decoded = new TextDecoder().decode(result.bytes); + // decoded.should.equal('JavaScript'); + // } + // ); + + // TODO: Delete later + // it('should handle special characters in JSON pointer', + // async () => { + // const credential = { + // '@context': ['https://www.w3.org/ns/credentials/v2'], + // type: ['VerifiableCredential'], + // credentialSubject: { + // id: 'did:example:123', + // 'field/with~slash': 'test-value' + // }, + // renderMethod: { + // type: 'TemplateRenderMethod', + // renderSuite: 'nfc-dynamic', + // renderProperty: ['/credentialSubject/field~1with~0slash'] + // } + // }; + + // let result; + // let err; + // try { + // result = await webWallet.nfcRenderer.renderToNfc({credential}); + // } catch(e) { + // err = e; + // } + + // should.not.exist(err); + // should.exist(result); + // should.exist(result.bytes); + // const decoded = new TextDecoder().decode(result.bytes); + // decoded.should.equal('test-value'); + // } + // ); + + it('should succeed when renderProperty is missing but template exists', + async () => { + // renderProperty is optional - template is what matters + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123', + name: 'Alice' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // "Hello NFC" + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD' + // No renderProperty + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + should.exist(result.bytes); + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Hello NFC'); + } + ); + + it('should fail when renderProperty references non-existent field', + async () => { + // Even though template is valid, renderProperty validation fails + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123', + name: 'Alice' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // valid template + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + renderProperty: ['/credentialSubject/nonExistentField'] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('Property not found'); + } + ); + + it('should validate all renderProperty fields exist', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123', + firstName: 'Alice', + lastName: 'Smith' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + renderProperty: [ + '/credentialSubject/firstName', + '/credentialSubject/lastName' + ] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + // Template is decoded, not the fields + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Hello NFC'); + } + ); + + it('should succeed when renderProperty is empty array', + async () => { + // Empty renderProperty is treated as "no filtering" + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123' + }, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + // Empty is OK + renderProperty: [] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Hello NFC'); + } + ); + + // TODO: Delete later + // it('should fail when renderProperty is empty array', + // async () => { + // const credential = { + // '@context': ['https://www.w3.org/ns/credentials/v2'], + // type: ['VerifiableCredential'], + // credentialSubject: { + // id: 'did:example:123' + // }, + // renderMethod: { + // type: 'TemplateRenderMethod', + // renderSuite: 'nfc-dynamic', + // renderProperty: [] + // } + // }; + + // let result; + // let err; + // try { + // result = await webWallet.nfcRenderer.renderToNfc({credential}); + // } catch(e) { + // err = e; + // } + + // should.exist(err); + // should.not.exist(result); + // err.message.should.contain('cannot be empty'); + // } + // ); + }); + + // TODO: Delete later + // describe('renderToNfc() - Generic NFC Suite', function() { + // it('should prioritize static rendering when both payload and ' + + // 'renderProperty exist', async () => { + // const credential = { + // '@context': ['https://www.w3.org/ns/credentials/v2'], + // type: ['VerifiableCredential'], + // credentialSubject: { + // id: 'did:example:123', + // name: 'Alice' + // }, + // renderMethod: { + // type: 'TemplateRenderMethod', + // renderSuite: 'nfc', + // // "Hello NFC" + // template: 'z2drAj5bAkJFsTPKmBvG3Z', + // renderProperty: ['/credentialSubject/name'] + // } + // }; + + // let result; + // let err; + // try { + // result = await webWallet.nfcRenderer.renderToNfc({credential}); + // } catch(e) { + // err = e; + // } + + // should.not.exist(err); + // should.exist(result); + // // Should use static rendering (template), not dynamic + // const decoded = new TextDecoder().decode(result.bytes); + // // If it was dynamic, it would be "Alice" + // decoded.should.not.equal('Alice'); + // }); + + // it('should fallback to dynamic rendering when' + + // ' only renderProperty exists', + // async () => { + // const credential = { + // '@context': ['https://www.w3.org/ns/credentials/v2'], + // type: ['VerifiableCredential'], + // credentialSubject: { + // id: 'did:example:123', + // name: 'Bob' + // }, + // renderMethod: { + // type: 'TemplateRenderMethod', + // renderSuite: 'nfc', + // renderProperty: ['/credentialSubject/name'] + // } + // }; + + // let result; + // let err; + // try { + // result = await webWallet.nfcRenderer.renderToNfc({credential}); + // } catch(e) { + // err = e; + // } + + // should.not.exist(err); + // should.exist(result); + // const decoded = new TextDecoder().decode(result.bytes); + // decoded.should.equal('Bob'); + // } + // ); + + // it('should fail when neither template nor renderProperty exist', + // async () => { + // const credential = { + // '@context': ['https://www.w3.org/ns/credentials/v2'], + // type: ['VerifiableCredential'], + // credentialSubject: { + // id: 'did:example:123' + // }, + // renderMethod: { + // type: 'TemplateRenderMethod', + // renderSuite: 'nfc' + // } + // }; + + // let result; + // let err; + // try { + // result = await webWallet.nfcRenderer.renderToNfc({credential}); + // } catch(e) { + // err = e; + // } + + // should.exist(err); + // should.not.exist(result); + // err.message.should.contain('neither payload nor renderProperty'); + // } + // ); + // }); + + describe('renderToNfc() - Error Cases', function() { + it('should fail when credential has no renderMethod', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:123' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('does not support NFC rendering'); + } + ); + + it('should fail when renderSuite is unsupported', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'unsupported-suite', + template: 'some-data' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('does not support NFC rendering'); + } + ); + + it('should fail when credential parameter is missing', + async () => { + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + } + ); + + it('should fail when data URI has wrong media type', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // Wrong media type - should be application/octet-stream + template: 'data:text/plain;base64,SGVsbG8=' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('media type'); + } + ); + + it('should fail when data URI format is malformed', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // Malformed data URI (missing encoding or data) + template: 'data:application/octet-stream' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('Invalid data URI'); + } + ); + + it('should fail when multibase encoding is unsupported', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // 'f' is base16 multibase - not supported by implementation + template: 'f48656c6c6f' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('encoding format'); + } + ); + + it('should fail when data URI encoding is unsupported', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // hex encoding is not supported + template: 'data:application/octet-stream;hex,48656c6c6f' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('encoding'); + } + ); + + it('should fail when base64 data is invalid', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // Invalid base64 characters + template: 'data:application/octet-stream;base64,!!!invalid!!!' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + // Error message varies by environment (browser vs Node) + } + ); + + it('should fail when template is not a string', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // Template should be a string, not an object + template: { + type: 'embedded', + data: 'some-data' + } + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + } + ); + + }); + + describe('NFC Renderer - EAD Credential Tests (from URL)', function() { + let eadCredential; + + // Fetch the credential once before all tests + before(async function() { + // Increase timeout for network request + this.timeout(5000); + + try { + const response = await fetch( + 'https://gist.githubusercontent.com/gannan08/b03a8943c1ed1636a74e1f1966d24b7c/raw/fca19c491e2ab397d9c547e1858ba4531dd4e3bf/full-example-ead.json' + ); + + if(!response.ok) { + throw new Error(`Failed to fetch credential: ${response.status}`); + } + + eadCredential = await response.json(); + console.log('✓ EAD Credential loaded from URL'); + } catch(error) { + console.error('Failed to load EAD credential:', error); + // Skip all tests if credential can't be loaded + this.skip(); + } + }); + + describe('supportsNFC() - EAD from URL', function() { + it('should return false for EAD credential without renderMethod', + function() { + // Skip if credential wasn't loaded + if(!eadCredential) { + this.skip(); + } + + // Destructure to exclude renderMethod + // Create credential copy without renderMethod + const credentialWithoutRenderMethod = {...eadCredential}; + delete credentialWithoutRenderMethod.renderMethod; + + const result = webWallet.nfcRenderer.supportsNFC({ + credential: credentialWithoutRenderMethod + }); + + should.exist(result); + result.should.equal(false); + } + ); + + it('should return true for EAD credential with renderMethod', + function() { + if(!eadCredential) { + this.skip(); + } + + const result = webWallet.nfcRenderer.supportsNFC({ + credential: eadCredential + }); + + should.exist(result); + result.should.equal(true); + } + ); + + it('should return true when adding nfc renderMethod with renderProperty', + function() { + if(!eadCredential) { + this.skip(); + } + + const credentialWithDynamic = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + renderProperty: ['/credentialSubject/givenName'] + // Note: no template, but supportsNFC() only checks capability + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({ + credential: credentialWithDynamic + }); + + should.exist(result); + result.should.equal(true); + } + ); + + it('should return true when adding nfc renderMethod with template', + function() { + if(!eadCredential) { + this.skip(); + } + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({ + credential + }); + + should.exist(result); + result.should.equal(true); + } + ); + }); + + describe('renderToNfc() - EAD Template Required Tests', function() { + it('should fail when extracting givenName without template', + async function() { + if(!eadCredential) { + this.skip(); + } + + // In unified architecture, template is required + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + renderProperty: ['/credentialSubject/givenName'] + // No template - should fail! + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('template'); + + } + ); + + it('should succeed when template is provided with renderProperty', + async function() { + if(!eadCredential) { + this.skip(); + } + + // Encode "JOHN" as base58 multibase for template + // Using TextEncoder + base58 encoding + const johnBytes = new TextEncoder().encode('JOHN'); + const base58 = await import('base58-universal'); + const encodedJohn = 'z' + base58.encode(johnBytes); + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: encodedJohn, + renderProperty: ['/credentialSubject/givenName'] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('JOHN'); + } + ); + + it('should validate renderProperty fields exist in credential', + async function() { + if(!eadCredential) { + this.skip(); + } + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + renderProperty: [ + '/credentialSubject/givenName', + '/credentialSubject/additionalName', + '/credentialSubject/familyName' + ] + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.not.exist(err); + should.exist(result); + // Template is decoded, not the credential fields + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Hello NFC'); + } + ); + }); + + describe('renderToNfc() - EAD Template Size Tests', function() { + it('should fail when trying to extract image without template', + async function() { + if(!eadCredential) { + this.skip(); + } + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + renderProperty: ['/credentialSubject/image'] + // No template - should fail! + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + err.message.should.contain('template'); + } + ); + + it('should decode large template successfully', + async function() { + if(!eadCredential) { + this.skip(); + } + + // Get the actual image from credential for comparison + const actualImage = eadCredential.credentialSubject.image; + + // Encode the image as base58 multibase template + const imageBytes = new TextEncoder().encode(actualImage); + const base58 = await import('base58-universal'); + const encodedImage = 'z' + base58.encode(imageBytes); + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // Large template with image data + template: encodedImage, + // Validates field exists + renderProperty: ['/credentialSubject/image'] + } + }; + + const result = await webWallet.nfcRenderer.renderToNfc({credential}); + + should.exist(result); + should.exist(result.bytes); + + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.match(/^data:image\/png;base64,/); + + // Verify it's the full large image (should be > 50KB) + result.bytes.length.should.be.greaterThan(50000); + } + ); + + }); + }); +}); +