From 08c2c5b74fbb99757978814217e33d2f94365310 Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Tue, 11 Nov 2025 17:42:10 -0800 Subject: [PATCH 1/5] Add NFC renderer for verifiable credentials. Implements NFC rendering support for verifiable credentials following the W3C VC Rendering Methods specification. Provides both static and dynamic rendering modes: - Static mode: Decodes pre-encoded payloads from template/payload fields - Dynamic mode: Extracts credential data using JSON pointer paths - Supports multibase (base58/base64url) and data URI encoding - Compatible with W3C spec renderSuite types (nfc, nfc-static, nfc-dynamic) - Maintains backward compatibility with legacy NfcRenderingTemplate2024 Public API includes supportsNFC() to check NFC capability and renderToNfc() to generate NFC payload bytes from credentials. --- lib/nfcRenderer.js | 468 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 lib/nfcRenderer.js diff --git a/lib/nfcRenderer.js b/lib/nfcRenderer.js new file mode 100644 index 0000000..793fab1 --- /dev/null +++ b/lib/nfcRenderer.js @@ -0,0 +1,468 @@ +/** + * VC NFC Renderer Library + * Handles NFC rendering for verifiable credentials. + * Supports both static and dynamic rendering modes. + */ +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. + * + * Supports both static (pre-encoded) and dynamic (runtime extraction) + * rendering modes based on the renderSuite value: + * - "nfc-static": Uses template/payload 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({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 +// ======================== + +/** + * Find the NFC-compatible render method in a verifiable credential. + * + * @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; +} + +/** + * 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'; +} + +/** + * 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 template or payload field. + */ +function _hasStaticPayload({renderMethod} = {}) { + if(renderMethod.template || renderMethod.payload) { + return true; + } + return false; +} + +// ======================== +// Static rendering +// ======================== + +/** + * 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} = {}) { + + // get the payload from template or payload field + const encoded = renderMethod.template || renderMethod.payload; + + if(!encoded) { + throw new Error( + 'Static NFC render method has no template or payload field.' + ); + } + + 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 +// ======================== + +/** + * 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}); +} + +/** + * 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; +} + +/** + * 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. + * + * @private + * @param {object} options - Options object. + * @param {string} options.dataUri - Data URI string. + * @returns {Uint8Array} Decoded bytes. + */ +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]; + + // 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 +}; From f8a9d94ea02abbb59deb4516df9f6f93725a8bf4 Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Tue, 11 Nov 2025 17:43:29 -0800 Subject: [PATCH 2/5] Add test suite for NFC renderer. Implements test suite covering NFC rendering functionality: - Tests supportsNFC() with various renderSuite configurations - Tests renderToNfc() static rendering with multibase, base64url, and data URIs - Tests renderToNfc() dynamic rendering with renderProperty extraction - Tests EAD credential handling with single/multiple field extraction - Tests legacy NfcRenderingTemplate2024 type compatibility - Tests error handling for invalid credentials and missing parameters - Includes real-world credential tests fetched from external sources Ensures compliance with W3C VC Rendering Methods specification. --- test/web/15-nfc-renderer.js | 1035 +++++++++++++++++++++++++++++++++++ 1 file changed, 1035 insertions(+) create mode 100644 test/web/15-nfc-renderer.js diff --git a/test/web/15-nfc-renderer.js b/test/web/15-nfc-renderer.js new file mode 100644 index 0000000..49ba3b4 --- /dev/null +++ b/test/web/15-nfc-renderer.js @@ -0,0 +1,1035 @@ +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-static renderSuite', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-static', + template: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(true); + } + ); + + it('should return true for credential with nfc-dynamic renderSuite', + async () => { + 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-dynamic', + renderProperty: ['/credentialSubject/name'] + } + }; + + 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); + } + ); + + it('should work with legacy NfcRenderingTemplate2024 using payload field', + async () => { + const credential = { + renderMethod: { + type: 'NfcRenderingTemplate2024', + // Using 'payload', not 'template' + payload: 'z2drAj5bAkJFsTPKmBvG3Z' + } + }; + const result = await webWallet.nfcRenderer.renderToNfc({credential}); + should.exist(result.bytes); + } + ); + + it('should return true for legacy NfcRenderingTemplate2024 type', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'NfcRenderingTemplate2024', + template: '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-static', + 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); + } + ); + }); + + describe('renderToNfc() - Static Rendering', function() { + it('should successfully render static NFC with multibase-encoded template', + async () => { + // Base58 encoded "Hello NFC" + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-static', + 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 payload', + async () => { + // Base64URL encoded "Test Data" + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-static', + payload: '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 () => { + // Base64 encoded "NFC Data" + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-static', + 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'); + } + ); + + it('should use template field when both template and payload exist', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-static', + // "Hello NFC" + template: 'z2drAj5bAkJFsTPKmBvG3Z', + // "Different" + payload: 'uRGlmZmVyZW50' + } + }; + + 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); + } + ); + + it('should work with legacy NfcRenderingTemplate2024 type', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'NfcRenderingTemplate2024', + 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); + } + ); + + it('should fail when static payload is missing', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-static' + // No template or payload 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 or payload'); + } + ); + + it('should fail when payload encoding is invalid', + async () => { + const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-static', + 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'); + } + ); + }); + + describe('renderToNfc() - Dynamic Rendering', function() { + it('should successfully render dynamic NFC with single renderProperty', + async () => { + 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-dynamic', + 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); + result.bytes.should.be.an.instanceof(Uint8Array); + // Verify content + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Alice Smith'); + } + ); + + it('should successfully render dynamic NFC with multiple renderProperty', + 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-dynamic', + 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); + should.exist(result.bytes); + // Verify content (concatenated) + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('AliceSmith'); + } + ); + + 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'); + + } + ); + + 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'); + } + ); + + 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'); + } + ); + + 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 fail when renderProperty is missing', + 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-dynamic' + // No 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('renderProperty'); + } + ); + + it('should fail when renderProperty path does not 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-dynamic', + 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 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'); + } + ); + }); + + 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 payload 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); + } + ); + }); + + 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-dynamic renderMethod', + function() { + if(!eadCredential) { + this.skip(); + } + + const credentialWithDynamic = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-dynamic', + renderProperty: ['/credentialSubject/givenName'] + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({ + credential: credentialWithDynamic + }); + + should.exist(result); + result.should.equal(true); + } + ); + + it('should return true when adding nfc-static renderMethod', + function() { + if(!eadCredential) { + this.skip(); + } + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-static', + template: 'z2drAj5bAkJFsTPKmBvG3Z' + } + }; + + const result = webWallet.nfcRenderer.supportsNFC({ + credential + }); + + should.exist(result); + result.should.equal(true); + } + ); + }); + + describe('renderToNfc() - EAD Single Field Extraction', function() { + it('should extract givenName from EAD credential', + async function() { + if(!eadCredential) { + this.skip(); + } + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-dynamic', + 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 extract familyName from EAD credential', + async function() { + if(!eadCredential) { + this.skip(); + } + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-dynamic', + renderProperty: ['/credentialSubject/familyName'] + } + }; + + const result = await webWallet.nfcRenderer.renderToNfc({credential}); + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('SMITH'); + } + ); + + it('should extract full name (concatenated)', + async function() { + if(!eadCredential) { + this.skip(); + } + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-dynamic', + renderProperty: [ + '/credentialSubject/givenName', + '/credentialSubject/additionalName', + '/credentialSubject/familyName' + ] + } + }; + + const result = await webWallet.nfcRenderer.renderToNfc({credential}); + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('JOHNJACOBSMITH'); + } + ); + }); + + describe('renderToNfc() - EAD Image Data', function() { + it('should extract large image data URI', + async function() { + if(!eadCredential) { + this.skip(); + } + + const credential = { + ...eadCredential, + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc-dynamic', + renderProperty: ['/credentialSubject/image'] + } + }; + + const result = await webWallet.nfcRenderer.renderToNfc({credential}); + + should.exist(result); + should.exist(result.bytes); + + const decoded = new TextDecoder().decode(result.bytes); + // Use regex to check starts with + decoded.should.match(/^data:image\/png;base64,/); + + // Verify it's the full large image (should be > 50KB) + result.bytes.length.should.be.greaterThan(50000); + } + ); + }); + + }); + +}); + From 7dd93f0dedeacfe1388f3406ecb8d1eef7e65429 Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Tue, 11 Nov 2025 17:44:59 -0800 Subject: [PATCH 3/5] Refactor NFC rendering to use new nfcRenderer library. Replaces legacy NFC rendering implementation in helper.js with calls to the new standardized nfcRenderer library functions: - Replace toNFCPayload() implementation with renderToNfc() call - Replace hasNFCPayload() implementation with supportsNFC() call - Remove unused base58 and base64url imports - Comment out legacy helper functions: _getNFCRenderingTemplate2024() and _decodeMultibase() - Comment out multibaseDecoders constant (now handled in nfcRenderer.js) This change consolidates NFC rendering logic into a single reusable library while maintaining backward compatibility with existing API. The new implementation supports both W3C spec formats and legacy NfcRenderingTemplate2024 type. --- lib/helpers.js | 126 +++++++++++++++++++++++++++---------------------- lib/index.js | 3 +- 2 files changed, 71 insertions(+), 58 deletions(-) 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 From 83386f155de3df6aef941f65e6862ae9047d373e Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Fri, 14 Nov 2025 19:10:16 -0800 Subject: [PATCH 4/5] Enforce strict field validation for NFC render methods. Implements field validation: - TemplateRenderMethod: requires 'template' field only (W3C spec) - NfcRenderingTemplate2024: requires 'payload' field only (legacy) - Rejects credentials with both fields present Updates _renderStatic() and _hasStaticPayload() functions with explicit field checking and clear error messages. Adds comprehensive validation tests and fixes existing tests to use correct fields for their respective render method types. --- lib/nfcRenderer.js | 62 +++++++++++++++++++++++----- test/web/15-nfc-renderer.js | 81 +++++++++++++++++++++++-------------- 2 files changed, 102 insertions(+), 41 deletions(-) diff --git a/lib/nfcRenderer.js b/lib/nfcRenderer.js index 793fab1..275e269 100644 --- a/lib/nfcRenderer.js +++ b/lib/nfcRenderer.js @@ -2,6 +2,10 @@ * 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'; @@ -40,7 +44,7 @@ export function supportsNFC({credential} = {}) { * * Supports both static (pre-encoded) and dynamic (runtime extraction) * rendering modes based on the renderSuite value: - * - "nfc-static": Uses template/payload field. + * - "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. * @@ -172,12 +176,27 @@ function _getRenderSuite({renderMethod} = {}) { * @private * @param {object} options - Options object. * @param {object} options.renderMethod - The render method object. - * @returns {boolean} - 'true' if has template or payload field. + * @returns {boolean} - 'true' if has appropriate field for render method type. */ function _hasStaticPayload({renderMethod} = {}) { - if(renderMethod.template || renderMethod.payload) { - return true; + // 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; } @@ -194,13 +213,36 @@ function _hasStaticPayload({renderMethod} = {}) { */ async function _renderStatic({renderMethod} = {}) { - // get the payload from template or payload field - const encoded = renderMethod.template || renderMethod.payload; + // enforce field usage based on render method type + let encoded; - if(!encoded) { - throw new Error( - 'Static NFC render method has no template or payload field.' - ); + // 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') { diff --git a/test/web/15-nfc-renderer.js b/test/web/15-nfc-renderer.js index 49ba3b4..9f45253 100644 --- a/test/web/15-nfc-renderer.js +++ b/test/web/15-nfc-renderer.js @@ -63,20 +63,7 @@ describe('NFC Renderer', function() { } ); - it('should work with legacy NfcRenderingTemplate2024 using payload field', - async () => { - const credential = { - renderMethod: { - type: 'NfcRenderingTemplate2024', - // Using 'payload', not 'template' - payload: 'z2drAj5bAkJFsTPKmBvG3Z' - } - }; - const result = await webWallet.nfcRenderer.renderToNfc({credential}); - should.exist(result.bytes); - } - ); - + // Check one more time - return false and not work with template field it('should return true for legacy NfcRenderingTemplate2024 type', async () => { const credential = { @@ -84,7 +71,7 @@ describe('NFC Renderer', function() { type: ['VerifiableCredential'], renderMethod: { type: 'NfcRenderingTemplate2024', - template: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' + payload: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' } }; @@ -182,7 +169,7 @@ describe('NFC Renderer', function() { } ); - it('should successfully render static NFC with base64url-encoded payload', + it('should successfully render static NFC with base64url-encoded template', async () => { // Base64URL encoded "Test Data" const credential = { @@ -191,7 +178,7 @@ describe('NFC Renderer', function() { renderMethod: { type: 'TemplateRenderMethod', renderSuite: 'nfc-static', - payload: 'uVGVzdCBEYXRh' + template: 'uVGVzdCBEYXRh' } }; @@ -240,8 +227,8 @@ describe('NFC Renderer', function() { decoded.should.equal('NFC Data'); } ); - - it('should use template field when both template and payload exist', + // Check one more time - as template and payload should never exist. + it('should fail when TemplateRenderMethod has both template and payload', async () => { const credential = { '@context': ['https://www.w3.org/ns/credentials/v2'], @@ -264,19 +251,22 @@ describe('NFC Renderer', function() { err = e; } - should.not.exist(err); - should.exist(result); - should.exist(result.bytes); + should.exist(err); + should.not.exist(result); + err.message.should.contain( + 'TemplateRenderMethod requires "template" and should not have both'); } ); - it('should work with legacy NfcRenderingTemplate2024 type', + // template field instead of payload + 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' } }; @@ -289,13 +279,14 @@ describe('NFC Renderer', function() { err = e; } - should.not.exist(err); - should.exist(result); - should.exist(result.bytes); + should.exist(err); + should.not.exist(result); + err.message.should.contain( + 'NfcRenderingTemplate2024 requires "payload"'); } ); - - it('should fail when static payload is missing', + // Check one more time - no template field + it('should fail TemplateRenderMethod has no template field', async () => { const credential = { '@context': ['https://www.w3.org/ns/credentials/v2'], @@ -303,7 +294,7 @@ describe('NFC Renderer', function() { renderMethod: { type: 'TemplateRenderMethod', renderSuite: 'nfc-static' - // No template or payload field + // No template field } }; @@ -317,7 +308,7 @@ describe('NFC Renderer', function() { should.exist(err); should.not.exist(result); - err.message.should.contain('template or payload'); + err.message.should.contain('TemplateRenderMethod requires "template"'); } ); @@ -346,6 +337,34 @@ describe('NFC Renderer', function() { 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); + } + ); }); describe('renderToNfc() - Dynamic Rendering', function() { @@ -711,7 +730,7 @@ describe('NFC Renderer', function() { } ); - it('should fail when neither payload nor renderProperty exist', + it('should fail when neither template nor renderProperty exist', async () => { const credential = { '@context': ['https://www.w3.org/ns/credentials/v2'], From 4c3f1deae39472f790b40e323e8183701a66a899 Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Wed, 19 Nov 2025 20:35:33 -0800 Subject: [PATCH 5/5] Refactor NFC renderer to unified pipeline architecture. Replace separate static/dynamic rendering with unified pipeline where template is always required and renderProperty validates credential fields. Update implementation and tests to reflect single rendering flow: filter credential -> decode template -> return bytes. Changes: - Consolidate rendering functions into _decodeTemplateToBytes(). - Add _filterCredential() for renderProperty validation. - Remove obsolete static vs dynamic distinction. - Update all tests to match unified architecture. - Fix test encodings and add comprehensive error coverage. --- lib/nfcRenderer.js | 562 +++++++++++++++++------- test/web/15-nfc-renderer.js | 827 +++++++++++++++++++++++++++--------- 2 files changed, 1022 insertions(+), 367 deletions(-) diff --git a/lib/nfcRenderer.js b/lib/nfcRenderer.js index 275e269..d6aa1ad 100644 --- a/lib/nfcRenderer.js +++ b/lib/nfcRenderer.js @@ -42,18 +42,24 @@ export function supportsNFC({credential} = {}) { /** * 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. + * 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} = {}) { - // find NFC-compatible render method + // finc NFC-compatible render method const renderMethod = _findNFCRenderMethod({credential}); if(!renderMethod) { @@ -62,53 +68,173 @@ export async function renderToNfc({credential} = {}) { ); } - // 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.'); + // 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.' + ); } - 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}`); + // Step 1: Filter credential if renderProperty exists + let filteredData = null; + if(renderMethod.renderProperty && renderMethod.renderProperty.length > 0) { + filteredData = _filterCredential({credential, renderMethod}); } - // wrap in object for consistent return format + // 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. @@ -147,6 +273,7 @@ function _findNFCRenderMethod({credential} = {}) { return null; } +// TODO: Delete later. /** * Get the render suite with fallback for legacy formats. * @@ -155,21 +282,22 @@ function _findNFCRenderMethod({credential} = {}) { * @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'; -} - +// 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. * @@ -178,93 +306,194 @@ function _getRenderSuite({renderMethod} = {}) { * @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; -} +// 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; +// } // ======================== -// Static rendering +// NFC rendering engine // ======================== /** - * Render static NFC payload. + * 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 {Promise} - NFC payload as bytes. + * @returns {string} - Encoded template string. + * @throws {Error} - If validation fails. */ -async function _renderStatic({renderMethod} = {}) { - - // enforce field usage based on render method type +function _extractTemplate({renderMethod} = {}) { let encoded; - // get the payload from template or payload field - // const encoded = renderMethod.template || renderMethod.payload; - if(renderMethod.type === 'NfcRenderingTemplate2024') { + // check W3C spec format first + if(renderMethod.type === 'TemplateRenderMethod') { + // validate: should not have both fields if(renderMethod.template && renderMethod.payload) { - throw new Error('NfcRenderingTemplate2024 should not have' + - ' both template and payload fields.' + throw new Error( + 'TemplateRenderMethod requires "template". ' + + 'It should not have both fields.' ); } - // legacy format: ONLY accept 'payload' field - encoded = renderMethod.payload; + + encoded = renderMethod.template; if(!encoded) { - throw new Error('NfcRenderingTemplate2024 requires "payload" field.'); + throw new Error('TemplateRenderMethod requires "template" field.'); } - } else if(renderMethod.type === 'TemplateRenderMethod') { - // W3C Spec format: ONLY accept 'template' field + // check legacy format + } else if(renderMethod.type === 'NfcRenderingTemplate2024') { + // validate: should not have both fields if(renderMethod.template && renderMethod.payload) { - throw new Error('TemplateRenderMethod requires "template"' + - ' and should not have both fields.' + throw new Error( + 'NfcRenderingTemplate2024 should not have both template ' + + 'and payload fields.' ); } - encoded = renderMethod.template; + encoded = renderMethod.payload; if(!encoded) { - throw new Error('TemplateRenderMethod requires "template" field.'); + throw new Error('NfcRenderingTemplate2024 requires "payload" field.'); } } else { - // This should never happen given _findNFCRenderMethod() logic 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.'); } - // decoded based on format + // Rendering: Decode the template to bytes + const bytes = await _decodeTemplate({encoded}); + + return bytes; +} + +async function _decodeTemplate({encoded} = {}) { + // data URI format if(encoded.startsWith('data:')) { - // data URI format return _decodeDataUri({dataUri: encoded}); } + + // multibase format (base58 'z' or base64url 'u') if(encoded[0] === 'z' || encoded[0] === 'u') { - // multibase format return _decodeMultibase({input: encoded}); } - throw new Error('Unknown payload encoding format'); + + 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. @@ -274,40 +503,41 @@ async function _renderStatic({renderMethod} = {}) { * @param {object} options.credential - The verifiable credential. * @returns {Promise} - NFC payload as bytes. */ -async function _renderDynamic( - {renderMethod, credential} = {}) { +// async function _renderDynamic( +// {renderMethod, credential} = {}) { - // validate renderProperty exists - if(!renderMethod.renderProperty) { - throw new Error('Dynamic NFC rendering requires renderProperty.'); - } +// // 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]; +// // 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.'); - } +// if(propertyPaths.length === 0) { +// throw new Error('renderProperty cannot be empty.'); +// } - // extract values from a verifiable credential using JSON pointers - const extractedValues = []; +// // extract values from a verifiable credential using JSON pointers +// const extractedValues = []; - for(const path of propertyPaths) { - const value = _resolveJSONPointer({obj: credential, pointer: path}); +// for(const path of propertyPaths) { +// const value = _resolveJSONPointer({obj: credential, pointer: path}); - if(value === undefined) { - throw new Error(`Property not found in credential: ${path}`); - } +// if(value === undefined) { +// throw new Error(`Property not found in credential: ${path}`); +// } - extractedValues.push({path, value}); - } +// extractedValues.push({path, value}); +// } - // build the NFC payload from extracted values - return _buildDynamicPayload( - {extractedValues}); -} +// // build the NFC payload from extracted values +// return _buildDynamicPayload( +// {extractedValues}); +// } +// TODO: Delete later. /** * Build NFC payload from extracted credential values. * @@ -316,29 +546,30 @@ async function _renderDynamic( * @param {Array} options.extractedValues - Extracted values with paths. * @returns {Uint8Array} - NFC payload as bytes. */ -function _buildDynamicPayload({extractedValues} = {}) { +// function _buildDynamicPayload({extractedValues} = {}) { - // simple concatenation of UTF-8 encoded values - const chunks = []; +// // simple concatenation of UTF-8 encoded values +// const chunks = []; - for(const item of extractedValues) { - const valueBytes = _encodeValue({value: item.value}); - chunks.push(valueBytes); - } +// 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); +// // 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; - } +// let offset = 0; +// for(const chunk of chunks) { +// result.set(chunk, offset); +// offset += chunk.length; +// } - return result; -} +// return result; +// } +// TODO: Delete later. /** * Encode a value to bytes. * @@ -347,22 +578,22 @@ function _buildDynamicPayload({extractedValues} = {}) { * @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)); -} +// 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 @@ -370,11 +601,13 @@ function _encodeValue({value} = {}) { /** * 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 @@ -384,10 +617,19 @@ function _decodeDataUri({dataUri} = {}) { throw new Error('Invalid data URI format.'); } - // const mimeType = match[1]; + 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}); diff --git a/test/web/15-nfc-renderer.js b/test/web/15-nfc-renderer.js index 9f45253..4c43808 100644 --- a/test/web/15-nfc-renderer.js +++ b/test/web/15-nfc-renderer.js @@ -5,14 +5,14 @@ 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-static renderSuite', + 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-static', + renderSuite: 'nfc', template: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' } }; @@ -23,26 +23,30 @@ describe('NFC Renderer', function() { } ); - it('should return true for credential with nfc-dynamic renderSuite', - async () => { - 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-dynamic', - renderProperty: ['/credentialSubject/name'] - } - }; + 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); - } + const result = webWallet.nfcRenderer.supportsNFC({credential}); + should.exist(result); + result.should.equal(true); + } ); it('should return true for credential with generic nfc renderSuite', @@ -63,7 +67,7 @@ describe('NFC Renderer', function() { } ); - // Check one more time - return false and not work with template field + // Legacy format uses 'payload' field instead of 'template' it('should return true for legacy NfcRenderingTemplate2024 type', async () => { const credential = { @@ -93,7 +97,7 @@ describe('NFC Renderer', function() { }, { type: 'TemplateRenderMethod', - renderSuite: 'nfc-static', + renderSuite: 'nfc', template: 'z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2rJQ' } ] @@ -137,18 +141,37 @@ describe('NFC Renderer', function() { 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() - Static Rendering', function() { + describe('renderToNfc() - Template Decoding', function() { it('should successfully render static NFC with multibase-encoded template', async () => { - // Base58 encoded "Hello NFC" + // 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-static', + renderSuite: 'nfc', template: 'z2drAj5bAkJFsTPKmBvG3Z' } }; @@ -177,7 +200,7 @@ describe('NFC Renderer', function() { type: ['VerifiableCredential'], renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-static', + renderSuite: 'nfc', template: 'uVGVzdCBEYXRh' } }; @@ -199,13 +222,13 @@ describe('NFC Renderer', function() { it('should successfully render static NFC with data URI format', async () => { - // Base64 encoded "NFC Data" + // Data URI with base64 encoded "NFC Data" const credential = { '@context': ['https://www.w3.org/ns/credentials/v2'], type: ['VerifiableCredential'], renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-static', + renderSuite: 'nfc', template: 'data:application/octet-stream;base64,TkZDIERhdGE=' } }; @@ -227,7 +250,8 @@ describe('NFC Renderer', function() { decoded.should.equal('NFC Data'); } ); - // Check one more time - as template and payload should never exist. + + // Field validation: TemplateRenderMethod uses 'template', not 'payload' it('should fail when TemplateRenderMethod has both template and payload', async () => { const credential = { @@ -235,7 +259,7 @@ describe('NFC Renderer', function() { type: ['VerifiableCredential'], renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-static', + renderSuite: 'nfc', // "Hello NFC" template: 'z2drAj5bAkJFsTPKmBvG3Z', // "Different" @@ -253,12 +277,11 @@ describe('NFC Renderer', function() { should.exist(err); should.not.exist(result); - err.message.should.contain( - 'TemplateRenderMethod requires "template" and should not have both'); + err.message.should.contain('template'); } ); - // template field instead of payload + // Field validation: NfcRenderingTemplate2024 uses 'payload', not 'template' it('should fail when NfcRenderingTemplate2024 uses template field', async () => { const credential = { @@ -281,11 +304,11 @@ describe('NFC Renderer', function() { should.exist(err); should.not.exist(result); - err.message.should.contain( - 'NfcRenderingTemplate2024 requires "payload"'); + err.message.should.contain('payload'); } ); - // Check one more time - no template field + + // Template is required for all NFC rendering it('should fail TemplateRenderMethod has no template field', async () => { const credential = { @@ -293,7 +316,7 @@ describe('NFC Renderer', function() { type: ['VerifiableCredential'], renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-static' + renderSuite: 'nfc' // No template field } }; @@ -308,18 +331,18 @@ describe('NFC Renderer', function() { should.exist(err); should.not.exist(result); - err.message.should.contain('TemplateRenderMethod requires "template"'); + err.message.should.contain('template'); } ); - it('should fail when payload encoding is invalid', + 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-static', + renderSuite: 'nfc', template: 'xInvalidEncoding123' } }; @@ -365,22 +388,24 @@ describe('NFC Renderer', function() { result.bytes.should.be.an.instanceof(Uint8Array); } ); - }); - describe('renderToNfc() - Dynamic Rendering', function() { - it('should successfully render dynamic NFC with single renderProperty', + 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: { - id: 'did:example:123', - name: 'Alice Smith' + greeting: 'Hello' }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', - renderProperty: ['/credentialSubject/name'] + renderSuite: 'nfc', + // "Hello NFC" as base64 in data URI format + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + // For transparency + renderProperty: ['/credentialSubject/greeting'] } }; @@ -395,30 +420,28 @@ describe('NFC Renderer', function() { should.not.exist(err); should.exist(result); should.exist(result.bytes); - result.bytes.should.be.an.instanceof(Uint8Array); - // Verify content + + // Should decode template, renderProperty is for transparency only const decoded = new TextDecoder().decode(result.bytes); - decoded.should.equal('Alice Smith'); + decoded.should.equal('Hello NFC'); } ); - it('should successfully render dynamic NFC with multiple renderProperty', + 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: { - id: 'did:example:123', - firstName: 'Alice', - lastName: 'Smith' + name: 'Alice' }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', - renderProperty: [ - '/credentialSubject/firstName', - '/credentialSubject/lastName' - ] + renderSuite: 'nfc', + template: 'z2drAj5bAkJFsTPKmBvG3Z', + // Doesn't exist! + renderProperty: ['/credentialSubject/nonExistent'] } }; @@ -430,28 +453,28 @@ describe('NFC Renderer', function() { err = e; } - should.not.exist(err); - should.exist(result); - should.exist(result.bytes); - // Verify content (concatenated) - const decoded = new TextDecoder().decode(result.bytes); - decoded.should.equal('AliceSmith'); + should.exist(err); + should.not.exist(result); + err.message.should.contain('Property not found'); } ); + }); - it('should handle numeric values in dynamic rendering', + 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', - age: 25 + name: 'Alice Smith' }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', - renderProperty: ['/credentialSubject/age'] + renderSuite: 'nfc', + renderProperty: ['/credentialSubject/name'] } }; @@ -463,31 +486,32 @@ describe('NFC Renderer', function() { err = e; } - should.not.exist(err); - should.exist(result); - should.exist(result.bytes); - const decoded = new TextDecoder().decode(result.bytes); - decoded.should.equal('25'); - + should.exist(err); + should.not.exist(result); + err.message.should.contain('template'); } ); - it('should handle object values in dynamic rendering', + 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', - address: { - street: '123 Main St', - city: 'Boston' - } + name: 'Alice Smith' }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', - renderProperty: ['/credentialSubject/address'] + renderSuite: 'nfc', + // "Hello NFC" encoded + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + // Validates field exists + renderProperty: [ + '/credentialSubject/name', + ] } }; @@ -502,27 +526,168 @@ describe('NFC Renderer', function() { should.not.exist(err); should.exist(result); should.exist(result.bytes); - // Should be JSON stringified + // Should decode template, NOT extract "Alice Smith" const decoded = new TextDecoder().decode(result.bytes); - const parsed = JSON.parse(decoded); - parsed.street.should.equal('123 Main St'); - parsed.city.should.equal('Boston'); + decoded.should.equal('Hello NFC'); + decoded.should.not.equal('Alice Smith'); } ); - it('should handle array access in JSON pointer', + // 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', - skills: ['JavaScript', 'Python', 'Rust'] + name: 'Alice' }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', - renderProperty: ['/credentialSubject/skills/0'] + renderSuite: 'nfc', + // "Hello NFC" + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD' + // No renderProperty } }; @@ -538,23 +703,26 @@ describe('NFC Renderer', function() { should.exist(result); should.exist(result.bytes); const decoded = new TextDecoder().decode(result.bytes); - decoded.should.equal('JavaScript'); + decoded.should.equal('Hello NFC'); } ); - it('should handle special characters in JSON pointer', + 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', - 'field/with~slash': 'test-value' + name: 'Alice' }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', - renderProperty: ['/credentialSubject/field~1with~0slash'] + renderSuite: 'nfc', + // valid template + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + renderProperty: ['/credentialSubject/nonExistentField'] } }; @@ -566,27 +734,30 @@ describe('NFC Renderer', function() { 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'); + should.exist(err); + should.not.exist(result); + err.message.should.contain('Property not found'); } ); - it('should fail when renderProperty is missing', + 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', - name: 'Alice' + firstName: 'Alice', + lastName: 'Smith' }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic' - // No renderProperty + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + renderProperty: [ + '/credentialSubject/firstName', + '/credentialSubject/lastName' + ] } }; @@ -598,25 +769,29 @@ describe('NFC Renderer', function() { err = e; } - should.exist(err); - should.not.exist(result); - err.message.should.contain('renderProperty'); + 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 fail when renderProperty path does not exist', + 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', - name: 'Alice' + id: 'did:example:123' }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', - renderProperty: ['/credentialSubject/nonExistentField'] + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', + // Empty is OK + renderProperty: [] } }; @@ -628,24 +803,175 @@ describe('NFC Renderer', function() { err = e; } - should.exist(err); - should.not.exist(result); - err.message.should.contain('Property not found'); + should.not.exist(err); + should.exist(result); + const decoded = new TextDecoder().decode(result.bytes); + decoded.should.equal('Hello NFC'); } ); - it('should fail when renderProperty is empty array', + // 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: 'nfc-dynamic', - renderProperty: [] + renderSuite: 'unsupported-suite', + template: 'some-data' } }; @@ -659,59 +985,62 @@ describe('NFC Renderer', function() { should.exist(err); should.not.exist(result); - err.message.should.contain('cannot be empty'); + err.message.should.contain('does not support NFC rendering'); } ); - }); - 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'] + it('should fail when credential parameter is missing', + async () => { + let result; + let err; + try { + result = await webWallet.nfcRenderer.renderToNfc({}); + } catch(e) { + err = e; } - }; - let result; - let err; - try { - result = await webWallet.nfcRenderer.renderToNfc({credential}); - } catch(e) { - err = e; + should.exist(err); + should.not.exist(result); } + ); - 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 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 fallback to dynamic rendering when only renderProperty exists', + it('should fail when data URI format is malformed', 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'] + // Malformed data URI (missing encoding or data) + template: 'data:application/octet-stream' } }; @@ -723,24 +1052,22 @@ describe('NFC Renderer', function() { err = e; } - should.not.exist(err); - should.exist(result); - const decoded = new TextDecoder().decode(result.bytes); - decoded.should.equal('Bob'); + should.exist(err); + should.not.exist(result); + err.message.should.contain('Invalid data URI'); } ); - it('should fail when neither template nor renderProperty exist', + it('should fail when multibase encoding is unsupported', async () => { const credential = { '@context': ['https://www.w3.org/ns/credentials/v2'], type: ['VerifiableCredential'], - credentialSubject: { - id: 'did:example:123' - }, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc' + renderSuite: 'nfc', + // 'f' is base16 multibase - not supported by implementation + template: 'f48656c6c6f' } }; @@ -754,19 +1081,20 @@ describe('NFC Renderer', function() { should.exist(err); should.not.exist(result); - err.message.should.contain('neither payload nor renderProperty'); + err.message.should.contain('encoding format'); } ); - }); - describe('renderToNfc() - Error Cases', function() { - it('should fail when credential has no renderMethod', + it('should fail when data URI encoding is unsupported', async () => { const credential = { '@context': ['https://www.w3.org/ns/credentials/v2'], type: ['VerifiableCredential'], - credentialSubject: { - id: 'did:example:123' + renderMethod: { + type: 'TemplateRenderMethod', + renderSuite: 'nfc', + // hex encoding is not supported + template: 'data:application/octet-stream;hex,48656c6c6f' } }; @@ -780,19 +1108,20 @@ describe('NFC Renderer', function() { should.exist(err); should.not.exist(result); - err.message.should.contain('does not support NFC rendering'); + err.message.should.contain('encoding'); } ); - it('should fail when renderSuite is unsupported', + 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: 'unsupported-suite', - template: 'some-data' + renderSuite: 'nfc', + // Invalid base64 characters + template: 'data:application/octet-stream;base64,!!!invalid!!!' } }; @@ -806,16 +1135,30 @@ describe('NFC Renderer', function() { should.exist(err); should.not.exist(result); - err.message.should.contain('does not support NFC rendering'); + // Error message varies by environment (browser vs Node) } ); - it('should fail when credential parameter is missing', + 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({}); + result = await webWallet.nfcRenderer.renderToNfc({credential}); } catch(e) { err = e; } @@ -824,6 +1167,7 @@ describe('NFC Renderer', function() { should.not.exist(result); } ); + }); describe('NFC Renderer - EAD Credential Tests (from URL)', function() { @@ -889,7 +1233,7 @@ describe('NFC Renderer', function() { } ); - it('should return true when adding nfc-dynamic renderMethod', + it('should return true when adding nfc renderMethod with renderProperty', function() { if(!eadCredential) { this.skip(); @@ -899,8 +1243,9 @@ describe('NFC Renderer', function() { ...eadCredential, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', + renderSuite: 'nfc', renderProperty: ['/credentialSubject/givenName'] + // Note: no template, but supportsNFC() only checks capability } }; @@ -913,7 +1258,7 @@ describe('NFC Renderer', function() { } ); - it('should return true when adding nfc-static renderMethod', + it('should return true when adding nfc renderMethod with template', function() { if(!eadCredential) { this.skip(); @@ -923,8 +1268,8 @@ describe('NFC Renderer', function() { ...eadCredential, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-static', - template: 'z2drAj5bAkJFsTPKmBvG3Z' + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD' } }; @@ -938,19 +1283,21 @@ describe('NFC Renderer', function() { ); }); - describe('renderToNfc() - EAD Single Field Extraction', function() { - it('should extract givenName from EAD credential', + 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-dynamic', + renderSuite: 'nfc', renderProperty: ['/credentialSubject/givenName'] + // No template - should fail! } }; @@ -962,36 +1309,52 @@ describe('NFC Renderer', function() { err = e; } - should.not.exist(err); - should.exist(result); + should.exist(err); + should.not.exist(result); + err.message.should.contain('template'); - const decoded = new TextDecoder().decode(result.bytes); - decoded.should.equal('JOHN'); } ); - it('should extract familyName from EAD credential', + 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-dynamic', - renderProperty: ['/credentialSubject/familyName'] + renderSuite: 'nfc', + template: encodedJohn, + renderProperty: ['/credentialSubject/givenName'] } }; - const result = await webWallet.nfcRenderer.renderToNfc({credential}); + 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('SMITH'); + decoded.should.equal('JOHN'); } ); - it('should extract full name (concatenated)', + it('should validate renderProperty fields exist in credential', async function() { if(!eadCredential) { this.skip(); @@ -1001,7 +1364,8 @@ describe('NFC Renderer', function() { ...eadCredential, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', + renderSuite: 'nfc', + template: 'data:application/octet-stream;base64,SGVsbG8gTkZD', renderProperty: [ '/credentialSubject/givenName', '/credentialSubject/additionalName', @@ -1010,15 +1374,25 @@ describe('NFC Renderer', function() { } }; - const result = await webWallet.nfcRenderer.renderToNfc({credential}); + 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('JOHNJACOBSMITH'); + decoded.should.equal('Hello NFC'); } ); }); - describe('renderToNfc() - EAD Image Data', function() { - it('should extract large image data URI', + describe('renderToNfc() - EAD Template Size Tests', function() { + it('should fail when trying to extract image without template', async function() { if(!eadCredential) { this.skip(); @@ -1028,7 +1402,48 @@ describe('NFC Renderer', function() { ...eadCredential, renderMethod: { type: 'TemplateRenderMethod', - renderSuite: 'nfc-dynamic', + 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'] } }; @@ -1039,16 +1454,14 @@ describe('NFC Renderer', function() { should.exist(result.bytes); const decoded = new TextDecoder().decode(result.bytes); - // Use regex to check starts with decoded.should.match(/^data:image\/png;base64,/); // Verify it's the full large image (should be > 50KB) result.bytes.length.should.be.greaterThan(50000); } ); - }); + }); }); - });