diff --git a/package-lock.json b/package-lock.json index e60db887..3c387c3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@keetanetwork/currency-info": "1.2.5", "@keetanetwork/keetanet-client": "0.14.8", + "@xmldom/xmldom": "*", "typia": "9.5.0" }, "devDependencies": { @@ -26,6 +27,9 @@ }, "engines": { "node": "20.18.0" + }, + "optionalDependencies": { + "@xmldom/xmldom": "0.8.11" } }, "node_modules/@ampproject/remapping": { @@ -5611,6 +5615,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", diff --git a/package.json b/package.json index 2db35da8..58281a9a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "typescript": "5.6.3", "vitest": "3.2.4" }, + "optionalDependencies": { + "@xmldom/xmldom": "0.8.11" + }, "dependencies": { "@keetanetwork/currency-info": "1.2.5", "@keetanetwork/keetanet-client": "0.14.8", diff --git a/src/lib/certificates.test.ts b/src/lib/certificates.test.ts index 4a6bd4f0..835e62af 100644 --- a/src/lib/certificates.test.ts +++ b/src/lib/certificates.test.ts @@ -359,3 +359,44 @@ test('Rust Certificate Interoperability', async function() { const contactProof = await contactAttr.value.getProof(); expect(await contactAttr.value.validateProof(contactProof)).toBe(true); }); + +test('Cetificate ISO20022 Serialization', async function() { + /* + * Create account for subject + */ + const subjectAccount = KeetaNetClient.lib.Account.fromSeed( + testSeed, + 0, + KeetaNetClient.lib.Account.AccountKeyAlgorithm.ECDSA_SECP256R1 + ); + + /* Create a Certificate Builder */ + const builder1 = new Certificates.Certificate.Builder({ + issuer: subjectAccount, + subject: subjectAccount, + serial: 5, + validFrom: new Date(), + validTo: new Date(Date.now() + 1000 * 60 * 60 * 24) + }); + + /* + * Create a User Certificate + */ + builder1.setAttribute('fullName', true, 'Test User'); + builder1.setAttribute('email', true, 'user@example.com'); + builder1.setAttribute('dateOfBirth', true, new Date('1990-05-15')); + builder1.setAttribute('dateAndPlaceOfBirth', true, { + birthDate: new Date('1990-05-15'), + cityOfBirth: 'Springfield', + provinceOfBirth: 'IL', + countryOfBirth: 'US' + }); + builder1.setAttribute('contactDetails', true, { + phoneNumber: '+1-555-000-1111' + }); + const certificate = new Certificates.Certificate(await builder1.buildDER(), { + subjectKey: subjectAccount + }); + + console.log(await certificate.toISO20022()); +}); diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts index 95650313..9acea7ef 100644 --- a/src/lib/certificates.ts +++ b/src/lib/certificates.ts @@ -796,6 +796,167 @@ export class Certificate extends KeetaNetClient.lib.Utils.Certificate.Certificat return(false); } + + private static _XMLDOM: Promise | undefined; + private static async getXMLDOM(): Promise { + if (!Certificate._XMLDOM) { + Certificate._XMLDOM = import('@xmldom/xmldom'); + } + return(await Certificate._XMLDOM); + } + + /** + * Emit an XML fragment representing the certificate's attributes + */ + private static async iso200022PartyIdentification135(certificate: Certificate): Promise { + const XMLDOM = await Certificate.getXMLDOM(); + const get = certificate.getAttributeValue.bind(certificate); + const has = function(name: CertificateAttributeNames): name is CertificateAttributeNames { + return(name in certificate.attributes); + } + + const doc = new XMLDOM.DOMImplementation().createDocument(null, 'Fragment', null); + const root = doc.documentElement; + if (has('fullName')) { + const fullName = await get('fullName'); + const nameElement = doc.createElement('Nm'); + nameElement.appendChild(doc.createTextNode(fullName)); + root.appendChild(nameElement); + } + + if (has('address')) { + const address = await get('address'); + const addressElement = doc.createElement('PstlAdr'); + + if (address.country) { + const countryElement = doc.createElement('Ctry'); + countryElement.appendChild(doc.createTextNode(address.country)); + addressElement.appendChild(countryElement); + } + if (address.townName) { + const townElement = doc.createElement('TwnNm'); + townElement.appendChild(doc.createTextNode(address.townName)); + addressElement.appendChild(townElement); + } + + root.appendChild(addressElement); + } + + if (has('dateOfBirth') || has('dateAndPlaceOfBirth') || has('id')) { + const idElement = doc.createElement('Id'); + const privateIdElement = doc.createElement('PrvtId'); + + if (has('dateOfBirth') || has('dateAndPlaceOfBirth')) { + const dob = has('dateOfBirth') ? await get('dateOfBirth') : undefined; + const pob = has('dateAndPlaceOfBirth') ? await get('dateAndPlaceOfBirth') : undefined; + + const dobElement = doc.createElement('DtAndPlcOfBirth'); + if (pob?.birthDate) { + const birthDateElement = doc.createElement('BirthDt'); + birthDateElement.appendChild(doc.createTextNode(pob.birthDate.toISOString().split('T').at(0)!)); + dobElement.appendChild(birthDateElement); + } else if (dob) { + const birthDateElement = doc.createElement('BirthDt'); + birthDateElement.appendChild(doc.createTextNode(dob.toISOString().split('T').at(0)!)); + dobElement.appendChild(birthDateElement); + } + if (pob?.provinceOfBirth) { + const provinceElement = doc.createElement('PrvcOfBirth'); + provinceElement.appendChild(doc.createTextNode(pob.provinceOfBirth)); + dobElement.appendChild(provinceElement); + } + if (pob?.cityOfBirth) { + const cityElement = doc.createElement('CityOfBirth'); + cityElement.appendChild(doc.createTextNode(pob.cityOfBirth)); + dobElement.appendChild(cityElement); + } + if (pob?.countryOfBirth) { + const countryElement = doc.createElement('CtryOfBirth'); + countryElement.appendChild(doc.createTextNode(pob.countryOfBirth)); + dobElement.appendChild(countryElement); + } + + privateIdElement.appendChild(dobElement); + } + + if (has('id')) { + /* XXX:TODO */ + } + + idElement.appendChild(privateIdElement); + root.appendChild(idElement); + } + + if (has('countryOfResidence') || has('address')) { + let cor: string | undefined; + if (has('address')) { + const address = await get('address'); + cor = address.country; + } else if (has('countryOfResidence')) { + cor = await get('countryOfResidence'); + } + + if (cor) { + const corElement = doc.createElement('CtryOfRes'); + corElement.appendChild(doc.createTextNode(cor)); + root.appendChild(corElement); + } + } + + if (has('contactDetails') || has('phoneNumber') || has('email')) { + let contactDetails: Awaited>> | undefined; + if (has('contactDetails')) { + contactDetails = await get('contactDetails'); + } + const contactElement = doc.createElement('CtctDtls'); + + if (contactDetails?.fullName) { + const nameElement = doc.createElement('Nm'); + nameElement.appendChild(doc.createTextNode(contactDetails.fullName)); + contactElement.appendChild(nameElement); + } + + if (contactDetails?.phoneNumber) { + const phoneElement = doc.createElement('PhneNb'); + phoneElement.appendChild(doc.createTextNode(contactDetails.phoneNumber)); + contactElement.appendChild(phoneElement); + } else if (has('phoneNumber')) { + const phoneNumber = await get('phoneNumber'); + const phoneElement = doc.createElement('PhneNb'); + phoneElement.appendChild(doc.createTextNode(phoneNumber)); + contactElement.appendChild(phoneElement); + } + + if (contactDetails?.mobileNumber) { + const mobileElement = doc.createElement('MobNb'); + mobileElement.appendChild(doc.createTextNode(contactDetails.mobileNumber)); + contactElement.appendChild(mobileElement); + } + if (contactDetails?.emailAddress) { + const emailElement = doc.createElement('EmailAdr'); + emailElement.appendChild(doc.createTextNode(contactDetails.emailAddress)); + contactElement.appendChild(emailElement); + } else if (has('email')) { + const email = await get('email'); + const emailElement = doc.createElement('EmailAdr'); + emailElement.appendChild(doc.createTextNode(email)); + contactElement.appendChild(emailElement); + } + + root.appendChild(contactElement); + } + + const output = new XMLDOM.XMLSerializer(); + return('\n' + output.serializeToString(root)); + } + /** + * Emit an ISO20022 Party Report message (XXX: which one ?) + * XXX: TODO + */ + async toISO20022(): Promise { + return(Certificate.iso200022PartyIdentification135(this)); + } + } /** @internal */ diff --git a/src/services/kyc/utils/oids.json b/src/services/kyc/utils/oids.json index f63aa181..c1bf5b46 100644 --- a/src/services/kyc/utils/oids.json +++ b/src/services/kyc/utils/oids.json @@ -48,6 +48,13 @@ "description": "Date of birth", "reference": "https://oid-base.com/get/1.3.6.1.4.1.62675.1.1" }, + "countryOfResidence": { + "oid": [ 1, 3, 6, 1, 4, 1, 62675, 1, 2, 8 ], + "token": "CountryOfResidence", + "type": "UTF8String", + "description": "Country of residence (ISO 3166-1 alpha-2 or alpha-3)", + "reference": "https://oid-base.com/get/1.3.6.1.4.1.62675.1.2.8" + }, "address": { "oid": [ 1, 3, 6, 1, 4, 1, 62675, 1, 2 ], "token": "Address",