Files
xo-stack/packages/json-payment-protocol-v2/src/index.ts
T

339 lines
9.4 KiB
TypeScript

import {
binsAreEqual,
hexToBin,
secp256k1,
sha256,
utf8ToBin,
} from '@bitauth/libauth';
export type TrustedKeys = {
[identity: string]: {
owner: string;
expirationDate: string;
domains: Array<string>;
publicKeys: Array<string>;
};
};
export interface PaymentOptionsResponse {
time: string; // ISO date string
expires: string; // ISO date string
memo: string;
paymentUrl: string;
paymentId: string;
paymentOptions: Array<{
chain: string;
currency: string;
network: string;
estimatedAmount: number;
requiredFeeRate: number;
minerFee: number;
decimals: number;
selected: boolean;
}>;
}
export interface PaymentRequestBody {
chain: string;
currency?: string;
}
export interface PaymentRequestResponse {
time: string; // ISO date string
expires: string; // ISO date string
memo: string;
paymentUrl: string;
paymentId: string;
chain: string;
network: string;
instructions: Array<{
type: string;
requiredFeeRate?: number;
outputs: Array<{
amount: number;
address: string;
}>;
}>;
}
export interface PaymentVerificationBody {
chain: string;
transactions: Array<{
tx: string;
}>;
currency: string;
}
export interface PaymentBody {
chain: string;
transactions: Array<{
tx: string;
}>;
}
export interface PaymentResponse {
memo: string;
transactions: Array<{
tx: string;
}>;
}
// These are fetched from https://bitpay.com/signingKeys/paymentProtocol.json
// Note that the expiration date is 2018, so I'm not sure wallets would even be using these anymore.
export const DEFAULT_TRUSTED_KEYS: TrustedKeys = {
mh65MN7drqmwpCRZcEeBEE9ceQCQ95HtZc: {
owner: 'BitPay, Inc.',
expirationDate: '2018-12-04T00:00:00.000Z',
domains: ['test.bitpay.com'],
publicKeys: [
'03159069584176096f1c89763488b94dbc8d5e1fa7bf91f50b42f4befe4e45295a',
'03de8bc75ff1fd82133dd1777fa08449032d4a7ac58c234545ffb7da38bc94aea1',
],
},
'1DbY94wCcLRM1Y6RGFg457JyqBbsYxzfiN': {
owner: 'BitPay, Inc.',
expirationDate: '2018-12-03T00:00:00.000Z',
domains: ['bitpay.com'],
publicKeys: [
'03218884b9a42334195ec32344d487ef291fda4b6e712a7858e9836c2d326e0c08',
'027ffb2cc626a97bc3b449ae3b28b0acf07bc900bba8912d60ca12ad2b616cbe72',
'03a29de86e138106a329bc6db085f02d8e19f59249204dfd5cc797fcb36b8ed93c',
'022ba5469ec10a1d12911a5ca39c45bd0de24db83145507ffaf1fb6a3ca047f717',
'03065b167ac14111efdbf7708708ecf44d22ed0e97af3c656a183162dabf5a59bf',
'03dc0f18eadf553705ab87421d264204a9fcb8a7984997e07a867cbf55972908eb',
'02cab72d4d5d0c9ede7617562e42c485dcfb34db52fd0e6a3de62462040ac1ce90',
'02e184ad56dee82bbaf290144f24b7ab8e508f6c26e9181c8501c8219a7e1999b1',
'027b7d555a3444936ddb200ca640f41aa8c89851fbbb13521b5aaa32d75df57cc8',
'03c3872f9c42c1c2fca77a77727f3b180cdc811754f6b560d298f1ce63c78df484',
'02527a9029fbb09ec9a02471e03296932d20544d3935fa492e2f74a1f79a0c48f6',
'03adbd79e63b046cc25c70b2c1f428a410e1ca46f88fb6c387f2883090b9fa0367',
'02c9f1fbf8afa5a9b6a22a6c442b184e7e72a8b173c4a06beb11a5b700f024b06e',
'0386126eaa4f7bc816ea0c5841cdd1086704167c126e03c17dbd5fc899469091bb',
'03271e4ea488ed0d1294dce6f3554c5d6f9cb068a847f500f2ea392148402499f1',
],
},
};
export class JSONPaymentProtocolV2 {
constructor(
public opts: {
verifySignatures: boolean;
allowUnknownIdentities: boolean;
} = {
verifySignatures: false,
allowUnknownIdentities: true,
},
public trustedKeys: TrustedKeys = DEFAULT_TRUSTED_KEYS,
) {}
async paymentOptions(url: string): Promise<PaymentOptionsResponse> {
const response = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/payment-options',
'x-paypro-version': '2',
},
});
if (!response.ok) {
throw new Error(`${(await response.text()) || response.status}`);
}
const responseRaw = await response.text();
if (this.opts.verifySignatures) {
this.verifyResponseSignature(url, responseRaw, response.headers);
}
return JSON.parse(responseRaw);
}
async paymentRequest(
url: string,
body: PaymentRequestBody = {
chain: 'BCH',
currency: 'BCH',
},
): Promise<PaymentRequestResponse> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/payment-request',
'x-paypro-version': '2',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${(await response.text()) || response.status}`);
}
const responseRaw = await response.text();
if (this.opts.verifySignatures) {
this.verifyResponseSignature(url, responseRaw, response.headers);
}
return JSON.parse(responseRaw);
}
async paymentVerifcation(
url: string,
body: PaymentVerificationBody,
): Promise<void> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/payment',
'x-paypro-version': '2',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${(await response.text()) || response.status}`);
}
const responseRaw = await response.text();
if (this.opts.verifySignatures) {
this.verifyResponseSignature(url, responseRaw, response.headers);
}
return JSON.parse(responseRaw);
}
async payment(url: string, body: PaymentBody): Promise<PaymentResponse> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/payment',
'x-paypro-version': '2',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${(await response.text()) || response.status}`);
}
const responseRaw = await response.text();
if (this.opts.verifySignatures) {
this.verifyResponseSignature(url, responseRaw, response.headers);
}
return JSON.parse(responseRaw);
}
protected verifyResponseSignature(
url: string,
rawBody: string,
headers: Headers,
): void {
// Parse the URL so we can extract the hostname.
const parsedUrl = new URL(url);
const { hostname } = parsedUrl;
// Extract necessary headers.
const digest = headers.get('digest');
const signature = headers.get('signature');
const signatureType = headers.get('x-signature-type');
const identity = headers.get('x-identity');
// Ensure that expected hash was provided.
if (typeof digest !== 'string' || !digest) {
throw new Error('Invalid digest header provided');
}
// Ensure that an identity was provided.
if (typeof identity !== 'string' || !identity) {
throw new Error('Invalid identity header provided.');
}
// Ensure that a signature was provided.
if (typeof signature !== 'string' || !signature) {
throw new Error('Invalid signature header provided.');
}
// Ensure that signature type is "ecc".
if (typeof signatureType !== 'string' || signatureType !== 'ecc') {
throw new Error(
`Unsupported signature type "${signatureType}" (must be "ecc")`,
);
}
// Split the digest into type and content.
const [digestType, digestHash] = digest.split('=');
// Ensure that digest type is SHA-256.
if (digestType !== 'SHA-256') {
throw new Error(
`Unsupported Digest Type (${digestType}). Must be "SHA-256"`,
);
}
// Ensure that digest is present.
if (!digestHash) {
throw new Error('Invalid digest header. Must be of form SHA-256=hash');
}
// Try to find a key for this identity in our trusted keys.
const keyData = this.trustedKeys[identity];
// If we could not find a key...
if (!keyData) {
// And we do not allow unknown entities, throw an error.
if (!this.opts.allowUnknownIdentities) {
throw new Error(`No keys available for identity "${identity}"`);
}
// Compile all our known domains into a single array.
const domains = Object.values(this.trustedKeys).flatMap(
(entry) => entry.domains,
);
// If this hostname is contained in our known domains, throw an error.
if (domains.includes(hostname)) {
throw new Error(
`"${hostname}" has a trusted key, but it belongs to another identity`,
);
}
// Otherwise, just return.
return;
}
// Ensure that the domain is whitelisted for this identity.
if (!keyData.domains.includes(hostname)) {
throw new Error(
`${hostname} is not included in this identity's trusted domains`,
);
}
// Calculate the hash of the body.
const actualHash = sha256.hash(utf8ToBin(rawBody));
// Ensure the actual hash matches the expected hash.
if (!binsAreEqual(actualHash, hexToBin(digestHash))) {
throw new Error('Provided digest does not match actual digest');
}
// Iterate through each of the whitelisted public keys and see if any of them can match the expected signature.
const didVerifySuccessfully = keyData.publicKeys.some((publicKey) => {
return secp256k1.verifySignatureCompact(
hexToBin(signature),
hexToBin(publicKey),
actualHash,
);
});
// If none of the public keys can verify the signature, throw an error.
if (!didVerifySuccessfully) {
throw new Error(
`Failed to verify the signature against any of the identity's (${identity}) listed public keys`,
);
}
}
}