@visa/cli 4.1.0-rc.154 → 4.1.0-rc.156
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checkout-engine/adapters/generic.d.ts +42 -0
- package/dist/checkout-engine/adapters/generic.js +163 -4
- package/dist/checkout-engine/adapters/shopify.d.ts +25 -1
- package/dist/checkout-engine/adapters/shopify.js +103 -12
- package/dist/checkout-engine/cli-engine.d.ts +4 -0
- package/dist/checkout-engine/cli-engine.js +6 -3
- package/dist/checkout-engine/confirmed-merchants.js +51 -22
- package/dist/checkout-engine/executor.d.ts +2 -0
- package/dist/checkout-engine/executor.js +50 -2
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +13 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +22 -0
- package/dist/checkout-engine/receipt.d.ts +42 -2
- package/dist/checkout-engine/receipt.js +30 -14
- package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
- package/dist/checkout-engine/web-bot-auth.js +159 -0
- package/dist/cli.js +359 -355
- package/dist/mcp-server/index.js +274 -272
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -24,7 +24,8 @@ import { observeOutcome } from './outcome.js';
|
|
|
24
24
|
import { selectAdapter } from './adapters/index.js';
|
|
25
25
|
import { traceHandleFields } from './trace-handles.js';
|
|
26
26
|
import { readGenericPageAmount } from './amount.js';
|
|
27
|
-
import {
|
|
27
|
+
import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
|
|
28
|
+
import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
|
|
28
29
|
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
29
30
|
const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
|
|
30
31
|
const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
|
|
@@ -674,7 +675,21 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
674
675
|
mandate: { ...opts.mandate },
|
|
675
676
|
};
|
|
676
677
|
const evidence = new EvidenceLog();
|
|
677
|
-
|
|
678
|
+
// Pin an English locale: amount reconciliation reads the order summary by
|
|
679
|
+
// its visible labels (Subtotal/Taxes/Total), and merchants localize by
|
|
680
|
+
// Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
|
|
681
|
+
// to /es-us and rendered "Impuestos estimados", so the total never parsed
|
|
682
|
+
// and the review refused fail-closed on a perfectly good checkout).
|
|
683
|
+
// Present Web Bot Auth (RFC 9421) credentials when, and only when, an
|
|
684
|
+
// operator directory is configured to resolve them. Off by default: an
|
|
685
|
+
// unresolvable signature fails verification and is worse than none. The
|
|
686
|
+
// signature covers @authority, so it is bound to the checkout host — a
|
|
687
|
+
// cross-origin redirect simply arrives unverified, never wrongly verified.
|
|
688
|
+
const webBotAuthHeaders = webBotAuthHeadersOrNone(options.webBotAuth ?? null, options.url, Date.now() / 1000);
|
|
689
|
+
const context = await options.browser.newContext({
|
|
690
|
+
locale: 'en-US',
|
|
691
|
+
...(webBotAuthHeaders ? { extraHTTPHeaders: webBotAuthHeaders } : {}),
|
|
692
|
+
});
|
|
678
693
|
// Bound every action so a mis-detected or hidden element fails fast instead
|
|
679
694
|
// of stalling on Playwright's long default timeout.
|
|
680
695
|
context.setDefaultTimeout(6000);
|
|
@@ -732,6 +747,19 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
732
747
|
// Instrument.getCredential() call can occur before an explicit approval.
|
|
733
748
|
let detected = await detectFields(page);
|
|
734
749
|
const shopifyPage = await isShopifyCheckoutPage(page);
|
|
750
|
+
if (shopifyPage) {
|
|
751
|
+
// Same checkout session, English presentation — the amount reader needs
|
|
752
|
+
// the English summary labels (see shopifyEnglishCheckoutUrl).
|
|
753
|
+
const englishUrl = shopifyEnglishCheckoutUrl(page.url());
|
|
754
|
+
if (englishUrl) {
|
|
755
|
+
await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
756
|
+
await waitForStableDom(page);
|
|
757
|
+
if (await isShopifyCheckoutPage(page)) {
|
|
758
|
+
evidence.step('navigation', { url: page.url(), reason: 'shopify-locale-normalized' });
|
|
759
|
+
detected = await detectFields(page);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
735
763
|
let adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
736
764
|
if (options.contact && adapter.prepareContact) {
|
|
737
765
|
const preparedContact = await adapter.prepareContact(page, options.contact);
|
|
@@ -799,6 +827,26 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
799
827
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
800
828
|
}
|
|
801
829
|
}
|
|
830
|
+
// A checkout the runner cannot put a card INTO is not reviewable. Without a
|
|
831
|
+
// detected card-number field a later pay would fill nothing and dispatch
|
|
832
|
+
// whatever control the review happened to bind (observed live 2026-08-16:
|
|
833
|
+
// a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
|
|
834
|
+
// and a Shopify discount-form "Submit" all reviewed clean this way — the
|
|
835
|
+
// real card fields sat in unreachable PSP iframes or an unrendered payment
|
|
836
|
+
// section). Every adapter fills from this same detection, so a missing
|
|
837
|
+
// number field here means no pay can ever succeed: refuse while it is
|
|
838
|
+
// still free.
|
|
839
|
+
if (!fields.number) {
|
|
840
|
+
const found = Object.keys(fields);
|
|
841
|
+
const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
|
|
842
|
+
'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
|
|
843
|
+
evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
|
|
844
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
845
|
+
return {
|
|
846
|
+
status: 'finished',
|
|
847
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, reason),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
802
850
|
const facts = await readTransactionFacts(page, options, 'review');
|
|
803
851
|
recordTransactionFacts(evidence, 'review', facts);
|
|
804
852
|
if (!facts.ok) {
|
|
@@ -61,6 +61,13 @@ export type CardMandateRecord = {
|
|
|
61
61
|
* created record attempted registration. ISO 8601.
|
|
62
62
|
*/
|
|
63
63
|
registerFailedAt?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Set after an authenticated owner-scoped server read proves this mandate was
|
|
66
|
+
* revoked. The local file is only an execution cache; server revocation is
|
|
67
|
+
* canonical and permanently retires the cached mandate from selection while
|
|
68
|
+
* retaining its receipt history for the owner.
|
|
69
|
+
*/
|
|
70
|
+
serverRevokedAt?: string;
|
|
64
71
|
/**
|
|
65
72
|
* A cross-host retail budget, not scoped to one `merchantHost`. The local
|
|
66
73
|
* selector may attempt it at any host, while the provider/network still
|
|
@@ -127,6 +134,12 @@ export declare class MandateLedger {
|
|
|
127
134
|
* Throws only if the mandate is unknown.
|
|
128
135
|
*/
|
|
129
136
|
markRegisterFailed(mandateId: string, now?: Date): Promise<CardMandateRecord>;
|
|
137
|
+
/**
|
|
138
|
+
* Retire a locally cached mandate after the authenticated account API proves
|
|
139
|
+
* it was revoked. This is idempotent and monotonic: an owner revocation can
|
|
140
|
+
* never be undone by a later stale/local read.
|
|
141
|
+
*/
|
|
142
|
+
markServerRevoked(mandateId: string, revokedAt: string): Promise<CardMandateRecord>;
|
|
130
143
|
/**
|
|
131
144
|
* ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
|
|
132
145
|
*
|
|
@@ -203,6 +203,7 @@ export class MandateLedger {
|
|
|
203
203
|
m.currencyCode.toUpperCase() === query.currencyCode.toUpperCase() &&
|
|
204
204
|
!m.unhonoredAt &&
|
|
205
205
|
!m.registerFailedAt &&
|
|
206
|
+
!m.serverRevokedAt &&
|
|
206
207
|
!isExpired(m, now) &&
|
|
207
208
|
hasDrawCountHeadroom(m) &&
|
|
208
209
|
remainingMinor(m) >= query.amountMinor);
|
|
@@ -252,6 +253,27 @@ export class MandateLedger {
|
|
|
252
253
|
return record;
|
|
253
254
|
});
|
|
254
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Retire a locally cached mandate after the authenticated account API proves
|
|
258
|
+
* it was revoked. This is idempotent and monotonic: an owner revocation can
|
|
259
|
+
* never be undone by a later stale/local read.
|
|
260
|
+
*/
|
|
261
|
+
markServerRevoked(mandateId, revokedAt) {
|
|
262
|
+
return this.run(async () => {
|
|
263
|
+
const parsed = new Date(revokedAt);
|
|
264
|
+
if (Number.isNaN(parsed.getTime()))
|
|
265
|
+
throw new Error('server revocation timestamp is invalid');
|
|
266
|
+
const file = await this.load();
|
|
267
|
+
const record = file.mandates.find((m) => m.mandateId === mandateId);
|
|
268
|
+
if (!record)
|
|
269
|
+
throw new Error(`no such mandate ${mandateId}`);
|
|
270
|
+
if (!record.serverRevokedAt) {
|
|
271
|
+
record.serverRevokedAt = parsed.toISOString();
|
|
272
|
+
await this.save(file);
|
|
273
|
+
}
|
|
274
|
+
return record;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
255
277
|
/**
|
|
256
278
|
* ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
|
|
257
279
|
*
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { EvidenceStep } from './evidence.js';
|
|
2
2
|
import type { CheckoutMode, CheckoutOutcome, CheckoutResult, CredentialLifecycle, CredentialTiming } from './executor.js';
|
|
3
3
|
import type { VicConfirmationReport } from './vic-confirmation.js';
|
|
4
|
-
|
|
4
|
+
/** Published v1 artifact shape. Retained so patch releases remain readable/typable. */
|
|
5
|
+
export type CheckoutReceiptV1 = {
|
|
5
6
|
schema: 'checkout-agent-receipt/v1';
|
|
6
7
|
recordedAt: string;
|
|
7
8
|
mode: CheckoutMode;
|
|
@@ -32,6 +33,42 @@ export type CheckoutReceipt = {
|
|
|
32
33
|
snapshotSummary: string | null;
|
|
33
34
|
};
|
|
34
35
|
};
|
|
36
|
+
export type CheckoutReceiptV2 = {
|
|
37
|
+
schema: 'checkout-agent-receipt/v2';
|
|
38
|
+
recordedAt: string;
|
|
39
|
+
merchant: {
|
|
40
|
+
name: string;
|
|
41
|
+
host: string;
|
|
42
|
+
checkoutUrl: string | null;
|
|
43
|
+
};
|
|
44
|
+
transaction: {
|
|
45
|
+
amount: string;
|
|
46
|
+
amountMinor: number;
|
|
47
|
+
currency: string;
|
|
48
|
+
};
|
|
49
|
+
outcome: CheckoutOutcome;
|
|
50
|
+
agent: {
|
|
51
|
+
name: string;
|
|
52
|
+
} | null;
|
|
53
|
+
rail: {
|
|
54
|
+
type: 'card';
|
|
55
|
+
cardLast4: string | null;
|
|
56
|
+
};
|
|
57
|
+
network: {
|
|
58
|
+
confirmation: 'APPROVED' | 'DECLINED' | null;
|
|
59
|
+
};
|
|
60
|
+
/** Local reviewed-attempt identifier. Keeps filenames stable across schema versions. */
|
|
61
|
+
receiptId: string | null;
|
|
62
|
+
/** Merchant confirmation reference only; never substituted with an internal review id. */
|
|
63
|
+
reference: string | null;
|
|
64
|
+
recovery: {
|
|
65
|
+
required: boolean;
|
|
66
|
+
retrySafe: boolean;
|
|
67
|
+
action: string;
|
|
68
|
+
actions: string[];
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
export type CheckoutReceipt = CheckoutReceiptV1 | CheckoutReceiptV2;
|
|
35
72
|
export type ReceiptWriteReport = {
|
|
36
73
|
written: true;
|
|
37
74
|
path: string;
|
|
@@ -46,6 +83,7 @@ export declare function buildReceipt(input: {
|
|
|
46
83
|
merchant: {
|
|
47
84
|
name: string;
|
|
48
85
|
host: string;
|
|
86
|
+
url?: string;
|
|
49
87
|
};
|
|
50
88
|
transaction: {
|
|
51
89
|
amount: string;
|
|
@@ -54,9 +92,11 @@ export declare function buildReceipt(input: {
|
|
|
54
92
|
};
|
|
55
93
|
result: CheckoutResult;
|
|
56
94
|
vicConfirmation: VicConfirmationReport | null;
|
|
95
|
+
agentName?: string;
|
|
96
|
+
cardLast4?: string;
|
|
57
97
|
/** Injectable for tests; defaults to now. */
|
|
58
98
|
recordedAt?: Date;
|
|
59
|
-
}):
|
|
99
|
+
}): CheckoutReceiptV2;
|
|
60
100
|
/**
|
|
61
101
|
* Defense-in-depth scrub before anything touches disk: any standalone
|
|
62
102
|
* 12–19-digit run that passes Luhn is replaced — contiguous OR separated by
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
|
|
3
4
|
import { submitClickedWithoutConfirmation } from './live-fill-approval.js';
|
|
4
5
|
/**
|
|
5
6
|
* What the operator still owes after this run. Empty for a clean dry-run fill
|
|
@@ -29,23 +30,37 @@ function reconciliationFor(result, vicConfirmation) {
|
|
|
29
30
|
}
|
|
30
31
|
export function buildReceipt(input) {
|
|
31
32
|
const { result } = input;
|
|
33
|
+
const reconciliation = reconciliationFor(result, input.vicConfirmation);
|
|
34
|
+
const fallbackAction = input.mode === 'dry-run'
|
|
35
|
+
? 'Dry run only — nothing was submitted; safe to run again.'
|
|
36
|
+
: result.outcome === 'confirmed'
|
|
37
|
+
? 'No action needed.'
|
|
38
|
+
: result.outcome === 'declined'
|
|
39
|
+
? 'Review the decline before trying again.'
|
|
40
|
+
: 'Review this outcome before retrying.';
|
|
41
|
+
const recoveryActions = reconciliation.reasons.length > 0 ? reconciliation.reasons : [fallbackAction];
|
|
42
|
+
const cardLast4 = input.cardLast4 && /^\d{4}$/.test(input.cardLast4) ? input.cardLast4 : null;
|
|
43
|
+
const checkoutUrl = input.merchant.url && KNOWN_MERCHANT_IDENTITIES[input.merchant.url] ? input.merchant.url : null;
|
|
44
|
+
const networkConfirmation = input.vicConfirmation?.posted === true ? input.vicConfirmation.transactionStatus : null;
|
|
32
45
|
return {
|
|
33
|
-
schema: 'checkout-agent-receipt/
|
|
46
|
+
schema: 'checkout-agent-receipt/v2',
|
|
34
47
|
recordedAt: (input.recordedAt ?? new Date()).toISOString(),
|
|
35
|
-
|
|
36
|
-
reviewId: input.reviewId,
|
|
37
|
-
merchant: { name: input.merchant.name, host: input.merchant.host },
|
|
48
|
+
merchant: { name: input.merchant.name, host: input.merchant.host, checkoutUrl },
|
|
38
49
|
transaction: input.transaction,
|
|
39
50
|
outcome: result.outcome,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
agent: input.agentName ? { name: input.agentName } : null,
|
|
52
|
+
rail: { type: 'card', cardLast4 },
|
|
53
|
+
network: { confirmation: networkConfirmation },
|
|
54
|
+
receiptId: input.reviewId,
|
|
55
|
+
reference: result.confirmationRef ?? null,
|
|
56
|
+
recovery: {
|
|
57
|
+
required: reconciliation.required,
|
|
58
|
+
// A clean dry-run did not submit anything, so repeating inspection is
|
|
59
|
+
// safe. Every submit outcome requires either no retry or owner review.
|
|
60
|
+
retrySafe: input.mode === 'dry-run' && !reconciliation.required,
|
|
61
|
+
action: recoveryActions[0],
|
|
62
|
+
actions: recoveryActions,
|
|
63
|
+
},
|
|
49
64
|
};
|
|
50
65
|
}
|
|
51
66
|
function luhnValid(digits) {
|
|
@@ -88,7 +103,8 @@ export function serializeReceipt(receipt) {
|
|
|
88
103
|
}
|
|
89
104
|
export function receiptFileName(receipt) {
|
|
90
105
|
const ts = receipt.recordedAt.replace(/[:.]/g, '-');
|
|
91
|
-
const
|
|
106
|
+
const receiptId = receipt.schema === 'checkout-agent-receipt/v1' ? receipt.reviewId : receipt.receiptId;
|
|
107
|
+
const review = (receiptId ?? 'no-review').replace(/[^A-Za-z0-9-]/g, '');
|
|
92
108
|
return `receipt-${ts}-${review || 'no-review'}.json`;
|
|
93
109
|
}
|
|
94
110
|
/**
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Signature validity ceiling. A signature is a bearer-ish artifact for as long
|
|
2
|
+
* as it is valid, so the window stays short even if a caller asks for more. */
|
|
3
|
+
export declare const WEB_BOT_AUTH_MAX_TTL_SECONDS = 300;
|
|
4
|
+
/** Default validity. Long enough to survive a redirect chain and a slow TLS
|
|
5
|
+
* handshake, short enough that a captured signature is near-useless. */
|
|
6
|
+
export declare const WEB_BOT_AUTH_DEFAULT_TTL_SECONDS = 60;
|
|
7
|
+
/** Label for the single signature we emit. RFC 9421 allows several per request. */
|
|
8
|
+
export declare const WEB_BOT_AUTH_SIGNATURE_LABEL = "sig1";
|
|
9
|
+
/** Tag fixed by the Web Bot Auth draft; verifiers select on it. */
|
|
10
|
+
export declare const WEB_BOT_AUTH_TAG = "web-bot-auth";
|
|
11
|
+
/** Components covered by the signature, in the order they appear in the base. */
|
|
12
|
+
export declare const WEB_BOT_AUTH_COVERED_COMPONENTS: readonly ["@authority", "signature-agent"];
|
|
13
|
+
/** Ed25519 private key in JWK form, as stored in the agent's runtime key file. */
|
|
14
|
+
export interface WebBotAuthPrivateJwk {
|
|
15
|
+
kty: 'OKP';
|
|
16
|
+
crv: 'Ed25519';
|
|
17
|
+
x: string;
|
|
18
|
+
d: string;
|
|
19
|
+
}
|
|
20
|
+
export interface WebBotAuthSigningKey {
|
|
21
|
+
/** RFC 7638 thumbprint. Must match a `kid` published in the directory. */
|
|
22
|
+
keyId: string;
|
|
23
|
+
privateJwk: WebBotAuthPrivateJwk;
|
|
24
|
+
}
|
|
25
|
+
export interface WebBotAuthConfig {
|
|
26
|
+
/** Absolute https URL of the operator-hosted signature directory. */
|
|
27
|
+
directoryUrl: string;
|
|
28
|
+
key: WebBotAuthSigningKey;
|
|
29
|
+
ttlSeconds?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface WebBotAuthHeaders {
|
|
32
|
+
'Signature-Input': string;
|
|
33
|
+
Signature: string;
|
|
34
|
+
'Signature-Agent': string;
|
|
35
|
+
[header: string]: string;
|
|
36
|
+
}
|
|
37
|
+
export interface SignatureBaseParams {
|
|
38
|
+
authority: string;
|
|
39
|
+
directoryUrl: string;
|
|
40
|
+
keyId: string;
|
|
41
|
+
created: number;
|
|
42
|
+
expires: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build the RFC 9421 signature base.
|
|
46
|
+
*
|
|
47
|
+
* The base is what actually gets signed, so its exact bytes are the contract
|
|
48
|
+
* with every verifier — one wrong space or a reordered parameter and the
|
|
49
|
+
* signature fails against a correct key. It is exported and pinned by a
|
|
50
|
+
* deterministic test vector for that reason.
|
|
51
|
+
*/
|
|
52
|
+
export declare function buildSignatureBase(params: SignatureBaseParams): string;
|
|
53
|
+
/**
|
|
54
|
+
* The `@signature-params` value, which appears twice: as the final line of the
|
|
55
|
+
* signature base, and verbatim as the `Signature-Input` header value. Building
|
|
56
|
+
* it once is what keeps those two identical — a verifier rederives the base
|
|
57
|
+
* from the header it received, so any divergence fails every signature.
|
|
58
|
+
*/
|
|
59
|
+
export declare function buildSignatureParams(params: SignatureBaseParams): string;
|
|
60
|
+
export interface BuildHeadersParams {
|
|
61
|
+
/** Host (and port, if non-default) of the request being signed. */
|
|
62
|
+
authority: string;
|
|
63
|
+
config: WebBotAuthConfig;
|
|
64
|
+
/** Unix seconds. Injected so tests are deterministic and never wall-clock. */
|
|
65
|
+
nowSeconds: number;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Produce the three Web Bot Auth headers for one request authority.
|
|
69
|
+
*
|
|
70
|
+
* Throws on unusable key material rather than emitting an unverifiable
|
|
71
|
+
* signature. Callers on the checkout path must treat a throw as "proceed
|
|
72
|
+
* unsigned" — see `webBotAuthHeadersOrNone`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function buildWebBotAuthHeaders(params: BuildHeadersParams): WebBotAuthHeaders;
|
|
75
|
+
/**
|
|
76
|
+
* Header-or-nothing wrapper for the checkout path.
|
|
77
|
+
*
|
|
78
|
+
* Signing is an optional trust upgrade, never a precondition for buying
|
|
79
|
+
* something. Unusable key material, a malformed URL, or any crypto failure
|
|
80
|
+
* degrades to an unsigned request — which is exactly what we sent before this
|
|
81
|
+
* module existed — rather than throwing into a live checkout.
|
|
82
|
+
*/
|
|
83
|
+
export declare function webBotAuthHeadersOrNone(config: WebBotAuthConfig | null, targetUrl: string, nowSeconds: number): WebBotAuthHeaders | null;
|
|
84
|
+
/**
|
|
85
|
+
* Resolve signing config from the environment. Returns `null` — meaning "send
|
|
86
|
+
* unsigned" — unless a directory URL and a usable key are BOTH present.
|
|
87
|
+
*
|
|
88
|
+
* Default-off is deliberate. Until the operator directory is live and serving
|
|
89
|
+
* this key, a signature resolves to nothing and fails verification, which is
|
|
90
|
+
* worse than the honest unsigned request we sent before.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveWebBotAuthConfig(env: NodeJS.ProcessEnv, loadKey: () => WebBotAuthSigningKey | null): WebBotAuthConfig | null;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @purpose Web Bot Auth (RFC 9421) request signing for checkout navigations —
|
|
3
|
+
* the client half of Trusted Agent Protocol verification.
|
|
4
|
+
*
|
|
5
|
+
* Cloudflare's TAP verifies an agent by checking an RFC 9421 HTTP message
|
|
6
|
+
* signature on the request. Three headers carry it: `Signature-Input` names the
|
|
7
|
+
* covered components and parameters, `Signature` carries the Ed25519 signature
|
|
8
|
+
* over the derived base, and `Signature-Agent` gives the URL of the directory
|
|
9
|
+
* that resolves `keyid` to a public key. The checkout browser previously sent
|
|
10
|
+
* none of them, so a TAP-aware edge had nothing to verify and fell through to
|
|
11
|
+
* ordinary bot management.
|
|
12
|
+
*
|
|
13
|
+
* TWO HALVES, BOTH REQUIRED. This module presents a `keyid`; the auth server
|
|
14
|
+
* must publish that key at the `Signature-Agent` URL for anything to resolve.
|
|
15
|
+
* Signing while the directory is absent is strictly worse than not signing,
|
|
16
|
+
* because it advertises intent and then fails verification — which is why
|
|
17
|
+
* signing stays OFF unless explicitly configured with a directory URL.
|
|
18
|
+
*
|
|
19
|
+
* KEY MATERIAL NEVER LEAVES THIS MODULE. The signing key is accepted as a
|
|
20
|
+
* value, used once per request, and never logged, serialized, returned, or
|
|
21
|
+
* attached to evidence. Callers get headers, never the key. This module takes
|
|
22
|
+
* no logger for exactly that reason.
|
|
23
|
+
*/
|
|
24
|
+
import { createPrivateKey, sign as cryptoSign } from 'node:crypto';
|
|
25
|
+
/** Signature validity ceiling. A signature is a bearer-ish artifact for as long
|
|
26
|
+
* as it is valid, so the window stays short even if a caller asks for more. */
|
|
27
|
+
export const WEB_BOT_AUTH_MAX_TTL_SECONDS = 300;
|
|
28
|
+
/** Default validity. Long enough to survive a redirect chain and a slow TLS
|
|
29
|
+
* handshake, short enough that a captured signature is near-useless. */
|
|
30
|
+
export const WEB_BOT_AUTH_DEFAULT_TTL_SECONDS = 60;
|
|
31
|
+
/** Label for the single signature we emit. RFC 9421 allows several per request. */
|
|
32
|
+
export const WEB_BOT_AUTH_SIGNATURE_LABEL = 'sig1';
|
|
33
|
+
/** Tag fixed by the Web Bot Auth draft; verifiers select on it. */
|
|
34
|
+
export const WEB_BOT_AUTH_TAG = 'web-bot-auth';
|
|
35
|
+
/** Components covered by the signature, in the order they appear in the base. */
|
|
36
|
+
export const WEB_BOT_AUTH_COVERED_COMPONENTS = ['@authority', 'signature-agent'];
|
|
37
|
+
/** Serialize an RFC 8941 structured-field string (always double-quoted). */
|
|
38
|
+
function sfString(value) {
|
|
39
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the RFC 9421 signature base.
|
|
43
|
+
*
|
|
44
|
+
* The base is what actually gets signed, so its exact bytes are the contract
|
|
45
|
+
* with every verifier — one wrong space or a reordered parameter and the
|
|
46
|
+
* signature fails against a correct key. It is exported and pinned by a
|
|
47
|
+
* deterministic test vector for that reason.
|
|
48
|
+
*/
|
|
49
|
+
export function buildSignatureBase(params) {
|
|
50
|
+
return [
|
|
51
|
+
`"@authority": ${params.authority}`,
|
|
52
|
+
`"signature-agent": ${sfString(params.directoryUrl)}`,
|
|
53
|
+
`"@signature-params": ${buildSignatureParams(params)}`,
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The `@signature-params` value, which appears twice: as the final line of the
|
|
58
|
+
* signature base, and verbatim as the `Signature-Input` header value. Building
|
|
59
|
+
* it once is what keeps those two identical — a verifier rederives the base
|
|
60
|
+
* from the header it received, so any divergence fails every signature.
|
|
61
|
+
*/
|
|
62
|
+
export function buildSignatureParams(params) {
|
|
63
|
+
return (`(${WEB_BOT_AUTH_COVERED_COMPONENTS.map(sfString).join(' ')})` +
|
|
64
|
+
`;created=${params.created}` +
|
|
65
|
+
`;expires=${params.expires}` +
|
|
66
|
+
`;keyid=${sfString(params.keyId)}` +
|
|
67
|
+
`;alg="ed25519"` +
|
|
68
|
+
`;tag=${sfString(WEB_BOT_AUTH_TAG)}`);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Produce the three Web Bot Auth headers for one request authority.
|
|
72
|
+
*
|
|
73
|
+
* Throws on unusable key material rather than emitting an unverifiable
|
|
74
|
+
* signature. Callers on the checkout path must treat a throw as "proceed
|
|
75
|
+
* unsigned" — see `webBotAuthHeadersOrNone`.
|
|
76
|
+
*/
|
|
77
|
+
export function buildWebBotAuthHeaders(params) {
|
|
78
|
+
const { authority, config, nowSeconds } = params;
|
|
79
|
+
const ttl = Math.min(Math.max(1, Math.floor(config.ttlSeconds ?? WEB_BOT_AUTH_DEFAULT_TTL_SECONDS)), WEB_BOT_AUTH_MAX_TTL_SECONDS);
|
|
80
|
+
const created = Math.floor(nowSeconds);
|
|
81
|
+
const expires = created + ttl;
|
|
82
|
+
const baseParams = {
|
|
83
|
+
authority,
|
|
84
|
+
directoryUrl: config.directoryUrl,
|
|
85
|
+
keyId: config.key.keyId,
|
|
86
|
+
created,
|
|
87
|
+
expires,
|
|
88
|
+
};
|
|
89
|
+
const base = buildSignatureBase(baseParams);
|
|
90
|
+
const signatureParams = buildSignatureParams(baseParams);
|
|
91
|
+
const privateKey = createPrivateKey({
|
|
92
|
+
key: config.key.privateJwk,
|
|
93
|
+
format: 'jwk',
|
|
94
|
+
});
|
|
95
|
+
// Ed25519 takes no separate digest algorithm; null is the required argument.
|
|
96
|
+
const signature = cryptoSign(null, Buffer.from(base, 'utf8'), privateKey);
|
|
97
|
+
return {
|
|
98
|
+
'Signature-Input': `${WEB_BOT_AUTH_SIGNATURE_LABEL}=${signatureParams}`,
|
|
99
|
+
Signature: `${WEB_BOT_AUTH_SIGNATURE_LABEL}=:${signature.toString('base64')}:`,
|
|
100
|
+
'Signature-Agent': sfString(config.directoryUrl),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Header-or-nothing wrapper for the checkout path.
|
|
105
|
+
*
|
|
106
|
+
* Signing is an optional trust upgrade, never a precondition for buying
|
|
107
|
+
* something. Unusable key material, a malformed URL, or any crypto failure
|
|
108
|
+
* degrades to an unsigned request — which is exactly what we sent before this
|
|
109
|
+
* module existed — rather than throwing into a live checkout.
|
|
110
|
+
*/
|
|
111
|
+
export function webBotAuthHeadersOrNone(config, targetUrl, nowSeconds) {
|
|
112
|
+
if (!config)
|
|
113
|
+
return null;
|
|
114
|
+
try {
|
|
115
|
+
const authority = new URL(targetUrl).host;
|
|
116
|
+
if (!authority)
|
|
117
|
+
return null;
|
|
118
|
+
return buildWebBotAuthHeaders({ authority, config, nowSeconds });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve signing config from the environment. Returns `null` — meaning "send
|
|
126
|
+
* unsigned" — unless a directory URL and a usable key are BOTH present.
|
|
127
|
+
*
|
|
128
|
+
* Default-off is deliberate. Until the operator directory is live and serving
|
|
129
|
+
* this key, a signature resolves to nothing and fails verification, which is
|
|
130
|
+
* worse than the honest unsigned request we sent before.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveWebBotAuthConfig(env, loadKey) {
|
|
133
|
+
const directoryUrl = env.VISA_WEB_BOT_AUTH_DIRECTORY_URL?.trim();
|
|
134
|
+
if (!directoryUrl)
|
|
135
|
+
return null;
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = new URL(directoryUrl);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
// A directory fetched over plain http could be swapped in flight, which would
|
|
144
|
+
// let an observer substitute the key set our identity is judged against.
|
|
145
|
+
if (parsed.protocol !== 'https:')
|
|
146
|
+
return null;
|
|
147
|
+
let key = null;
|
|
148
|
+
try {
|
|
149
|
+
key = loadKey();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (!key?.keyId || key.privateJwk?.crv !== 'Ed25519' || !key.privateJwk.d)
|
|
155
|
+
return null;
|
|
156
|
+
const rawTtl = Number(env.VISA_WEB_BOT_AUTH_TTL_SECONDS);
|
|
157
|
+
const ttlSeconds = Number.isFinite(rawTtl) && rawTtl > 0 ? rawTtl : undefined;
|
|
158
|
+
return { directoryUrl: parsed.toString(), key, ttlSeconds };
|
|
159
|
+
}
|