339 lines
9.4 KiB
TypeScript
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`,
|
|
);
|
|
}
|
|
}
|
|
}
|