diff --git a/src/lib/certificates.test.ts b/src/lib/certificates.test.ts index 4892cfe5..afc30201 100644 --- a/src/lib/certificates.test.ts +++ b/src/lib/certificates.test.ts @@ -1,7 +1,7 @@ import { test, expect } from 'vitest'; import * as Certificates from './certificates.js'; import * as KeetaNetClient from '@keetanetwork/keetanet-client'; -import { arrayBufferToBuffer, bufferToArrayBuffer } from './utils/buffer.js'; +import { bufferToArrayBuffer } from './utils/buffer.js'; import type { Schema as ASN1Schema } from './utils/asn1.js'; import type { CertificateAttributeValue, CertificateAttributeOIDDB } from '../services/kyc/iso20022.generated.ts'; import { ExternalReferenceBuilder } from './utils/external.js'; @@ -60,88 +60,6 @@ async function verifyAttribute( } const testSeed = 'D6986115BE7334E50DA8D73B1A4670A510E8BF47E8C5C9960B8F5248EC7D6E3D'; -const testAccount1 = KeetaNetClient.lib.Account.fromSeed(testSeed, 0); -const testAccount2 = KeetaNetClient.lib.Account.fromSeed(testSeed, 1); - -test('Sensitive Attributes', async function() { - /* - * Build a sensitive attribute with a test value from the users public key - */ - const testAccount1NoPrivate = KeetaNetClient.lib.Account.fromPublicKeyString(testAccount1.publicKeyString.get()); - const builder1 = new Certificates._Testing.SensitiveAttributeBuilder(testAccount1NoPrivate); - const contactDetails: ArrayBuffer = bufferToArrayBuffer(Buffer.from(JSON.stringify({ - fullName: 'Test User', - emailAddress: 'test@example.com', - phoneNumber: '+1 555 911 3808' - }), 'utf-8')); - - builder1.set(contactDetails); - - const attribute = await builder1.build(); - - /* - * Access it with the private key - */ - const sensitiveAttribute1 = new Certificates._Testing.SensitiveAttribute(testAccount1, attribute); - const sensitiveAttribute1Value = await sensitiveAttribute1.getValue(); - expect(Buffer.from(sensitiveAttribute1Value).toString('base64')).toEqual(Buffer.from(contactDetails).toString('base64')); - - /** - * Process the attribute as JSON - */ - const attributeJSON = sensitiveAttribute1.toJSON(); - expect(JSON.parse(JSON.stringify(attributeJSON))).toEqual(attributeJSON); - if (typeof attributeJSON !== 'object' || attributeJSON === null) { - throw(new Error('Expected JSON object')); - } - expect(Object.keys(attributeJSON)).toContain('version'); - expect(Object.keys(attributeJSON)).toContain('cipher'); - expect(Object.keys(attributeJSON)).toContain('publicKey'); - expect(Object.keys(attributeJSON)).toContain('hashedValue'); - expect(Object.keys(attributeJSON)).toContain('encryptedValue'); - expect(Object.keys(attributeJSON).length).toBe(5); - - /* - * Validate it with the public key and value - */ - const sensitiveAttribute1Proof = await sensitiveAttribute1.getProof(); - - const sensitiveAttribute2 = new Certificates._Testing.SensitiveAttribute(testAccount1NoPrivate, attribute); - const sensitiveAttribute2Valid = await sensitiveAttribute2.validateProof(sensitiveAttribute1Proof); - expect(sensitiveAttribute2Valid).toBe(true); - - /* - * Attempt to access it with the wrong private key - */ - const sensitiveAttribute3 = new Certificates._Testing.SensitiveAttribute(testAccount2, attribute); - await expect(async function() { - return(await sensitiveAttribute3.getProof()); - }).rejects.toThrow(); - - /* - * Attempt to validate it with the wrong value - */ - const sensitiveAttribute2Invalid = await sensitiveAttribute2.validateProof({ - ...sensitiveAttribute1Proof, - value: 'Something' - }); - expect(sensitiveAttribute2Invalid).toBe(false); - - /* - * Attempt to validate it with the wrong public key - */ - const sensitiveAttribute3Invalid = await sensitiveAttribute3.validateProof(sensitiveAttribute1Proof); - expect(sensitiveAttribute3Invalid).toBe(false); - - /* - * Attempt to validate a tampered attribute - */ - const attributeBuffer = arrayBufferToBuffer(attribute); - attributeBuffer.set([0x00], attributeBuffer.length - 3); - const tamperedAttribute = bufferToArrayBuffer(attributeBuffer); - const sensitiveAttribute4 = new Certificates._Testing.SensitiveAttribute(testAccount1NoPrivate, tamperedAttribute); - expect(await sensitiveAttribute4.validateProof(sensitiveAttribute1Proof)).toBe(false); -}); test('Certificates', async function() { /* diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts index 5fcd6914..66d99ea8 100644 --- a/src/lib/certificates.ts +++ b/src/lib/certificates.ts @@ -1,21 +1,17 @@ import * as KeetaNetClient from '@keetanetwork/keetanet-client'; import * as oids from '../services/kyc/oids.generated.js'; import * as ASN1 from './utils/asn1.js'; -import { arrayBufferLikeToBuffer, arrayBufferToBuffer, Buffer, bufferToArrayBuffer } from './utils/buffer.js'; -import crypto from './utils/crypto.js'; +import { arrayBufferToBuffer, Buffer, bufferToArrayBuffer } from './utils/buffer.js'; import { assertNever } from './utils/never.js'; -import type { SensitiveAttributeType, CertificateAttributeValue } from '../services/kyc/iso20022.generated.js'; +import type { CertificateAttributeValue } from '../services/kyc/iso20022.generated.js'; import { CertificateAttributeOIDDB, CertificateAttributeSchema } from '../services/kyc/iso20022.generated.js'; -import { getOID, lookupByOID } from './utils/oid.js'; -import { convertToJSON as convertToJSONUtil } from './utils/json.js'; +import { lookupByOID } from './utils/oid.js'; import { EncryptedContainer } from './encrypted-container.js'; import { assertSharableCertificateAttributesContentsSchema } from './certificates.generated.js'; import { checkHashWithOID } from './utils/external.js'; - -/** - * Short alias for printing a debug representation of an object - */ -const DPO = KeetaNetClient.lib.Utils.Helper.debugPrintableObject.bind(KeetaNetClient.lib.Utils.Helper); +import { SensitiveAttribute, SensitiveAttributeBuilder, encodeForSensitive, encodeAttribute, type CertificateAttributeNames } from './sensitive-attribute.js'; +export { SensitiveAttribute, SensitiveAttributeBuilder } from './sensitive-attribute.js'; +export type { CertificateAttributeNames } from './sensitive-attribute.js'; /** * Short alias for the KeetaNetAccount type @@ -227,10 +223,6 @@ async function walkObject(input: unknown, keyTransformer?: (key: string, input: return(newObj); } -function toJSON(data: unknown): unknown { - return(convertToJSONUtil(data)); -} - // Generic type guard to align decoded values with generated attribute types function isAttributeValue( _name: NAME, @@ -252,79 +244,6 @@ function asAttributeValue( return(v); } -/** - * Sensitive Attribute Schema - * - * ASN.1 Schema: - * SensitiveAttributes DEFINITIONS ::= BEGIN - * SensitiveAttribute ::= SEQUENCE { - * version INTEGER { v1(0) }, - * cipher SEQUENCE { - * algorithm OBJECT IDENTIFIER, - * ivOrNonce OCTET STRING, - * key OCTET STRING - * }, - * hashedValue SEQUENCE { - * encryptedSalt OCTET STRING, - * algorithm OBJECT IDENTIFIER, - * value OCTET STRING - * }, - * encryptedValue OCTET STRING - * } - * END - * - * https://keeta.notion.site/Keeta-KYC-Certificate-Extensions-13e5da848e588042bdcef81fc40458b7 - * - * @internal - */ -const SensitiveAttributeSchemaInternal: [ - version: 0n, - cipher: [ - algorithm: typeof ASN1.ValidateASN1.IsOID, - iv: typeof ASN1.ValidateASN1.IsOctetString, - key: typeof ASN1.ValidateASN1.IsOctetString - ], - hashedValue: [ - encryptedSalt: typeof ASN1.ValidateASN1.IsOctetString, - algorithm: typeof ASN1.ValidateASN1.IsOID, - value: typeof ASN1.ValidateASN1.IsOctetString - ], - encryptedValue: typeof ASN1.ValidateASN1.IsOctetString -] = [ - 0n, - [ - ASN1.ValidateASN1.IsOID, - ASN1.ValidateASN1.IsOctetString, - ASN1.ValidateASN1.IsOctetString - ], - [ - ASN1.ValidateASN1.IsOctetString, - ASN1.ValidateASN1.IsOID, - ASN1.ValidateASN1.IsOctetString - ], - ASN1.ValidateASN1.IsOctetString -]; - -/** - * The Sensitive Attribute Schema Internal - * - * @internal - */ -type SensitiveAttributeSchema = ASN1.SchemaMap; - -/* - * Database of permitted algorithms and their OIDs - */ -const sensitiveAttributeOIDDB = { - 'aes-256-gcm': oids.AES_256_GCM, - 'aes-256-cbc': oids.AES_256_CBC, - 'sha2-256': oids.SHA2_256, - 'sha3-256': oids.SHA3_256, - 'sha256': oids.SHA2_256, - 'aes256-gcm': oids.AES_256_GCM, - 'aes256-cbc': oids.AES_256_CBC -}; - function assertCertificateAttributeNames(name: string): asserts name is CertificateAttributeNames { if (!(name in CertificateAttributeOIDDB)) { throw(new Error(`Unknown attribute name: ${name}`)); @@ -336,55 +255,6 @@ function asCertificateAttributeNames(name: string): CertificateAttributeNames { return(name); } -function encodeAttribute(name: CertificateAttributeNames, value: unknown): ArrayBuffer { - const schema = CertificateAttributeSchema[name]; - - let encodedJS; - try { - encodedJS = new ASN1.ValidateASN1(schema).fromJavaScriptObject(value); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw(new Error(`Attribute ${name}: ${message} (value: ${JSON.stringify(DPO(value))})`)); - } - - if (encodedJS === undefined) { - throw(new Error(`Unsupported attribute value for encoding: ${JSON.stringify(DPO(value))}`)); - } - - const asn1Object = ASN1.JStoASN1(encodedJS); - if (!asn1Object) { - throw(new Error(`Failed to encode value for attribute ${name}`)); - } - - return(asn1Object.toBER(false)); -} - -// Prepare a value for inclusion in a SensitiveAttribute: pre-encode complex and date types -function encodeForSensitive( - name: CertificateAttributeNames | undefined, - value: SensitiveAttributeType | Buffer | ArrayBuffer -): Buffer { - if (Buffer.isBuffer(value)) { return(value); } - if (value instanceof ArrayBuffer) { return(arrayBufferToBuffer(value)); } - if (typeof value === 'string') { - const asn1 = ASN1.JStoASN1({ type: 'string', kind: 'utf8', value }); - return(arrayBufferToBuffer(asn1.toBER(false))); - } - - if (value instanceof Date) { - const asn1 = ASN1.JStoASN1(value); - return(arrayBufferToBuffer(asn1.toBER(false))); - } - - if (typeof value === 'object' && value !== null) { - if (!name) { throw(new Error('attributeName required for complex types')); } - const encoded = encodeAttribute(name, value); - return(arrayBufferToBuffer(encoded)); - } - - return(Buffer.from(String(value), 'utf-8')); -} - function unwrapSingleLayer(schema: ASN1.Schema): ASN1.Schema { if (typeof schema === 'object' && schema !== null && 'type' in schema && schema.type === 'context') { return(schema.contains); @@ -539,251 +409,6 @@ async function decodeAttribute(name: NAM return(asAttributeValue(name, candidate)); } -class SensitiveAttributeBuilder { - readonly #account: KeetaNetAccount; - #value: Buffer | undefined; - - constructor(account: KeetaNetAccount) { - this.#account = account; - } - - set(value: Buffer | ArrayBufferLike): this { - this.#value = Buffer.isBuffer(value) ? value : arrayBufferLikeToBuffer(value); - return(this); - } - - async build() { - if (this.#value === undefined) { - throw(new Error('Value not set')); - } - - const salt = crypto.randomBytes(32); - - const hashingAlgorithm = KeetaNetClient.lib.Utils.Hash.HashFunctionName; - const publicKey = Buffer.from(this.#account.publicKey.get()); - - const cipher = 'aes-256-gcm'; - const key = crypto.randomBytes(32); - const nonce = crypto.randomBytes(12); - const encryptedKey = await this.#account.encrypt(bufferToArrayBuffer(key)); - - function encrypt(value: Buffer) { - const cipherObject = crypto.createCipheriv(cipher, key, nonce); - let retval = cipherObject.update(value); - retval = Buffer.concat([retval, cipherObject.final()]); - - /* - * For AES-GCM, the last 16 bytes are the authentication tag - */ - if (cipher === 'aes-256-gcm') { - const getAuthTagFn = Reflect.get(cipherObject, 'getAuthTag'); - if (typeof getAuthTagFn === 'function') { - const tag: unknown = getAuthTagFn.call(cipherObject); - if (!Buffer.isBuffer(tag)) { throw(new Error('getAuthTag did not return a Buffer')); } - retval = Buffer.concat([retval, tag]); - } else { - throw(new Error('getAuthTag is not available on cipherObject')); - } - } - return(retval); - } - - const encryptedValue = encrypt(this.#value); - const encryptedSalt = encrypt(arrayBufferLikeToBuffer(salt)); - - const saltedValue = Buffer.concat([salt, publicKey, encryptedValue, this.#value]); - const hashedAndSaltedValue = KeetaNetClient.lib.Utils.Hash.Hash(saltedValue); - - const attributeStructure: SensitiveAttributeSchema = [ - /* Version */ - 0n, - /* Cipher Details */ - [ - /* Algorithm */ - { type: 'oid', oid: getOID(cipher, sensitiveAttributeOIDDB) }, - /* IV or Nonce */ - nonce, - /* Symmetric key, encrypted with the public key of the account */ - Buffer.from(encryptedKey) - ], - /* Hashed Value */ - [ - /* Encrypted Salt */ - Buffer.from(encryptedSalt), - /* Hashing Algorithm */ - { type: 'oid', oid: getOID(hashingAlgorithm, sensitiveAttributeOIDDB) }, - /* Hash of || || */ - Buffer.from(hashedAndSaltedValue) - ], - /* Encrypted Value, encrypted with the Cipher above */ - encryptedValue - ]; - - const encodedAttributeObject = ASN1.JStoASN1(attributeStructure); - - // Produce canonical DER as ArrayBuffer - const retval = encodedAttributeObject.toBER(false); - return(retval); - } -} - -class SensitiveAttribute { - readonly #account: KeetaNetAccount; - readonly #info: ReturnType['decode']>; - readonly #decoder?: (data: Buffer | ArrayBuffer) => T; - - constructor(account: KeetaNetAccount, data: Buffer | ArrayBuffer, decoder?: (data: Buffer | ArrayBuffer) => T) { - this.#account = account; - this.#info = this.decode(data); - if (decoder) { - this.#decoder = decoder; - } - } - - private decode(data: Buffer | ArrayBuffer) { - if (Buffer.isBuffer(data)) { - data = bufferToArrayBuffer(data); - } - - let decodedAttribute; - try { - const dataObject = new ASN1.BufferStorageASN1(data, SensitiveAttributeSchemaInternal); - decodedAttribute = dataObject.getASN1(); - } catch { - const js = ASN1.ASN1toJS(data); - throw(new Error(`SensitiveAttribute.decode: unexpected DER shape ${JSON.stringify(DPO(js))}`)); - } - - const decodedVersion = decodedAttribute[0] + 1n; - if (decodedVersion !== 1n) { - throw(new Error(`Unsupported Sensitive Attribute version (${decodedVersion})`)); - } - - return({ - version: decodedVersion, - publicKey: this.#account.publicKeyString.get(), - cipher: { - algorithm: lookupByOID(decodedAttribute[1][0].oid, sensitiveAttributeOIDDB), - iv: decodedAttribute[1][1], - key: decodedAttribute[1][2] - }, - hashedValue: { - encryptedSalt: decodedAttribute[2][0], - algorithm: lookupByOID(decodedAttribute[2][1].oid, sensitiveAttributeOIDDB), - value: decodedAttribute[2][2] - }, - encryptedValue: decodedAttribute[3] - }); - } - - async #decryptValue(value: Buffer) { - const decryptedKey = await this.#account.decrypt(bufferToArrayBuffer(this.#info.cipher.key)); - const algorithm = this.#info.cipher.algorithm; - const iv = this.#info.cipher.iv; - - const cipher = crypto.createDecipheriv(algorithm, Buffer.from(decryptedKey), iv); - - // For AES-GCM, the last 16 bytes are the authentication tag - if (algorithm === 'aes-256-gcm') { - const authTag = value.subarray(value.length - 16); - const ciphertext = value.subarray(0, value.length - 16); - - // XXX:TODO Fix typescript unsafe calls - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const setAuthTagFn = Reflect.get(cipher, 'setAuthTag'); - if (typeof setAuthTagFn === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - setAuthTagFn.call(cipher, authTag); - } else { - throw(new Error('setAuthTag is not available on cipher')); - } - - const decrypted = cipher.update(ciphertext); - cipher.final(); // Verify auth tag - return(decrypted); - } - - // For other algorithms (like CBC), just decrypt normally - const decryptedValue = cipher.update(value); - cipher.final(); - return(decryptedValue); - } - - /** - * Get the value of the sensitive attribute - * - * This will decrypt the value using the account's private key - * and return the value as an ArrayBuffer - * - * Since sensitive attributes are binary blobs, this returns an - * ArrayBuffer - */ - async get(): Promise { - const decryptedValue = await this.#decryptValue(arrayBufferLikeToBuffer(this.#info.encryptedValue)); - return(bufferToArrayBuffer(decryptedValue)); - } - - async getValue(): Promise { - const value = await this.get(); - if (!this.#decoder) { - /** - * TypeScript complains that T may not be the correct - * type here, but gives us no tools to enforce that it - * is -- it should always be ArrayBuffer if no decoder - * is provided, but someone could always specify a - * type parameter in that case and we cannot check - * that at runtime since T is only a compile-time type. - */ - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return(value as unknown as T); - } - return(this.#decoder(value)); - } - - /** - * Generate a proof that a sensitive attribute is a given value, - * which can be validated by a third party using the certificate - * and the `validateProof` method - */ - async getProof(): Promise<{ value: string; hash: { salt: string }}> { - const value = await this.get(); - const salt = await this.#decryptValue(arrayBufferLikeToBuffer(this.#info.hashedValue.encryptedSalt)); - - return({ - value: Buffer.from(value).toString('base64'), - hash: { - salt: salt.toString('base64') - } - }); - } - - /** - * Validate the proof that a sensitive attribute is a given value - */ - async validateProof(proof: Awaited>): Promise { - const plaintextValue = Buffer.from(proof.value, 'base64'); - const proofSaltBuffer = Buffer.from(proof.hash.salt, 'base64'); - - const publicKeyBuffer = Buffer.from(this.#account.publicKey.get()); - const encryptedValue = this.#info.encryptedValue; - - const hashInput = Buffer.concat([proofSaltBuffer, publicKeyBuffer, encryptedValue, plaintextValue]); - const hashedAndSaltedValue = KeetaNetClient.lib.Utils.Hash.Hash(hashInput); - const hashedAndSaltedValueBuffer = Buffer.from(hashedAndSaltedValue); - - return(this.#info.hashedValue.value.equals(hashedAndSaltedValueBuffer)); - } - - toJSON(): unknown/* XXX:TODO */ { - return(toJSON(this.#info)); - } -} - -/** - * Type for certificate attribute names (derived from generated OID database) - */ -type CertificateAttributeNames = keyof typeof CertificateAttributeOIDDB; - type BaseCertificateBuilderParams = NonNullable[0]>; type CertificateBuilderParams = Required & { /** @@ -831,9 +456,11 @@ type CertificateAttributeInput = Certifi export class CertificateBuilder extends BaseCertificateBuilder { readonly #attributes: { - [name: string]: { sensitive: boolean; value: ArrayBuffer } + [name: string]: { sensitive: boolean; value: ArrayBuffer; preEncrypted?: boolean } } = {}; + #subjectPublicKeyString: string | undefined; + /** * Map the parameters from the public interface to the internal * (Certificate library) interface @@ -854,6 +481,9 @@ export class CertificateBuilder extends BaseCertificateBuilder { constructor(params?: Partial) { super(CertificateBuilder.mapParams(params)); + if (params?.subject) { + this.#subjectPublicKeyString = params.subject.publicKeyString.get(); + } } /** @@ -891,6 +521,27 @@ export class CertificateBuilder extends BaseCertificateBuilder { }; } + /** + * Set a pre-built SensitiveAttribute for a given attribute name. + * + * The attribute must have been encrypted for this certificate's subject. + * + * @throws Error if the attribute was encrypted for a different subject + */ + setSensitiveAttribute( + name: NAME, + attribute: SensitiveAttribute> + ): void { + if (this.#subjectPublicKeyString && attribute.publicKey !== this.#subjectPublicKeyString) { + throw(new Error('SensitiveAttribute was encrypted for a different subject')); + } + this.#attributes[name] = { + sensitive: true, + value: attribute.toDER(), + preEncrypted: true + }; + } + protected async addExtensions(...args: Parameters): ReturnType { const retval = await super.addExtensions(...args); @@ -898,31 +549,35 @@ export class CertificateBuilder extends BaseCertificateBuilder { /* Encode the attributes */ const certAttributes: CertificateKYCAttributeSchema = []; + for (const [name, attribute] of Object.entries(this.#attributes)) { if (!(name in CertificateAttributeOIDDB)) { throw(new Error(`Unknown attribute: ${name}`)); } - /* - * Since we are iteratively building the certificate, we - * can assume that the attribute is always present in - * the object - */ assertCertificateAttributeNames(name); const nameOID = CertificateAttributeOIDDB[name]; let value: Buffer; if (attribute.sensitive) { - const builder = new SensitiveAttributeBuilder(subject); - builder.set(attribute.value); - value = arrayBufferToBuffer(await builder.build()); + if (attribute.preEncrypted) { + // Already encrypted via setSensitiveAttribute + value = arrayBufferToBuffer(attribute.value); + } else { + // Encrypt now + const builder = new SensitiveAttributeBuilder(subject); + builder.set(attribute.value); + const builtAttr = await builder.build(); + value = arrayBufferToBuffer(builtAttr.toDER()); + } } else { if (typeof attribute.value === 'string') { value = Buffer.from(attribute.value, 'utf-8'); } else { value = arrayBufferToBuffer(attribute.value); } - } certAttributes.push([{ + } + certAttributes.push([{ type: 'oid', oid: nameOID }, { diff --git a/src/lib/sensitive-attribute.test.ts b/src/lib/sensitive-attribute.test.ts new file mode 100644 index 00000000..e49ae24f --- /dev/null +++ b/src/lib/sensitive-attribute.test.ts @@ -0,0 +1,195 @@ +import { test, expect } from 'vitest'; +import * as KeetaNetClient from '@keetanetwork/keetanet-client'; +import { SensitiveAttribute, SensitiveAttributeBuilder } from './sensitive-attribute.js'; +import type { CertificateAttributeNames } from './sensitive-attribute.js'; +import type { CertificateAttributeValue } from '../services/kyc/iso20022.generated.js'; +import { arrayBufferToBuffer, bufferToArrayBuffer } from './utils/buffer.js'; +import { testAccounts } from './utils/tests/certificates.js'; + +// ============================================================================ +// Test Accounts +// ============================================================================ +const accounts = { + withPrivateKey: testAccounts.subject, + publicKeyOnly: KeetaNetClient.lib.Account.fromPublicKeyString( + testAccounts.subject.publicKeyString.get() + ), + wrong: testAccounts.other +}; + +// ============================================================================ +// Test Data +// ============================================================================ +function attr( + name: K, + value: CertificateAttributeValue +): { name: K; value: CertificateAttributeValue } { + return({ name, value }); +} + +const SCHEMA_ATTRIBUTES = [ + attr('firstName', 'John'), + attr('lastName', 'Doe'), + attr('email', 'john.doe@example.com'), + attr('dateOfBirth', new Date('1990-01-15')) +]; + +const RAW_PAYLOAD = bufferToArrayBuffer(Buffer.from('secret-value', 'utf-8')); + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Build an encrypted attribute from raw bytes + */ +async function buildRaw(data: ArrayBuffer = RAW_PAYLOAD): Promise<{ + encrypted: SensitiveAttribute; + der: ArrayBuffer; +}> { + const encrypted = await new SensitiveAttributeBuilder(accounts.publicKeyOnly) + .set(data) + .build(); + + return({ encrypted, der: encrypted.toDER() }); +} + +/** + * Generate a proof using the private key + */ +async function generateProof(der: ArrayBuffer): Promise<{ + proof: Awaited['getProof']>>; + attrWithKey: SensitiveAttribute; +}> { + const attrWithKey = new SensitiveAttribute(accounts.withPrivateKey, der); + return({ proof: await attrWithKey.getProof(), attrWithKey }); +} + +/** + * Tamper with DER data + */ +function tamperDER(der: ArrayBuffer): ArrayBuffer { + const buffer = arrayBufferToBuffer(der); + buffer.set([0x00], buffer.length - 3); + return(bufferToArrayBuffer(buffer)); +} + +// ============================================================================ +// Tests: Building & Decryption +// ============================================================================ + +test('raw bytes: encrypt and decrypt round-trip', async function() { + const { der } = await buildRaw(); + const decrypted = await new SensitiveAttribute(accounts.withPrivateKey, der).getValue(); + expect(arrayBufferToBuffer(decrypted).toString('utf-8')).toBe('secret-value'); +}); + +test('schema-aware: encrypt and decrypt with type preservation', async function() { + for (const { name, value } of SCHEMA_ATTRIBUTES) { + const encrypted = await new SensitiveAttributeBuilder(accounts.withPrivateKey) + .set(name, value) + .build(); + expect(await encrypted.getValue(), name).toEqual(value); + } +}); + +test('publicKey getter matches encryption key', async function() { + const { encrypted } = await buildRaw(); + expect(encrypted.publicKey).toBe(accounts.publicKeyOnly.publicKeyString.get()); +}); + +test('toDER returns re-constructable bytes', async function() { + const { der } = await buildRaw(); + expect(der).toBeInstanceOf(ArrayBuffer); + expect(der.byteLength).toBeGreaterThan(0); + + const decrypted = await new SensitiveAttribute(accounts.withPrivateKey, der).getValue(); + expect(arrayBufferToBuffer(decrypted).toString('utf-8')).toBe('secret-value'); +}); + +test('toJSON contains expected structure', async function() { + const { encrypted } = await buildRaw(); + const json = encrypted.toJSON(); + expect(typeof json).toBe('object'); + expect(json).not.toBeNull(); + + const keys = Object.keys(json ?? {}); + const expectedKeys = ['version', 'cipher', 'publicKey', 'hashedValue', 'encryptedValue']; + expect(keys.sort()).toEqual(expectedKeys.sort()); + expect(JSON.parse(JSON.stringify(json))).toEqual(json); +}); + +// ============================================================================ +// Tests: Proof Validation +// ============================================================================ + +test('proof: valid proof passes validation', async function() { + const { der } = await buildRaw(); + const { proof } = await generateProof(der); + expect(proof).toHaveProperty('value'); + expect(proof).toHaveProperty('hash'); + expect(proof.hash).toHaveProperty('salt'); + + const valid = await new SensitiveAttribute(accounts.publicKeyOnly, der).validateProof(proof); + expect(valid).toBe(true); +}); + +type ProofTestCase = { + name: string; + modify: (der: ArrayBuffer) => Promise<{ + der: ArrayBuffer; + proof: Awaited['getProof']>>; + account?: typeof accounts.publicKeyOnly; + }>; +}; + +const INVALID_PROOF_CASES: ProofTestCase[] = [ + { + name: 'tampered value', + modify: async function(der) { + const { proof } = await generateProof(der); + return({ der, proof: { ...proof, value: 'tampered' }}); + } + }, + { + name: 'wrong public key', + modify: async function(der) { + const { proof } = await generateProof(der); + return({ der, proof, account: accounts.wrong }); + } + }, + { + name: 'tampered DER', + modify: async function(der) { + const { proof } = await generateProof(der); + return({ der: tamperDER(der), proof }); + } + } +]; + +for (const { name, modify } of INVALID_PROOF_CASES) { + test(`proof: fails with ${name}`, async function() { + const { der } = await buildRaw(); + const { der: testDER, proof, account = accounts.publicKeyOnly } = await modify(der); + const valid = await new SensitiveAttribute(account, testDER).validateProof(proof); + expect(valid).toBe(false); + }); +} + +// ============================================================================ +// Tests: Error Cases +// ============================================================================ + +test('error: decryption fails with wrong private key', async function() { + const { der } = await buildRaw(); + await expect(async function() { + return(await new SensitiveAttribute(accounts.wrong, der).getProof()); + }).rejects.toThrow(); +}); + +test('error: build throws when value not set', async function() { + await expect(async function() { + return(await new SensitiveAttributeBuilder(accounts.publicKeyOnly).build()); + }).rejects.toThrow(); +}); + diff --git a/src/lib/sensitive-attribute.ts b/src/lib/sensitive-attribute.ts new file mode 100644 index 00000000..98a72849 --- /dev/null +++ b/src/lib/sensitive-attribute.ts @@ -0,0 +1,550 @@ +import * as KeetaNetClient from '@keetanetwork/keetanet-client'; +import * as oids from '../services/kyc/oids.generated.js'; +import * as ASN1 from './utils/asn1.js'; +import { arrayBufferLikeToBuffer, arrayBufferToBuffer, Buffer, bufferToArrayBuffer } from './utils/buffer.js'; +import crypto from './utils/crypto.js'; +import type { SensitiveAttributeType, CertificateAttributeValue , CertificateAttributeOIDDB } from '../services/kyc/iso20022.generated.js'; +import { CertificateAttributeSchema } from '../services/kyc/iso20022.generated.js'; +import { getOID, lookupByOID } from './utils/oid.js'; +import { convertToJSON } from './utils/json.js'; + +/** + * Short alias for printing a debug representation of an object + */ +const DPO = KeetaNetClient.lib.Utils.Helper.debugPrintableObject.bind(KeetaNetClient.lib.Utils.Helper); + +/* ENUM */ +type AccountKeyAlgorithm = InstanceType['keyType']; + +/** + * An alias for the KeetaNetAccount type + */ +type KeetaNetAccount = ReturnType>; + +/** + * Type for certificate attribute names (derived from generated OID database) + */ +export type CertificateAttributeNames = keyof typeof CertificateAttributeOIDDB; + +/** + * Sensitive Attribute Schema + * + * ASN.1 Schema: + * SensitiveAttributes DEFINITIONS ::= BEGIN + * SensitiveAttribute ::= SEQUENCE { + * version INTEGER { v1(0) }, + * cipher SEQUENCE { + * algorithm OBJECT IDENTIFIER, + * ivOrNonce OCTET STRING, + * key OCTET STRING + * }, + * hashedValue SEQUENCE { + * encryptedSalt OCTET STRING, + * algorithm OBJECT IDENTIFIER, + * value OCTET STRING + * }, + * encryptedValue OCTET STRING + * } + * END + * + * https://keeta.notion.site/Keeta-KYC-Certificate-Extensions-13e5da848e588042bdcef81fc40458b7 + * + * @internal + */ +const SensitiveAttributeSchemaInternal: [ + version: 0n, + cipher: [ + algorithm: typeof ASN1.ValidateASN1.IsOID, + iv: typeof ASN1.ValidateASN1.IsOctetString, + key: typeof ASN1.ValidateASN1.IsOctetString + ], + hashedValue: [ + encryptedSalt: typeof ASN1.ValidateASN1.IsOctetString, + algorithm: typeof ASN1.ValidateASN1.IsOID, + value: typeof ASN1.ValidateASN1.IsOctetString + ], + encryptedValue: typeof ASN1.ValidateASN1.IsOctetString +] = [ + 0n, + [ + ASN1.ValidateASN1.IsOID, + ASN1.ValidateASN1.IsOctetString, + ASN1.ValidateASN1.IsOctetString + ], + [ + ASN1.ValidateASN1.IsOctetString, + ASN1.ValidateASN1.IsOID, + ASN1.ValidateASN1.IsOctetString + ], + ASN1.ValidateASN1.IsOctetString +]; + +/** + * The Sensitive Attribute Schema Internal + * + * @internal + */ +type SensitiveAttributeSchema = ASN1.SchemaMap; + +/* + * Database of permitted algorithms and their OIDs + */ +const sensitiveAttributeOIDDB = { + 'aes-256-gcm': oids.AES_256_GCM, + 'aes-256-cbc': oids.AES_256_CBC, + 'sha2-256': oids.SHA2_256, + 'sha3-256': oids.SHA3_256, + 'sha256': oids.SHA2_256, + 'aes256-gcm': oids.AES_256_GCM, + 'aes256-cbc': oids.AES_256_CBC +}; + +/** + * Encode an attribute value using its ASN.1 schema + */ +export function encodeAttribute(name: CertificateAttributeNames, value: unknown): ArrayBuffer { + const schema = CertificateAttributeSchema[name]; + + let encodedJS; + try { + encodedJS = new ASN1.ValidateASN1(schema).fromJavaScriptObject(value); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw(new Error(`Attribute ${name}: ${message} (value: ${JSON.stringify(DPO(value))})`)); + } + + if (encodedJS === undefined) { + throw(new Error(`Unsupported attribute value for encoding: ${JSON.stringify(DPO(value))}`)); + } + + const asn1Object = ASN1.JStoASN1(encodedJS); + if (!asn1Object) { + throw(new Error(`Failed to encode value for attribute ${name}`)); + } + + return(asn1Object.toBER(false)); +} + +/** + * Prepare a value for inclusion in a SensitiveAttribute: pre-encode complex and date types + */ +export function encodeForSensitive( + name: CertificateAttributeNames | undefined, + value: SensitiveAttributeType | Buffer | ArrayBuffer +): Buffer { + if (Buffer.isBuffer(value)) { return(value); } + if (value instanceof ArrayBuffer) { return(arrayBufferToBuffer(value)); } + if (typeof value === 'string') { + const asn1 = ASN1.JStoASN1({ type: 'string', kind: 'utf8', value }); + return(arrayBufferToBuffer(asn1.toBER(false))); + } + + if (value instanceof Date) { + const asn1 = ASN1.JStoASN1(value); + return(arrayBufferToBuffer(asn1.toBER(false))); + } + + if (typeof value === 'object' && value !== null) { + if (!name) { throw(new Error('attributeName required for complex types')); } + const encoded = encodeAttribute(name, value); + return(arrayBufferToBuffer(encoded)); + } + + return(Buffer.from(String(value), 'utf-8')); +} + +/** + * Check if value is an ASN.1-like wrapper object + */ +function isASN1Wrapper(obj: object): obj is { type: string; value: unknown } { + return('type' in obj && 'value' in obj && typeof obj.type === 'string'); +} + +/** + * Normalize the result (unwrap ASN.1-like objects) + */ +function normalizeDecodedValue(input: unknown): unknown { + if (input === undefined || input === null || typeof input !== 'object') { + return(input); + } + if (input instanceof Date || Buffer.isBuffer(input) || input instanceof ArrayBuffer) { + return(input); + } + if (Array.isArray(input)) { + return(input.map(item => normalizeDecodedValue(item))); + } + // Unwrap ASN.1-like objects + if (isASN1Wrapper(input)) { + if (input.type === 'string' && typeof input.value === 'string') { + return(input.value); + } + if (input.type === 'date' && input.value instanceof Date) { + return(input.value); + } + } + // Recursively normalize object properties + const result: { [key: string]: unknown } = {}; + for (const [key, value] of Object.entries(input)) { + result[key] = normalizeDecodedValue(value); + } + return(result); +} + +/** + * Decode ASN.1 data using a schema + * + * Note: The ASN1 library uses dynamic typing internally, so we consolidate + * the unsafe operations here rather than scattering them throughout the code. + */ +function decodeWithSchema(buffer: ArrayBuffer, schema: unknown): unknown { + /* eslint-disable + @typescript-eslint/no-explicit-any, + @typescript-eslint/consistent-type-assertions, + @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unsafe-call, + @typescript-eslint/no-unsafe-assignment + */ + let decodedASN1: ASN1.ASN1AnyJS | undefined; + try { + decodedASN1 = new (ASN1.BufferStorageASN1 as any)(buffer, schema).getASN1() as ASN1.ASN1AnyJS; + } catch { + decodedASN1 = ASN1.ASN1toJS(buffer); + } + if (decodedASN1 === undefined) { + throw(new Error('Failed to decode ASN1 data')); + } + + const validator = new (ASN1.ValidateASN1 as any)(schema); + return(validator.toJavaScriptObject(decodedASN1)); + /* eslint-enable */ +} + +/** + * GCM cipher helpers - Node's crypto types don't properly type getAuthTag/setAuthTag + * when using createCipheriv/createDecipheriv, so we use typed wrappers. + */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Reflect.get returns unknown */ +function getGCMAuthTag(cipher: ReturnType): Buffer { + const getAuthTag = Reflect.get(cipher, 'getAuthTag') as (() => Buffer) | undefined; + if (typeof getAuthTag !== 'function') { + throw(new Error('getAuthTag is not available on cipher')); + } + return(getAuthTag.call(cipher)); +} + +function setGCMAuthTag(decipher: ReturnType, tag: Buffer): void { + const setAuthTag = Reflect.get(decipher, 'setAuthTag') as ((tag: Buffer) => void) | undefined; + if (typeof setAuthTag !== 'function') { + throw(new Error('setAuthTag is not available on decipher')); + } + setAuthTag.call(decipher, tag); +} +/* eslint-enable @typescript-eslint/consistent-type-assertions */ + +/** + * Decode a value from its ASN.1 representation back to the original type + * + * @internal + */ +function decodeForSensitive( + name: CertificateAttributeNames, + data: Buffer | ArrayBuffer +): unknown { + const buffer = Buffer.isBuffer(data) ? bufferToArrayBuffer(data) : data; + const schema: unknown = CertificateAttributeSchema[name]; + const plainObject = decodeWithSchema(buffer, schema); + return(normalizeDecodedValue(plainObject)); +} + +export class SensitiveAttribute { + private static readonly SensitiveAttributeObjectTypeID = 'c0cc9591-cebb-4441-babe-23739279e3f2'; + private readonly SensitiveAttributeObjectTypeID!: string; + + readonly #account: KeetaNetAccount; + readonly #encryptedDER: ArrayBuffer; + readonly #info: ReturnType['decode']>; + readonly #decoder?: (data: Buffer | ArrayBuffer) => T | Promise; + + constructor( + account: KeetaNetAccount, + data: Buffer | ArrayBuffer, + decoder?: (data: Buffer | ArrayBuffer) => T | Promise + ) { + Object.defineProperty(this, 'SensitiveAttributeObjectTypeID', { + value: SensitiveAttribute.SensitiveAttributeObjectTypeID, + enumerable: false + }); + + this.#account = account; + this.#encryptedDER = Buffer.isBuffer(data) ? bufferToArrayBuffer(data) : data; + this.#info = this.decode(data); + if (decoder) { + this.#decoder = decoder; + } + } + + /** + * Check if a value is a SensitiveAttribute instance + */ + static isInstance(input: unknown): input is SensitiveAttribute { + if (typeof input !== 'object' || input === null) { + return(false); + } + + return(Reflect.get(input, 'SensitiveAttributeObjectTypeID') === SensitiveAttribute.SensitiveAttributeObjectTypeID); + } + + /** + * Get the public key this attribute was encrypted for + */ + get publicKey(): string { + return(this.#account.publicKeyString.get()); + } + + /** + * Get the raw encrypted DER for certificate embedding + */ + toDER(): ArrayBuffer { + return(this.#encryptedDER); + } + + private decode(data: Buffer | ArrayBuffer) { + if (Buffer.isBuffer(data)) { + data = bufferToArrayBuffer(data); + } + + let decodedAttribute; + try { + const dataObject = new ASN1.BufferStorageASN1(data, SensitiveAttributeSchemaInternal); + decodedAttribute = dataObject.getASN1(); + } catch { + const js = ASN1.ASN1toJS(data); + throw(new Error(`SensitiveAttribute.decode: unexpected DER shape ${JSON.stringify(DPO(js))}`)); + } + + const decodedVersion = decodedAttribute[0] + 1n; + if (decodedVersion !== 1n) { + throw(new Error(`Unsupported Sensitive Attribute version (${decodedVersion})`)); + } + + return({ + version: decodedVersion, + publicKey: this.#account.publicKeyString.get(), + cipher: { + algorithm: lookupByOID(decodedAttribute[1][0].oid, sensitiveAttributeOIDDB), + iv: decodedAttribute[1][1], + key: decodedAttribute[1][2] + }, + hashedValue: { + encryptedSalt: decodedAttribute[2][0], + algorithm: lookupByOID(decodedAttribute[2][1].oid, sensitiveAttributeOIDDB), + value: decodedAttribute[2][2] + }, + encryptedValue: decodedAttribute[3] + }); + } + + async #decryptValue(value: Buffer) { + const decryptedKey = await this.#account.decrypt(bufferToArrayBuffer(this.#info.cipher.key)); + const algorithm = this.#info.cipher.algorithm; + const iv = this.#info.cipher.iv; + + const decipher = crypto.createDecipheriv(algorithm, Buffer.from(decryptedKey), iv); + + // For AES-GCM, extract and set the 16-byte authentication tag + if (algorithm === 'aes-256-gcm') { + const authTag = value.subarray(value.length - 16); + const ciphertext = value.subarray(0, value.length - 16); + + setGCMAuthTag(decipher, authTag); + + const decrypted = decipher.update(ciphertext); + decipher.final(); // Verify auth tag + return(decrypted); + } + + // For other algorithms (like CBC), just decrypt normally + const decrypted = decipher.update(value); + decipher.final(); + return(decrypted); + } + + /** + * Get the value of the sensitive attribute + * + * This will decrypt the value using the account's private key + * and return the value as an ArrayBuffer + * + * Since sensitive attributes are binary blobs, this returns an + * ArrayBuffer + */ + async get(): Promise { + const decryptedValue = await this.#decryptValue(arrayBufferLikeToBuffer(this.#info.encryptedValue)); + return(bufferToArrayBuffer(decryptedValue)); + } + + async getValue(): Promise { + const value = await this.get(); + if (!this.#decoder) { + /** + * TypeScript complains that T may not be the correct + * type here, but gives us no tools to enforce that it + * is -- it should always be ArrayBuffer if no decoder + * is provided, but someone could always specify a + * type parameter in that case and we cannot check + * that at runtime since T is only a compile-time type. + */ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(value as unknown as T); + } + return(await this.#decoder(value)); + } + + /** + * Generate a proof that a sensitive attribute is a given value, + * which can be validated by a third party using the certificate + * and the `validateProof` method + */ + async getProof(): Promise<{ value: string; hash: { salt: string }}> { + const value = await this.get(); + const salt = await this.#decryptValue(arrayBufferLikeToBuffer(this.#info.hashedValue.encryptedSalt)); + + return({ + value: Buffer.from(value).toString('base64'), + hash: { + salt: salt.toString('base64') + } + }); + } + + /** + * Validate the proof that a sensitive attribute is a given value + */ + async validateProof(proof: Awaited>): Promise { + const plaintextValue = Buffer.from(proof.value, 'base64'); + const proofSaltBuffer = Buffer.from(proof.hash.salt, 'base64'); + + const publicKeyBuffer = Buffer.from(this.#account.publicKey.get()); + const encryptedValue = this.#info.encryptedValue; + + const hashInput = Buffer.concat([proofSaltBuffer, publicKeyBuffer, encryptedValue, plaintextValue]); + const hashedAndSaltedValue = KeetaNetClient.lib.Utils.Hash.Hash(hashInput); + const hashedAndSaltedValueBuffer = Buffer.from(hashedAndSaltedValue); + + return(this.#info.hashedValue.value.equals(hashedAndSaltedValueBuffer)); + } + + toJSON(): unknown/* XXX:TODO */ { + return(convertToJSON(this.#info)); + } +} + +export class SensitiveAttributeBuilder { + readonly #account: KeetaNetAccount; + #value: Buffer | undefined; + #attributeName: CertificateAttributeNames | undefined; + + constructor(account: KeetaNetAccount) { + this.#account = account; + } + + /** + * Set a schema-aware attribute value (handles encoding internally) + */ + set(name: K, value: CertificateAttributeValue): this; + /** + * Set raw bytes for encryption + */ + set(value: Buffer | ArrayBufferLike): this; + set( + nameOrValue: K | Buffer | ArrayBufferLike, + value?: CertificateAttributeValue + ): this { + // Distinguish overloads: if value provided, first arg is name; otherwise it's raw bytes + if (value !== undefined && typeof nameOrValue === 'string') { + this.#attributeName = nameOrValue; + this.#value = encodeForSensitive(nameOrValue, value); + } else if (Buffer.isBuffer(nameOrValue)) { + this.#value = nameOrValue; + } else if (typeof nameOrValue === 'object' && nameOrValue !== null) { + this.#value = arrayBufferLikeToBuffer(nameOrValue); + } + + return(this); + } + + async build(decoder?: (data: Buffer | ArrayBuffer) => T | Promise): Promise> { + if (this.#value === undefined) { + throw(new Error('Value not set')); + } + + const salt = crypto.randomBytes(32); + + const hashingAlgorithm = KeetaNetClient.lib.Utils.Hash.HashFunctionName; + const publicKey = Buffer.from(this.#account.publicKey.get()); + + const cipher = 'aes-256-gcm'; + const key = crypto.randomBytes(32); + const nonce = crypto.randomBytes(12); + const encryptedKey = await this.#account.encrypt(bufferToArrayBuffer(key)); + + function encrypt(value: Buffer) { + const cipherObject = crypto.createCipheriv(cipher, key, nonce); + let retval = Buffer.concat([cipherObject.update(value), cipherObject.final()]); + + // For AES-GCM, append the 16-byte authentication tag + if (cipher === 'aes-256-gcm') { + retval = Buffer.concat([retval, getGCMAuthTag(cipherObject)]); + } + + return(retval); + } + + const encryptedValue = encrypt(this.#value); + const encryptedSalt = encrypt(arrayBufferLikeToBuffer(salt)); + + const saltedValue = Buffer.concat([salt, publicKey, encryptedValue, this.#value]); + const hashedAndSaltedValue = KeetaNetClient.lib.Utils.Hash.Hash(saltedValue); + + const attributeStructure: SensitiveAttributeSchema = [ + /* Version */ + 0n, + /* Cipher Details */ + [ + /* Algorithm */ + { type: 'oid', oid: getOID(cipher, sensitiveAttributeOIDDB) }, + /* IV or Nonce */ + nonce, + /* Symmetric key, encrypted with the public key of the account */ + Buffer.from(encryptedKey) + ], + /* Hashed Value */ + [ + /* Encrypted Salt */ + Buffer.from(encryptedSalt), + /* Hashing Algorithm */ + { type: 'oid', oid: getOID(hashingAlgorithm, sensitiveAttributeOIDDB) }, + /* Hash of || || */ + Buffer.from(hashedAndSaltedValue) + ], + /* Encrypted Value, encrypted with the Cipher above */ + encryptedValue + ]; + + // Produce canonical DER as ArrayBuffer + const encodedAttributeObject = ASN1.JStoASN1(attributeStructure); + const encryptedDER = encodedAttributeObject.toBER(false); + + // Use provided decoder, or create one from attribute name, or undefined for raw bytes + let effectiveDecoder: ((data: Buffer | ArrayBuffer) => T | Promise) | undefined = decoder; + if (!effectiveDecoder && this.#attributeName) { + const attrName = this.#attributeName; + effectiveDecoder = function(data: Buffer | ArrayBuffer): T { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(decodeForSensitive(attrName, data) as T); + }; + } + + return(new SensitiveAttribute(this.#account, encryptedDER, effectiveDecoder)); + } +} diff --git a/src/lib/utils/pii.test.ts b/src/lib/utils/pii.test.ts new file mode 100644 index 00000000..9627e86a --- /dev/null +++ b/src/lib/utils/pii.test.ts @@ -0,0 +1,282 @@ +import { test, expect } from 'vitest'; +import * as util from 'util'; +import { PIIStore, PIIError } from './pii.js'; +import type { PIIAttributeNames } from './pii.js'; +import type { CertificateAttributeValue } from '../../services/kyc/iso20022.generated.js'; +import { createTestCertificate, testAttributeValues, testAccounts } from './tests/certificates.js'; +import { Certificate } from '../certificates.js'; +import * as KeetaNetClient from '@keetanetwork/keetanet-client'; + +// ============================================================================ +// Test Data +// ============================================================================ + +function attr( + name: K, + value: CertificateAttributeValue +): { name: K; value: CertificateAttributeValue } { + return({ name, value }); +} + +const TEST_ATTRIBUTES = [ + attr('firstName', 'John'), + attr('lastName', 'Doe'), + attr('email', 'john.doe@example.com'), + attr('phoneNumber', '+1-555-123-4567'), + attr('dateOfBirth', new Date('1990-01-15')), + attr('address', { + addressLines: ['123 Main St'], + townName: 'Springfield', + postalCode: '12345', + country: 'US' + }) +]; + +const REDACTION_METHODS = [ + { name: 'toString()', expose: function(s: PIIStore) { return(s.toString()); }, expected: '[PII: REDACTED]' }, + { name: 'util.inspect()', expose: function(s: PIIStore) { return(util.inspect(s)); }, expected: '[PII: REDACTED]' }, + { name: 'string coercion', expose: function(s: PIIStore) { return(String(s)); }, expected: '[PII: REDACTED]' }, + { name: 'template literal', expose: function(s: PIIStore) { return(`${s}`); }, expected: '[PII: REDACTED]' } +]; + +// ============================================================================ +// Helpers +// ============================================================================ + +function createStore(): PIIStore { + return(new PIIStore()); +} + +function createPopulatedStore(): PIIStore { + const store = createStore(); + for (const { name, value } of TEST_ATTRIBUTES) { + store.setAttribute(name, value); + } + return(store); +} + +/** + * Get decrypted value from store + */ +async function getValue( + store: PIIStore, + name: K +): Promise> { + return(await (await store.toSensitiveAttribute(name, testAccounts.subject)).getValue()); +} + +/** + * Create a certificate builder for the subject + */ +function createBuilder(): InstanceType { + const subjectNoPrivate = KeetaNetClient.lib.Account.fromPublicKeyString( + testAccounts.subject.publicKeyString.get() + ); + return(new Certificate.Builder({ + issuer: testAccounts.issuer.assertAccount(), + subject: subjectNoPrivate.assertAccount(), + validFrom: new Date(), + validTo: new Date(Date.now() + 86400000) + })); +} + +/** + * Assert that a function throws a PIIError with a specific code + */ +function expectPIIError(fn: () => unknown, code: Parameters[1]): PIIError { + try { + fn(); + expect.fail(`Expected PIIError with code ${code}`); + } catch (error) { + expect(PIIError.isInstance(error, code)).toBe(true); + if (PIIError.isInstance(error)) { + return(error); + } + } + + throw(new Error('Unreachable')); +} + +// ============================================================================ +// Tests: Basic Operations +// ============================================================================ + +test('setAttribute and toSensitiveAttribute round-trip', async function() { + const store = createStore(); + for (const { name, value } of TEST_ATTRIBUTES) { + store.setAttribute(name, value); + expect(await getValue(store, name)).toEqual(value); + } +}); + +test('hasAttribute tracks set attributes', function() { + const store = createStore(); + for (const { name, value } of TEST_ATTRIBUTES) { + expect(store.hasAttribute(name)).toBe(false); + store.setAttribute(name, value); + expect(store.hasAttribute(name)).toBe(true); + } +}); + +test('getAttributeNames returns set attribute names in order', function() { + const store = createStore(); + expect(store.getAttributeNames()).toEqual([]); + + const expectedNames: PIIAttributeNames[] = []; + for (const { name, value } of TEST_ATTRIBUTES) { + store.setAttribute(name, value); + expectedNames.push(name); + expect(store.getAttributeNames()).toEqual(expectedNames); + } +}); + +test('toSensitiveAttribute throws PIIError for missing attributes', async function() { + const store = createStore(); + for (const { name } of TEST_ATTRIBUTES) { + await expect(store.toSensitiveAttribute(name, testAccounts.subject)).rejects.toSatisfy( + function(error: unknown) { return(PIIError.isInstance(error, 'PII_ATTRIBUTE_NOT_FOUND')); } + ); + } +}); + +test('setAttribute overwrites existing values', async function() { + const store = createStore(); + store.setAttribute('firstName', 'John'); + expect(await getValue(store, 'firstName')).toBe('John'); + + store.setAttribute('firstName', 'Jane'); + expect(await getValue(store, 'firstName')).toBe('Jane'); + expect(store.getAttributeNames()).toEqual(['firstName']); +}); + +test('toSensitiveAttribute rejects external attributes with PIIError', async function() { + const store = createStore(); + const externalName = 'externalProvider.result'; + + store.setAttribute(externalName, { provider: 'test' }); + + // @ts-expect-error Testing runtime rejection of external attributes + await expect(store.toSensitiveAttribute(externalName, testAccounts.subject)) + .rejects.toSatisfy(function(error: unknown) { + return(PIIError.isInstance(error, 'PII_EXTERNAL_ATTRIBUTE')); + }); +}); + +test('run provides scoped access to external attributes', function() { + const store = createStore(); + const externalData = { provider: 'test', score: 42 }; + store.setAttribute('externalProvider.result', externalData); + + const result = store.run(function(get) { + return(get('externalProvider.result')); + }); + expect(result).toEqual(externalData); +}); + +test('run provides scoped access to known attributes', function() { + const store = createStore(); + store.setAttribute('firstName', 'John'); + store.setAttribute('lastName', 'Doe'); + + const fullName = store.run(function(get) { + return(`${get('firstName')} ${get('lastName')}`); + }); + expect(fullName).toBe('John Doe'); +}); + +test('run throws PIIError for missing attributes', function() { + const store = createStore(); + const error = expectPIIError(function() { + store.run(function(get) { return(get('nonexistent')); }); + }, 'PII_ATTRIBUTE_NOT_FOUND'); + expect(error.attributeName).toBe('nonexistent'); +}); + +// ============================================================================ +// Tests: Redaction +// ============================================================================ + +test('toJSON returns redacted object with attribute names', function() { + const json = createPopulatedStore().toJSON(); + expect(json.type).toBe('PIIStore'); + expect(Object.keys(json.attributes).sort()).toEqual( + TEST_ATTRIBUTES.map(function(a) { return(a.name); }).sort() + ); + for (const value of Object.values(json.attributes)) { + expect(value).toBe('[REDACTED]'); + } +}); + +test('redaction prevents PII exposure', function() { + const store = createPopulatedStore(); + for (const method of REDACTION_METHODS) { + const result = method.expose(store); + expect(result, method.name).toBe(method.expected); + + for (const { value } of TEST_ATTRIBUTES) { + if (typeof value === 'string') { + expect(result, `${method.name} leaked "${value}"`).not.toContain(value); + } + } + } +}); + +// ============================================================================ +// Tests: Certificate Integration +// ============================================================================ + +test('fromCertificate extracts all attributes', async function() { + const { certificateWithKey } = await createTestCertificate(); + + const store = PIIStore.fromCertificate(certificateWithKey); + for (const name of ['fullName', 'email', 'phoneNumber', 'dateOfBirth', 'address', 'entityType'] as const) { + expect(await getValue(store, name), name).toEqual(testAttributeValues[name]); + } + + expect(store.toString()).toBe('[PII: REDACTED]'); +}); + +test('toCertificateBuilder <-> fromCertificate round-trip', async function() { + const originalStore = createPopulatedStore(); + + const certificate = await originalStore + .toCertificateBuilder(createBuilder()) + .build({ serial: 1 }); + + const certWithKey = new Certificate(certificate, { subjectKey: testAccounts.subject }); + const extractedStore = PIIStore.fromCertificate(certWithKey); + for (const { name, value } of TEST_ATTRIBUTES) { + expect(await getValue(extractedStore, name)).toEqual(value); + } +}); + +test('toSensitiveAttribute creates encrypted attribute with correct public key', async function() { + const store = createStore(); + store.setAttribute('email', 'test@example.com'); + + const sensitiveAttr = await store.toSensitiveAttribute('email', testAccounts.subject); + expect(sensitiveAttr.publicKey).toBe(testAccounts.subject.publicKeyString.get()); + expect(await sensitiveAttr.getValue()).toBe('test@example.com'); +}); + +test('setSensitiveAttribute accepts pre-built attribute', async function() { + const store = createStore(); + store.setAttribute('email', 'secure@example.com'); + + const builder = createBuilder(); + builder.setSensitiveAttribute('email', await store.toSensitiveAttribute('email', testAccounts.subject)); + const certificate = await builder.build({ serial: 1 }); + + const certWithKey = new Certificate(certificate, { subjectKey: testAccounts.subject }); + expect(await certWithKey.getAttributeValue('email')).toBe('secure@example.com'); +}); + +test('setSensitiveAttribute rejects wrong subject key', async function() { + const wrongKeyStore = new PIIStore(); + wrongKeyStore.setAttribute('email', 'wrong@example.com'); + + const wrongKeyAttr = await wrongKeyStore.toSensitiveAttribute('email', testAccounts.other); + expect(function() { + createBuilder().setSensitiveAttribute('email', wrongKeyAttr); + }).toThrowError('SensitiveAttribute was encrypted for a different subject'); +}); diff --git a/src/lib/utils/pii.ts b/src/lib/utils/pii.ts new file mode 100644 index 00000000..2ece890f --- /dev/null +++ b/src/lib/utils/pii.ts @@ -0,0 +1,255 @@ +import type * as KeetaNetClient from '@keetanetwork/keetanet-client'; +import { CertificateAttributeOIDDB, type CertificateAttributeValueMap, type CertificateAttributeValue } from '../../services/kyc/iso20022.generated.js'; +import type { CertificateBuilder, Certificate } from '../certificates.js'; +import { SensitiveAttribute, SensitiveAttributeBuilder } from '../certificates.js'; +import { KeetaAnchorError } from '../error.js'; + +type AccountKeyAlgorithm = InstanceType['keyType']; +type KeetaNetAccount = ReturnType>; + +/** + * Type alias for certificate attribute names + */ +export type PIIAttributeNames = keyof CertificateAttributeValueMap; + +/** + * Redacted message shown when attempting to log or serialize PIIStore + */ +const REDACTED = '[PII: REDACTED]'; + +/** + * PII error codes + */ +export type PIIErrorCode = 'PII_ATTRIBUTE_NOT_FOUND' | 'PII_EXTERNAL_ATTRIBUTE'; + +/** + * Error class for PII-related errors + */ +export class PIIError extends KeetaAnchorError { + static override readonly name: string = 'PIIError'; + private readonly PIIErrorObjectTypeID!: string; + private static readonly PIIErrorObjectTypeID = 'b8e3c7a1-5d2f-4e6b-9a1c-3f8d2e7b4c5a'; + + readonly code: PIIErrorCode; + readonly attributeName: string; + + constructor(code: PIIErrorCode, attributeName: string, message: string) { + super(message); + + Object.defineProperty(this, 'PIIErrorObjectTypeID', { + value: PIIError.PIIErrorObjectTypeID, + enumerable: false + }); + + this.code = code; + this.attributeName = attributeName; + } + + static isInstance(input: unknown, code?: PIIErrorCode): input is PIIError { + if (!this.hasPropWithValue(input, 'PIIErrorObjectTypeID', PIIError.PIIErrorObjectTypeID)) { + return(false); + } + if (code && !this.hasPropWithValue(input, 'code', code)) { + return(false); + } + + return(true); + } +} + +type StoredAttribute = { + value: unknown; + sensitive: boolean; +}; + +/** + * PIIStore is a secure container for Personally Identifiable Information (PII). + * + * It encapsulates sensitive data and prevents accidental logging or serialization + * by overriding common output methods to return redacted placeholders. + * + * @example + * ```typescript + * const store = new PIIStore(); + * store.setAttribute('firstName', 'John'); + * store.setAttribute('lastName', 'Doe'); + * + * console.log(store); // '[PIIStore: REDACTED]' + * JSON.stringify(store); // '{"type":"PIIStore","message":"REDACTED"}' + * ``` + */ +export class PIIStore { + readonly #attributes = new Map(); + + constructor() { + // Define Node.js util.inspect custom formatter to prevent PII exposure + Object.defineProperty(this, Symbol.for('nodejs.util.inspect.custom'), { + value: () => REDACTED, + enumerable: false, + writable: false, + configurable: false + }); + } + + /** + * Create a PIIStore from a Certificate, extracting all attributes + * + * @param certificate - The certificate to extract attributes from + * + * @returns A new PIIStore populated with the certificate's attributes + */ + static fromCertificate(certificate: Certificate): PIIStore { + const store = new PIIStore(); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const attributeNames = Object.keys(certificate.attributes) as PIIAttributeNames[]; + for (const name of attributeNames) { + const attr = certificate.attributes[name]; + if (attr) { + store.#attributes.set(name, { + value: attr.value, + sensitive: attr.sensitive + }); + } + } + + return(store); + } + + /** + * Set a known certificate attribute + * + * @param name - The attribute name + * @param value - The value to store + * @param sensitive - Whether the attribute is sensitive (default: true) + */ + setAttribute(name: K, value: CertificateAttributeValue, sensitive?: boolean): void; + setAttribute(name: string, value: T, sensitive?: boolean): void; + setAttribute(name: string, value: unknown, sensitive = true): void { + this.#attributes.set(name, { value, sensitive }); + } + + /** + * Check if an attribute exists in the store + */ + hasAttribute(name: string): boolean { + return(this.#attributes.has(name)); + } + + /** + * Get all attribute names currently stored + */ + getAttributeNames(): string[] { + return(Array.from(this.#attributes.keys())); + } + + /** + * Execute a function with scoped access to PII values + * + * Provides controlled access to all attribute values. Known certificate attributes + * are automatically typed; external attributes require an explicit type parameter. + * + * @param fn - Function that receives a getter for accessing attribute values + * @returns The return value of the callback function + * + * @throws PIIError with PII_ATTRIBUTE_NOT_FOUND if accessing a missing attribute + */ + run(fn: (get: { + (name: K): CertificateAttributeValue; + (name: string): T; + }) => R): R { + const attributes = this.#attributes; + const get = (name: string): T => { + if (!this.hasAttribute(name)) { + throw(new PIIError('PII_ATTRIBUTE_NOT_FOUND', name, `Attribute '${name}' not found in PIIStore`)); + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(attributes.get(name)?.value as T); + }; + + return(fn(get)); + } + + /** + * Create a SensitiveAttribute for a known certificate attribute + * + * Only known certificate attributes are supported. External attributes + * cannot be converted to SensitiveAttributes. + * + * @param name - The attribute name to convert (must be a known certificate attribute) + * @param subjectKey - The account to encrypt the attribute for + * @returns A SensitiveAttribute containing the encrypted value + * + * @throws PIIError with PII_ATTRIBUTE_NOT_FOUND if the attribute is not set + * @throws Error if the attribute is not a known certificate attribute + */ + async toSensitiveAttribute( + name: K, + subjectKey: KeetaNetAccount + ): Promise>> { + if (!this.hasAttribute(name)) { + throw(new PIIError('PII_ATTRIBUTE_NOT_FOUND', name, `Attribute '${name}' not found in PIIStore`)); + } + if (!this.#isKnownAttribute(name)) { + throw(new PIIError('PII_EXTERNAL_ATTRIBUTE', name, `Cannot convert external attribute '${name}' to SensitiveAttribute`)); + } + + const stored = this.#attributes.get(name); + const storedValue = stored?.value; + if (SensitiveAttribute.isInstance(storedValue)) { + // If already a SensitiveAttribute, return it directly + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(storedValue as SensitiveAttribute>); + } + + const builder = new SensitiveAttributeBuilder(subjectKey); + // @ts-expect-error storedValue type is validated at setAttribute time + builder.set(name, storedValue); + return(await builder.build()); + } + + /** + * Apply known attributes to a CertificateBuilder + * + * External attributes are not included in the certificate. + * + * @param builder - The certificate builder to apply attributes to + * @returns The certificate builder with the attributes applied + */ + toCertificateBuilder(builder: CertificateBuilder): CertificateBuilder { + for (const [name, attr] of this.#attributes.entries()) { + if (this.#isKnownAttribute(name) && attr.value !== undefined && attr.value !== null) { + builder.setAttribute(name, attr.sensitive, attr.value); + } + } + + return(builder); + } + + /** + * Prevent logging of PII data via string coercion + */ + toString(): string { + return(REDACTED); + } + + /** + * Serialize to JSON with redacted values + * + * Shows attribute names for debugging, but all values are redacted. + */ + toJSON(): { type: string; attributes: { [key: string]: string }} { + const attributes: { [key: string]: string } = {}; + for (const name of this.#attributes.keys()) { + attributes[name] = '[REDACTED]'; + } + + return({ type: 'PIIStore', attributes }); + } + + #isKnownAttribute(name: string): name is PIIAttributeNames { + return(name in CertificateAttributeOIDDB); + } +} + diff --git a/src/lib/utils/tests/certificates.ts b/src/lib/utils/tests/certificates.ts new file mode 100644 index 00000000..cdc95d0d --- /dev/null +++ b/src/lib/utils/tests/certificates.ts @@ -0,0 +1,131 @@ +import * as KeetaNetClient from '@keetanetwork/keetanet-client'; +import * as Certificates from '../../certificates.js'; + +type AccountKeyAlgorithm = InstanceType['keyType']; +type KeetaNetAccount = ReturnType>; + +/** + * Shared test seed for deterministic account generation + */ +export const testSeed = 'D6986115BE7334E50DA8D73B1A4670A510E8BF47E8C5C9960B8F5248EC7D6E3D'; + +/** + * Pre-generated test accounts from testSeed + */ +export const testAccounts: { + issuer: KeetaNetAccount; + subject: KeetaNetAccount; + other: KeetaNetAccount; +} = { + issuer: KeetaNetClient.lib.Account.fromSeed(testSeed, 0), + subject: KeetaNetClient.lib.Account.fromSeed(testSeed, 1), + other: KeetaNetClient.lib.Account.fromSeed(testSeed, 2) +}; + +/** + * Test attribute values matching CertificateAttributeValueMap types + */ +export const testAttributeValues: { + fullName: string; + firstName: string; + lastName: string; + email: string; + phoneNumber: string; + dateOfBirth: Date; + address: { + addressLines: string[]; + streetName: string; + townName: string; + countrySubDivision: string; + postalCode: string; + }; + entityType: { + person: { id: string; schemeName: 'SSN' }[]; + }; +} = { + fullName: 'Test User', + firstName: 'Test', + lastName: 'User', + email: 'user@example.com', + phoneNumber: '+1 555 911 3808', + dateOfBirth: new Date('1980-01-01'), + address: { + addressLines: ['100 Belgrave Street'], + streetName: '100 Belgrave Street', + townName: 'Oldsmar', + countrySubDivision: 'FL', + postalCode: '34677' + }, + entityType: { + person: [{ id: '123-45-6789', schemeName: 'SSN' }] + } +}; + +/** + * Options for creating test certificates + */ +export type CreateTestCertificateOptions = { + /** Attributes to include (defaults to all) */ + attributes?: (keyof typeof testAttributeValues)[]; + /** Whether to mark attributes as sensitive (default: true) */ + sensitive?: boolean; +}; + +/** + * Create a test certificate with PII attributes + * + * @returns Object containing certificate, subject account, and CA + */ +export async function createTestCertificate(options: CreateTestCertificateOptions = {}): Promise<{ + certificate: Certificates.Certificate; + certificateWithKey: Certificates.Certificate; + subjectKey: typeof testAccounts.subject; + issuerAccount: typeof testAccounts.issuer; + ca: Certificates.Certificate; +}> { + const { attributes, sensitive = true } = options; + const issuerAccount = testAccounts.issuer; + const subjectAccount = testAccounts.subject; + const subjectAccountNoPrivate = KeetaNetClient.lib.Account.fromPublicKeyString( + subjectAccount.publicKeyString.get() + ); + + const builder = new Certificates.Certificate.Builder({ + issuer: issuerAccount.assertAccount(), + subject: subjectAccountNoPrivate.assertAccount(), + validFrom: new Date(), + validTo: new Date(Date.now() + 1000 * 60 * 60 * 24) + }); + + // Build CA certificate + const ca = await builder.build({ + subject: issuerAccount.assertAccount(), + serial: 1 + }); + + // Add attributes + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const attributesToInclude = attributes ?? (Object.keys(testAttributeValues) as (keyof typeof testAttributeValues)[]); + for (const name of attributesToInclude) { + const value = testAttributeValues[name]; + if (value !== undefined) { + builder.setAttribute(name, sensitive, value); + } + } + + // Build user certificate + const certificate = await builder.build({ serial: 2 }); + return({ + certificate: new Certificates.Certificate(certificate, { + store: { root: new Set([ca]) } + }), + certificateWithKey: new Certificates.Certificate(certificate, { + subjectKey: subjectAccount, + store: { root: new Set([ca]) } + }), + subjectKey: subjectAccount, + issuerAccount, + ca + }); +} +