Skip to content

Commit

Permalink
Create salted hashes of privateData in Rfq (#215)
Browse files Browse the repository at this point in the history
* Create salted hashes of privateData in Rfq

* Add new test vector

* Lint

* Update packages/protocol/tests/generate-test-vectors.ts

Co-authored-by: Kendall Weihe <[email protected]>

* Add OSP Reusable Workflow for Scans (#214)

* Rip out List Offering query params (#217)

* Rip out List Offering query params

* Rip out the rest

* PR comments

* Update submodule to latest in spec branch

* Add doc to PrivatePaymentDetails

* Add tests

* claimsHashes -> claimsHash

* Lint

* Require salt in Rfq privateData JSON schema

* Fix test names

* Lint

* Update submodule to main

---------

Co-authored-by: Kendall Weihe <[email protected]>
Co-authored-by: Leo Ribeiro <[email protected]>
  • Loading branch information
3 people authored Mar 31, 2024
1 parent 300f346 commit 717dac3
Show file tree
Hide file tree
Showing 9 changed files with 579 additions and 28 deletions.
2 changes: 2 additions & 0 deletions packages/protocol/build/compile-validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -38,6 +39,7 @@ const schemas = {
orderstatus : OrderstatusSchema,
quote : QuoteSchema,
resource : ResourceSchema,
rfqPrivate : RfqPrivateSchema,
rfq : RfqSchema,
}

Expand Down
6 changes: 3 additions & 3 deletions packages/protocol/src/dev-tools.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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' },
Expand All @@ -187,7 +187,7 @@ export class DevTools {
/**
* creates an example RfqData. Useful for testing purposes
*/
static async createRfqData(opts?: MessageOptions): Promise<RfqData> {
static async createRfqData(opts?: MessageOptions): Promise<UnhashedRfqData> {
let vcJwt: string = ''

if (opts?.sender) {
Expand Down
237 changes: 219 additions & 18 deletions packages/protocol/src/message-kinds/rfq.ts
Original file line number Diff line number Diff line change
@@ -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<RfqMetadata, 'id' | 'kind' | 'createdAt' | 'exchangeId' | 'protocol'> & { 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
Expand All @@ -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
}

/**
Expand All @@ -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<Rfq> {
static async parse(rawMessage: MessageModel | string, opts?: ParseRfqOptions): Promise<Rfq> {
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
Expand All @@ -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()
}

/**
Expand Down Expand Up @@ -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)
}
Expand All @@ -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<string, any> | 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)`
)
}

Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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')
Expand All @@ -212,6 +410,9 @@ export class Rfq extends Message {
*/
toJSON() {
const jsonMessage = super.toJSON()
if (this.privateData !== undefined) {
jsonMessage.privateData = this.privateData
}

return jsonMessage
}
Expand Down
Loading

0 comments on commit 717dac3

Please sign in to comment.