diff --git a/packages/protocol/build/compile-validators.js b/packages/protocol/build/compile-validators.js index c794eac7..eb595ac6 100644 --- a/packages/protocol/build/compile-validators.js +++ b/packages/protocol/build/compile-validators.js @@ -26,6 +26,7 @@ import OrderSchema from '../../../tbdex/hosted/json-schemas/order.schema.json' a import OrderstatusSchema from '../../../tbdex/hosted/json-schemas/orderstatus.schema.json' assert { type: 'json' } import QuoteSchema from '../../../tbdex/hosted/json-schemas/quote.schema.json' assert { type: 'json' } import ResourceSchema from '../../../tbdex/hosted/json-schemas/resource.schema.json' assert { type: 'json' } +import RfqPrivateSchema from '../../../tbdex/hosted/json-schemas/rfq-private.schema.json' assert { type: 'json' } import RfqSchema from '../../../tbdex/hosted/json-schemas/rfq.schema.json' assert { type: 'json' } const schemas = { @@ -38,6 +39,7 @@ const schemas = { orderstatus : OrderstatusSchema, quote : QuoteSchema, resource : ResourceSchema, + rfqPrivate : RfqPrivateSchema, rfq : RfqSchema, } diff --git a/packages/protocol/src/dev-tools.ts b/packages/protocol/src/dev-tools.ts index cdbcf0ef..15b81d94 100644 --- a/packages/protocol/src/dev-tools.ts +++ b/packages/protocol/src/dev-tools.ts @@ -1,5 +1,5 @@ -import type { BalanceData, OfferingData, QuoteData, RfqData } from './types.js' +import type { BalanceData, OfferingData, QuoteData, UnhashedRfqData } from './types.js' import type { BearerDid } from '@web5/dids' import { Balance, Offering } from './resource-kinds/index.js' @@ -176,7 +176,7 @@ export class DevTools { static async createRfq(opts: MessageOptions) { const { sender, receiver } = opts - const rfqData: RfqData = await DevTools.createRfqData(opts) + const rfqData: UnhashedRfqData = await DevTools.createRfqData(opts) return Rfq.create({ metadata : { from: sender.uri, to: receiver?.uri ?? 'did:ex:pfi' }, @@ -187,7 +187,7 @@ export class DevTools { /** * creates an example RfqData. Useful for testing purposes */ - static async createRfqData(opts?: MessageOptions): Promise { + static async createRfqData(opts?: MessageOptions): Promise { let vcJwt: string = '' if (opts?.sender) { diff --git a/packages/protocol/src/message-kinds/rfq.ts b/packages/protocol/src/message-kinds/rfq.ts index 944c88a5..aa3b52c7 100644 --- a/packages/protocol/src/message-kinds/rfq.ts +++ b/packages/protocol/src/message-kinds/rfq.ts @@ -1,21 +1,38 @@ -import type { MessageKind, MessageModel, PayinMethod, RfqData, RfqMetadata, SelectedPayoutMethod } from '../types.js' +import type { MessageKind, MessageModel, PayinMethod, RfqData, RfqMetadata, RfqPrivateData, UnhashedRfqData } from '../types.js' import { BigNumber } from 'bignumber.js' +import { Crypto } from '../crypto.js' import { Offering } from '../resource-kinds/index.js' import { VerifiableCredential, PresentationExchange } from '@web5/credentials' import { Message } from '../message.js' import Ajv from 'ajv' import { Parser } from '../parser.js' +import { validate } from '../validator.js' +import { Convert } from '@web5/common' +import { randomBytes } from '@web5/crypto/utils' /** * Options passed to {@link Rfq.create} * @beta */ export type CreateRfqOptions = { - data: RfqData + data: UnhashedRfqData metadata: Omit & { protocol?: RfqMetadata['protocol'] } } +/** + * Options passed to {@link Rfq.parse} + * @beta + */ +export type ParseRfqOptions = { + /** + * If true, validate that all private data properties are present and run integrity check. + * Otherwise, only check integrity of private fields which are present. + * If false, validate only the private data properties that are currently present in `privateData` + */ + requireAllPrivateData: boolean +} + /** * Message sent by Alice to PFI to request a quote (RFQ) * @beta @@ -30,11 +47,14 @@ export class Rfq extends Message { readonly metadata: RfqMetadata /** Rfq's data containing information to initiate an exchange between Alice and a PFI */ readonly data: RfqData + /** Rfq's unhashed private information to initiate an exchange between Alice and a PFI */ + readonly privateData: RfqPrivateData | undefined - constructor(metadata: RfqMetadata, data: RfqData, signature?: string) { + constructor(metadata: RfqMetadata, data: RfqData, signature?: string, privateData?: RfqPrivateData) { super(metadata, data, signature) this.metadata = metadata this.data = data + this.privateData = privateData } /** @@ -43,19 +63,40 @@ export class Rfq extends Message { * @throws if the rfq could not be parsed or is not a valid Rfq * @returns The parsed Rfq */ - static async parse(rawMessage: MessageModel | string): Promise { + static async parse(rawMessage: MessageModel | string, opts?: ParseRfqOptions): Promise { const jsonMessage = Parser.rawToMessageModel(rawMessage) const rfq = new Rfq( jsonMessage.metadata as RfqMetadata, jsonMessage.data as RfqData, - jsonMessage.signature + jsonMessage.signature, + jsonMessage.privateData ) await rfq.verify() + + if (opts?.requireAllPrivateData) { + rfq.verifyAllPrivateData() + } else { + rfq.verifyPresentPrivateData() + } + return rfq } + /** + * Valid structure of the message including the presence of the signature + * using the official spec JSON Schemas + * @override + * @throws If the message's structure does not match the JSON schemas + */ + validate(): void { + super.validate() + if (this.privateData !== undefined) { + validate(this.privateData, 'rfqPrivate') + } + } + /** * Creates an rfq with the given options * @param opts - options to create an rfq @@ -72,16 +113,158 @@ export class Rfq extends Message { protocol : opts.metadata.protocol ?? '1.0' } - // TODO: hash and set private fields + const { data, privateData } = Rfq.hashPrivateData(opts.data) + + const rfq = new Rfq(metadata, data, undefined, privateData) + rfq.validateData() + + return rfq + } + + /** + * Hash private RFQ data and set private fields in an RfqPrivateData object + * @param - unhashedRfqData + * @returns An object with fields data and privateData. + * @returns {@link RfqData} The value of data field. + * @returns {@link RfqPrivateData} The value of privateData field. + */ + private static hashPrivateData(unhashedRfqData: UnhashedRfqData): { data: RfqData, privateData: RfqPrivateData } { + const salt = Convert.uint8Array(randomBytes(16)).toString() + + const { + claims, + payin, + payout, + ...remainingRfqData + } = unhashedRfqData + const { paymentDetails: payinDetails, ...remainingPayin } = payin + const { paymentDetails: payoutDetails, ...remainingPayout } = payout const data: RfqData = { - ...opts.data, + ...remainingRfqData, + payin : remainingPayin, + payout : remainingPayout, + } + if (payinDetails !== undefined) { + data.payin!.paymentDetailsHash = Rfq.digestPrivateData(salt, payinDetails) + } + if (payoutDetails !== undefined) { + data.payout!.paymentDetailsHash = Rfq.digestPrivateData(salt, payoutDetails) + } + if (claims !== undefined && claims?.length > 0) { + data.claimsHash = Rfq.digestPrivateData(salt, claims) } - const rfq = new Rfq(metadata, data) - rfq.validateData() + const privateData: RfqPrivateData = { + salt, + payin: { + paymentDetails: payinDetails, + }, + payout: { + paymentDetails: payoutDetails, + }, + claims: claims + } - return rfq + return { + data, + privateData, + } + } + + /** + * Verify the presence and integrity of all possible properties in {@link Rfq.privateData}. + * @throws if there are properties missing in {@link Rfq.privateData} or which do not match the corresponding + * hashed property in {@link Rfq.data} + */ + private verifyAllPrivateData(): void { + if (this.privateData === undefined) { + throw new Error('Could not verify all privateData because privateData property is missing') + } + + // Verify payin details + if (this.data.payin.paymentDetailsHash !== undefined) { + this.verifyPayinDetailsHash() + } + + // Verify payout details + if (this.data.payout.paymentDetailsHash !== undefined) { + this.verifyPayoutDetailsHash() + } + + // Verify claims + if (this.data.claimsHash !== undefined) { + this.verifyClaimsHash() + } + } + + /** + * Verify the integrity properties that are present in {@link Rfq.privateData}. + * @throws if there are properties present in {@link Rfq.privateData} which do not match the corresponding + * hashed property in {@link Rfq.data} + */ + private verifyPresentPrivateData(): void { + // Verify payin details + if (this.data.payin.paymentDetailsHash !== undefined && this.privateData?.payin?.paymentDetails !== undefined) { + this.verifyPayinDetailsHash() + } + + // Verify payout details + if (this.data.payout.paymentDetailsHash !== undefined && this.privateData?.payout?.paymentDetails !== undefined) { + this.verifyPayoutDetailsHash() + } + + // Verify claims + if (this.data.claimsHash !== undefined && this.privateData?.claims !== undefined) { + this.verifyClaimsHash() + } + } + + private verifyPayinDetailsHash(): void { + const digest = Rfq.digestPrivateData(this.privateData!.salt, this.privateData?.payin?.paymentDetails) + + if (digest !== this.data.payin.paymentDetailsHash) { + throw new Error( + 'Private data integrity check failed: ' + + 'data.payin.paymentDetailsHash does not match digest of privateData.payin.paymentDetails' + ) + } + } + + private verifyPayoutDetailsHash(): void { + const digest = Rfq.digestPrivateData(this.privateData!.salt, this.privateData?.payout?.paymentDetails) + + if (digest !== this.data.payout.paymentDetailsHash) { + throw new Error( + 'Private data integrity check failed: ' + + 'data.payout.paymentDetailsHash does not match digest of privateData.payout.paymentDetails' + ) + } + } + + private verifyClaimsHash(): void { + const claimsHash = this.data.claimsHash! + const claims = this.privateData?.claims + const digest = Rfq.digestPrivateData(this.privateData!.salt, claims) + + if (digest !== claimsHash) { + throw new Error( + 'Private data integrity check failed: ' + + `data.claimsHash does not match digest of privateData.claims` + ) + } + } + + /** + * Given a salt and a value, compute a deterministic digest used in hashed fields in RfqData + * @param - salt + * @param - value + * @returns salted hash of the private data value + */ + private static digestPrivateData(salt: string, value: any): string { + const digestible = [salt, value] + const byteArray = Crypto.digest(digestible) + return Convert.uint8Array(byteArray).toBase64Url() } /** @@ -126,8 +309,20 @@ export class Rfq extends Message { } // Verify payin/payout methods - this.verifyPaymentMethod(this.data.payin, offering.data.payin.methods, 'payin') - this.verifyPaymentMethod(this.data.payout, offering.data.payout.methods, 'payout') + this.verifyPaymentMethod( + this.data.payin.kind, + this.data.payin.paymentDetailsHash, + this.privateData?.payin?.paymentDetails, + offering.data.payin.methods, + 'payin' + ) + this.verifyPaymentMethod( + this.data.payout.kind, + this.data.payout.paymentDetailsHash, + this.privateData?.payout?.paymentDetails, + offering.data.payout.methods, + 'payout' + ) await this.verifyClaims(offering) } @@ -140,19 +335,22 @@ export class Rfq extends Message { * @param payDirection - Either 'payin' or 'payout', used to provide more detailed error messages. * * @throws if rfqPaymentMethod property `kind` cannot be validated against the provided offering's paymentMethod's kinds + * @throws if {@link Rfq.privateData} property `paymentDetails` is missing but is necessary to validate against the provided offering's paymentMethod's kinds * @throws if rfqPaymentMethod property `paymentDetails` cannot be validated against the provided offering's paymentMethod's requiredPaymentDetails */ private verifyPaymentMethod( - rfqPaymentMethod: SelectedPayoutMethod, + selectedPaymentKind: string | undefined, + selectedPaymentDetailsHash: string | undefined, + selectedPaymentDetails: Record | undefined, allowedPaymentMethods: PayinMethod[], payDirection: 'payin' | 'payout' ): void { - const paymentMethodMatches = allowedPaymentMethods.filter(paymentMethod => paymentMethod.kind === rfqPaymentMethod.kind) + const paymentMethodMatches = allowedPaymentMethods.filter(paymentMethod => paymentMethod.kind === selectedPaymentKind) if (!paymentMethodMatches.length) { const paymentMethodKinds = allowedPaymentMethods.map(paymentMethod => paymentMethod.kind).join(', ') throw new Error( - `offering does not support rfq's ${payDirection}Method kind. (rfq) ${rfqPaymentMethod.kind} was not found in: [${paymentMethodKinds}] (offering)` + `offering does not support rfq's ${payDirection}Method kind. (rfq) ${selectedPaymentKind} was not found in: [${paymentMethodKinds}] (offering)` ) } @@ -162,7 +360,7 @@ export class Rfq extends Message { for (const paymentMethodMatch of paymentMethodMatches) { if (!paymentMethodMatch.requiredPaymentDetails) { // If requiredPaymentDetails is omitted, and paymentDetails is also omitted, we have a match - if (!rfqPaymentMethod.paymentDetails) { + if (selectedPaymentDetailsHash === undefined) { return } @@ -171,7 +369,7 @@ export class Rfq extends Message { } else { // requiredPaymentDetails is present, so Rfq's payment details must match const validate = ajv.compile(paymentMethodMatch.requiredPaymentDetails) - const isValid = validate(rfqPaymentMethod.paymentDetails) + const isValid = validate(selectedPaymentDetails) if (isValid) { // Selected payment method matches one of the offering's allowed payment methods return @@ -196,7 +394,7 @@ export class Rfq extends Message { return } - const credentials = PresentationExchange.selectCredentials({ vcJwts: this.data.claims, presentationDefinition: offering.data.requiredClaims }) + const credentials = PresentationExchange.selectCredentials({ vcJwts: this.privateData?.claims ?? [], presentationDefinition: offering.data.requiredClaims }) if (credentials.length === 0) { throw new Error('claims do not fulfill the offering\'s requirements') @@ -212,6 +410,9 @@ export class Rfq extends Message { */ toJSON() { const jsonMessage = super.toJSON() + if (this.privateData !== undefined) { + jsonMessage.privateData = this.privateData + } return jsonMessage } diff --git a/packages/protocol/src/parser.ts b/packages/protocol/src/parser.ts index 7553f7e9..1a56d9d8 100644 --- a/packages/protocol/src/parser.ts +++ b/packages/protocol/src/parser.ts @@ -32,7 +32,8 @@ export class Parser { message = new Rfq( jsonMessage.metadata as RfqMetadata, jsonMessage.data as RfqData, - jsonMessage.signature + jsonMessage.signature, + jsonMessage.privateData, ) break diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 5e3a6ee1..a7e1077b 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -182,6 +182,8 @@ export type MessageModel = { data: MessageData /** signature that verifies that authenticity and integrity of a message */ signature?: string + /** Private data which can be detached from the payload without disrupting integrity. Only used in RFQs */ + privateData?: RfqPrivateData } /** @@ -256,13 +258,49 @@ export type MessageData = RfqData | QuoteData | OrderData | OrderStatusData | Cl export type RfqData = { /** Offering which Alice would like to get a quote for */ offeringId: string - /** Selected payment method that Alice will use to send the listed payin currency to the PFI. */ payin: SelectedPayinMethod /** Selected payment method that the PFI will use to send the listed base currency to Alice */ payout: SelectedPayoutMethod + /** Hashes of claims that fulfill the requirements declared in an Offering */ + claimsHash?: string +} + +/** + * Private data contained in a RFQ message + * @beta + */ +export type RfqPrivateData = { + /** Randomly generated cryptographic salt used to hash privateData fields */ + salt: string /** claims that fulfill the requirements declared in an Offering */ - claims: string[] + claims?: string[] + /** Selected payment method that Alice will use to send the listed payin currency to the PFI. */ + payin?: PrivatePaymentDetails + /** Selected payment method that the PFI will use to send the listed base currency to Alice */ + payout?: PrivatePaymentDetails +} + +/** + * Data contained in a RFQ message, including data which will be placed in {@link RfqPrivateData} + * @beta + */ +export type UnhashedRfqData = Omit & { + payin: Omit & PrivatePaymentDetails + payout: Omit & PrivatePaymentDetails, + claims?: string[] +} + +/** + * A container for the unhashed `paymentDetails` + * @beta + */ +export type PrivatePaymentDetails = { + /** + * An object containing the properties defined in the respective Offering's requiredPaymentDetails json schema. + * Omitted from the signature payload. + */ + paymentDetails?: Record } /** @@ -278,7 +316,7 @@ export type SelectedPayinMethod = { * An object containing the properties defined in the respective Offering's requiredPaymentDetails json schema. * Omitted from the signature payload. */ - paymentDetails?: Record + paymentDetailsHash?: string } /** @@ -301,7 +339,7 @@ export type SelectedPayoutMethod = { * An object containing the properties defined in the respective Offering's requiredPaymentDetails json schema. * Omitted from the signature payload. */ - paymentDetails?: Record + paymentDetailsHash?: string } /** diff --git a/packages/protocol/tests/generate-test-vectors.ts b/packages/protocol/tests/generate-test-vectors.ts index 901bb53b..579dcbaf 100644 --- a/packages/protocol/tests/generate-test-vectors.ts +++ b/packages/protocol/tests/generate-test-vectors.ts @@ -117,6 +117,55 @@ const generateParseRfqVector = async () => { } } +const generateParseRfqOmitPrivateDataVector = async () => { + const vc = await VerifiableCredential.create({ + type : 'PewPewCredential', + issuer : aliceDid.uri, + subject : aliceDid.uri, + data : { + 'beep': 'boop' + } + }) + + const vcJwt = await vc.sign({ did: aliceDid }) + + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: pfiDid.uri, protocol: '1.0' }, + data : { + offeringId : Resource.generateId('offering'), + payin : { + kind : 'DEBIT_CARD', + amount : '20000.00', + paymentDetails : { + 'cardNumber' : '1234567890123456', + 'expiryDate' : '12/22', + 'cardHolderName' : 'Ephraim Bartholomew Winthrop', + 'cvv' : '123' + } + }, + payout: { + kind : 'BTC_ADDRESS', + paymentDetails : { + btcAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' + } + }, + claims: [vcJwt] + } + }) + + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + delete rfqJson.privateData + + return { + description : 'RFQ with privateData omitted parses from string', + input : JSON.stringify(rfqJson), + output : rfqJson, + error : false, + } +} + const generateParseOrderVector = async () => { const order = Order.create({ metadata: { from: aliceDid.uri, to: pfiDid.uri, exchangeId: Message.generateId('rfq'), externalId: 'ext_1234', protocol: '1.0' } @@ -181,6 +230,7 @@ const overWriteTestVectors = async () => { { filename: 'parse-quote.json', vector: await generateParseQuoteVector() }, { filename: 'parse-close.json', vector: await generateParseCloseVector() }, { filename: 'parse-rfq.json', vector: await generateParseRfqVector() }, + { filename: 'parse-rfq-omit-private-data.json', vector: await generateParseRfqOmitPrivateDataVector() }, { filename: 'parse-order.json', vector: await generateParseOrderVector() }, { filename: 'parse-orderstatus.json', vector: await generateParseOrderStatusVector() }, ] diff --git a/packages/protocol/tests/rfq.spec.ts b/packages/protocol/tests/rfq.spec.ts index 75272506..3d876d1c 100644 --- a/packages/protocol/tests/rfq.spec.ts +++ b/packages/protocol/tests/rfq.spec.ts @@ -114,6 +114,252 @@ describe('Rfq', () => { expect(jsonMessage).to.equal(JSON.stringify(parsedMessage)) }) + + describe('requireAllPrivateData: true', () => { + it('succeeds when all privateData is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + }) + + it('throws if private data is missing but hashed fields are present in Rfq.data', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + delete rfqJson.privateData + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + expect.fail() + } catch(e) { + expect(e.message).to.include('Could not verify all privateData because privateData property is missing') + } + }) + + it('throws if salt is missing but hashed fields are present in Rfq.data', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + delete (rfqJson.privateData as any).salt + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + expect.fail() + } catch(e) { + expect(e.message).to.include('must have required property \'salt\'') + } + }) + + + it('throws if Rfq.privateData.payin.paymentDetails is incorrect but Rfq.data.payin.paymentDetailsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + rfq.data.payin.paymentDetailsHash = '123' + await rfq.sign(aliceDid) + + const rfqJson: any = rfq.toJSON() + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.payin.paymentDetailsHash does not match digest of privateData.payin.paymentDetails') + } + }) + + it('throws if Rfq.privateData.payout.paymentDetails is incorrect but Rfq.data.payout.paymentDetailsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + rfq.data.payout.paymentDetailsHash = '123' + await rfq.sign(aliceDid) + + const rfqJson: any = rfq.toJSON() + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.payout.paymentDetailsHash does not match digest of privateData.payout.paymentDetails') + } + }) + + it('throws if Rfq.privateData.claims is incorrect but Rfq.data.claimsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + rfq.data.claimsHash = 'not right' + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.claimsHash does not match digest of privateData.claims') + } + }) + + it('throws if Rfq.privateData.payin.paymentDetails is missing but Rfq.data.payin.paymentDetailsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + delete rfqJson.privateData!.payin!.paymentDetails + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.payin.paymentDetailsHash does not match digest of privateData.payin.paymentDetails') + } + }) + + it('throws if Rfq.privateData.payout.paymentDetails is missing but Rfq.data.payout.paymentDetailsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + delete rfqJson.privateData!.payout!.paymentDetails + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.payout.paymentDetailsHash does not match digest of privateData.payout.paymentDetails') + } + }) + + it('throws if Rfq.privateData.claims is missing but Rfq.data.claimsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + delete rfqJson.privateData!.claims + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: true }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.claimsHash does not match digest of privateData.claims') + } + }) + }) + + describe('requireAllPrivateData: false', () => { + it('throws if salt is missing but hashed fields are present in Rfq.data', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + delete (rfqJson.privateData as any).salt + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: false }) + expect.fail() + } catch(e) { + expect(e.message).to.include('must have required property \'salt\'') + } + }) + + it('throws if Rfq.privateData.payin.paymentDetails is missing but Rfq.data.payin.paymentDetailsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + rfq.data.payin.paymentDetailsHash = '123' + await rfq.sign(aliceDid) + + const rfqJson: any = rfq.toJSON() + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: false }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.payin.paymentDetailsHash does not match digest of privateData.payin.paymentDetails') + } + }) + + it('throws if Rfq.privateData.payout.paymentDetails is missing but Rfq.data.payout.paymentDetailsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + rfq.data.payout.paymentDetailsHash = '123' + await rfq.sign(aliceDid) + + const rfqJson: any = rfq.toJSON() + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: false }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.payout.paymentDetailsHash does not match digest of privateData.payout.paymentDetails') + } + }) + + it('throws if Rfq.privateData.claims is missing but Rfq.data.claimsHash is present', async () => { + const aliceDid = await DidJwk.create() + const rfq = Rfq.create({ + metadata : { from: aliceDid.uri, to: 'did:ex:pfi' }, + data : await DevTools.createRfqData() + }) + rfq.data.claimsHash = 'not right' + await rfq.sign(aliceDid) + + const rfqJson = rfq.toJSON() + + try { + await Rfq.parse(rfqJson, { requireAllPrivateData: false }) + expect.fail() + } catch(e) { + expect(e.message).to.include('data.claimsHash does not match digest of privateData.claims') + } + }) + }) }) describe('verifySignature', () => { diff --git a/packages/protocol/tests/test-vectors.spec.ts b/packages/protocol/tests/test-vectors.spec.ts index 6a1e8a83..3d2e86b3 100644 --- a/packages/protocol/tests/test-vectors.spec.ts +++ b/packages/protocol/tests/test-vectors.spec.ts @@ -5,6 +5,7 @@ import ParseOrder from '../../../tbdex/hosted/test-vectors/protocol/vectors/pars import ParseOrderStatus from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-orderstatus.json' assert { type: 'json' } import ParseQuote from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-quote.json' assert { type: 'json' } import ParseRfq from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-rfq.json' assert { type: 'json' } +import ParseOmitPrivateData from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-rfq-omit-private-data.json' assert { type: 'json' } import ParseBalance from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-balance.json' assert { type: 'json' } import { Balance, Close, Offering, Order, OrderStatus, Quote, Rfq } from '../src/main.js' import { Parser } from '../src/parser.js' @@ -71,7 +72,7 @@ describe('TbdexTestVectorsProtocol', function () { expect(quote.toJSON()).to.deep.eq(ParseQuote.output) }) - it.skip('parse_rfq', async () => { + it('parse_rfq', async () => { // Parse with parseMessage() const message = await Parser.parseMessage(ParseRfq.input) expect(message.isRfq()).to.be.true @@ -83,6 +84,18 @@ describe('TbdexTestVectorsProtocol', function () { expect(rfq.toJSON()).to.deep.eq(ParseRfq.output) }) + it('parse_rfq_omit_private_data', async () => { + // Parse with parseMessage() + const message = await Parser.parseMessage(ParseOmitPrivateData.input) + expect(message.isRfq()).to.be.true + expect(message.toJSON()).to.deep.eq(ParseOmitPrivateData.output) + + // Parse with Rfq.parse() + const rfq = await Rfq.parse(ParseOmitPrivateData.input) + expect(rfq.isRfq()).to.be.true + expect(rfq.toJSON()).to.deep.eq(ParseOmitPrivateData.output) + }) + it('parse_balance', async () => { // Parse with parseResource() const resource = await Parser.parseResource(ParseBalance.input) diff --git a/tbdex b/tbdex index 6af20c9d..38cf284d 160000 --- a/tbdex +++ b/tbdex @@ -1 +1 @@ -Subproject commit 6af20c9d52a1497bef72ee88f712496844edd372 +Subproject commit 38cf284d8279f897cc69f3ffc30690ca90b273e6