diff --git a/lib/helpers.js b/lib/helpers.js index cd48836..5e382ee 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,8 +1,7 @@ /*! * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. */ -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,11 +18,6 @@ const supportedSignerTypes = new Map([ ] ]); -const multibaseDecoders = new Map([ - ['u', base64url], - ['z', base58], -]); - export async function createCapabilities({profileId, request}) { // TODO: validate `request` @@ -178,61 +172,11 @@ 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}; + return renderToNfc({credential}); } 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); + return supportsNfc({credential}); } async function _updateProfileAgentUser({ 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..f5fdcd5 --- /dev/null +++ b/lib/nfcRenderer.js @@ -0,0 +1,419 @@ +/** + * 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'; +import {base64ToBytes} from './util.js'; + +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}); + + // return whether any NFC render method was found + return renderMethod !== null; + } 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 (safety check - should not reach here + // as _findNfcRenderMethod only returns valid methods) + if(!_hasTemplate({renderMethod})) { + throw new Error('NFC render method is missing the "template" field.'); + } + + // 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}; +} + +// ======================== +// 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 + return renderMethod?.template !== undefined; + } + + if(renderMethod.type === 'NfcRenderingTemplate2024') { + // legacy format: check for 'payload' field + return renderMethod?.payload !== undefined; + } + + 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?.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?.startsWith('nfc')) { + // only return if template field exists (valid render method) + if(method.template !== undefined) { + return method; + } + } + } + + // check for legacy format/existing codebase in + // bedrock-web-wallet/lib/helper.js file + if(method.type === 'NfcRenderingTemplate2024') { + // only return if payload field exists (valid render method) + if(method.payload !== undefined) { + return method; + } + } + } + + return null; +} + +// ======================== +// 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') { + encoded = renderMethod.template; + if(!encoded) { + throw new Error('TemplateRenderMethod requires "template" field.'); + } + // check legacy format + } else if(renderMethod.type === 'NfcRenderingTemplate2024') { + 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 TypeError('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 URL format + if(encoded.startsWith('data:')) { + return _decodeDataUrl({dataUrl: 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 URL (data:...) or multibase (z..., u...)' + ); +} + +// ======================== +// Decoding utilities +// ======================== + +/** + * Decode a data URL to bytes. + * Validates media type is application/octet-stream.. + * + * @private + * @param {object} options - Options object. + * @param {string} options.dataUrl - Data URL string. + * @returns {Uint8Array} Decoded bytes. + * @throws {Error} If data URL is invalid or has wrong media type. + */ +function _decodeDataUrl({dataUrl} = {}) { + // parse data URL format: data:mime/type;encoding,data + const match = dataUrl.match(/^data:([^;]+);([^,]+),(.*)$/); + if(!match) { + throw new Error('Invalid data URL 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 URL 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 URL 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); +} + +// ======================== +// 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/lib/util.js b/lib/util.js new file mode 100644 index 0000000..bdd13ac --- /dev/null +++ b/lib/util.js @@ -0,0 +1,25 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ + +/** + * Decode base64 string to bytes (Node.js implmentation). + * + * @param {object} options - Options object. + * @param {string} options.base64String - Base64 encoded string. + * @returns {Uint8Array} Decoded bytes. + */ +export function base64ToBytes({base64String} = {}) { + return new Uint8Array(Buffer.from(base64String, 'base64')); +} + +/** + * Encode bytes to base64 string (Node.js implementation). + * + * @param {object} options - Options object. + * @param {Uint8Array} options.bytes - Bytes to encode. + * @returns {string} Base64 encoded string. + */ +export function bytesToBase64({bytes} = {}) { + return Buffer.from(bytes).toString('base64'); +} diff --git a/lib/utilBrowser.js b/lib/utilBrowser.js new file mode 100644 index 0000000..c2012b1 --- /dev/null +++ b/lib/utilBrowser.js @@ -0,0 +1,46 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ + +/** + * Decode base64 string to bytes (Browser implmentation). + * + * @param {object} options - Options object. + * @param {string} options.base64String - Base64 encoded string. + * @returns {Uint8Array} Decoded bytes. + */ +export function base64ToBytes({base64String} = {}) { + // Use modern API + if(typeof Uint8Array.fromBase64 === 'function') { + return Uint8Array.fromBase64(base64String); + } + + // Fallback to atob + 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; +} + +/** + * Encode bytes to base64 string (Browser implementation). + * + * @param {object} options - Options object. + * @param {Uint8Array} options.bytes - Bytes to encode. + * @returns {string} Base64 encoded string. + */ +export function bytesToBase64({bytes} = {}) { + // Use modern API + if(typeof Uint8Array.prototype.toBase64 === 'function') { + return bytes.toBase64(); + } + + // Fallback to btoa + let binaryString = ''; + for(let i = 0; i < bytes.length; i++) { + binaryString += String.fromCharCode(bytes[i]); + } + return btoa(binaryString); +} diff --git a/package.json b/package.json index a909dac..0198ef8 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Bedrock Web Wallet", "type": "module", "exports": "./lib/index.js", + "browser": { + "./lib/util.js": "./lib/utilBrowser.js" + }, "files": [ "lib/*" ], diff --git a/test/web/15-nfc-renderer.js b/test/web/15-nfc-renderer.js new file mode 100644 index 0000000..5118f25 --- /dev/null +++ b/test/web/15-nfc-renderer.js @@ -0,0 +1,1213 @@ +import * as webWallet from '@bedrock/web-wallet'; + +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 false when template is missing ' + + '(pre-filters invalid methods)', + async () => { + // supportsNfc() should only return true for VALID render methods. + 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(false); + } + ); + + 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 URL format', + async () => { + // Data URL 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 ignore payload field when TemplateRenderMethod has both fields', + async () => { + // Per spec: unknown fields are ignored + // TemplateRenderMethod uses 'template', 'payload' is ignored + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // "Hello NFC" + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + // "Different" + payload: 'uRGlmZmVyZW50' + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + // Should succeed - payload is ignored + should.not.exist(err); + should.exist(result); + should.exist(result.bytes); + + // Verify template was used (not payload) + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Hello NFC'); + decoded.should.not.equal('Different'); + } + ); + + // 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); + // Pre-filtered: render method not found (missing template) + err.message.should.contain('does not support NFC'); + } + ); + + // 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 - pre-filtered as invalid + } + }; + + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({credential}); + } catch(e) { + err = e; + } + + should.exist(err); + should.not.exist(result); + // Pre-filtered: render method not found (missing template) + err.message.should.contain('does not support NFC'); + } + ); + + 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 URL 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 + // Without template, render method is pre-filtered as invalid + 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); + // Pre-filtered: render method not found + err.message.should.contain('does not support NFC'); + } + ); + + 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'); + } + ); + + 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'); + } + ); + }); + + 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 URL 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 URL format is malformed', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // Malformed data URL (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 URL'); + } + ); + + 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 URL 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 false 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); + // Pre-filtered: returns false (not a valid NFC render method) + result.should.equal(false); + } + ); + + 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); + // Pre-filtered: render method not found + err.message.should.contain('does not support NFC'); + } + ); + + 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); + // Pre-filtered: render method not found + err.message.should.contain('does not support NFC'); + } + ); + + 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); + } + ); + + }); + + }); +}); +