@whooshbang/contracts 1.0.0-rc.13
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/CHANGELOG.md +198 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/contract-manifest.json +390 -0
- package/dist/base64url.d.ts +2 -0
- package/dist/base64url.js +51 -0
- package/dist/base64url.js.map +1 -0
- package/dist/generated/artifact-index.d.ts +205 -0
- package/dist/generated/artifact-index.js +396 -0
- package/dist/generated/artifact-index.js.map +1 -0
- package/dist/generated/contract-manifest.d.ts +66 -0
- package/dist/generated/contract-manifest.js +6 -0
- package/dist/generated/contract-manifest.js.map +1 -0
- package/dist/generated/core-scenario.d.ts +18 -0
- package/dist/generated/core-scenario.js +6 -0
- package/dist/generated/core-scenario.js.map +1 -0
- package/dist/generated/fixture-index.d.ts +33 -0
- package/dist/generated/fixture-index.js +6 -0
- package/dist/generated/fixture-index.js.map +1 -0
- package/dist/generated/interaction-machine-scenario.d.ts +263 -0
- package/dist/generated/interaction-machine-scenario.js +6 -0
- package/dist/generated/interaction-machine-scenario.js.map +1 -0
- package/dist/generated/interaction-v1.d.ts +152 -0
- package/dist/generated/interaction-v1.js +6 -0
- package/dist/generated/interaction-v1.js.map +1 -0
- package/dist/generated/machine-v1.d.ts +169 -0
- package/dist/generated/machine-v1.js +6 -0
- package/dist/generated/machine-v1.js.map +1 -0
- package/dist/generated/provider-capabilities-v1.d.ts +78 -0
- package/dist/generated/provider-capabilities-v1.js +6 -0
- package/dist/generated/provider-capabilities-v1.js.map +1 -0
- package/dist/generated/validators-standalone.d.ts +659 -0
- package/dist/generated/validators-standalone.js +47800 -0
- package/dist/generated/validators-standalone.js.map +1 -0
- package/dist/generated/validators.d.ts +74 -0
- package/dist/generated/validators.js +493 -0
- package/dist/generated/validators.js.map +1 -0
- package/dist/generated/webhook-delivery-case-v1.d.ts +91 -0
- package/dist/generated/webhook-delivery-case-v1.js +6 -0
- package/dist/generated/webhook-delivery-case-v1.js.map +1 -0
- package/dist/generated/webhook-signing-vector-v1.d.ts +88 -0
- package/dist/generated/webhook-signing-vector-v1.js +6 -0
- package/dist/generated/webhook-signing-vector-v1.js.map +1 -0
- package/dist/generated/whooshbang-event-v1.d.ts +191 -0
- package/dist/generated/whooshbang-event-v1.js +6 -0
- package/dist/generated/whooshbang-event-v1.js.map +1 -0
- package/dist/generated/whooshbang-v1.d.ts +1162 -0
- package/dist/generated/whooshbang-v1.js +6 -0
- package/dist/generated/whooshbang-v1.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/machine-credential.d.ts +31 -0
- package/dist/machine-credential.js +78 -0
- package/dist/machine-credential.js.map +1 -0
- package/dist/webhook-consumer.d.ts +50 -0
- package/dist/webhook-consumer.js +71 -0
- package/dist/webhook-consumer.js.map +1 -0
- package/dist/webhook-delivery.d.ts +46 -0
- package/dist/webhook-delivery.js +126 -0
- package/dist/webhook-delivery.js.map +1 -0
- package/dist/webhook-signature.d.ts +36 -0
- package/dist/webhook-signature.js +255 -0
- package/dist/webhook-signature.js.map +1 -0
- package/examples/python/verify_webhook.py +283 -0
- package/fixtures/.gitkeep +1 -0
- package/fixtures/core-v1/fixture-index.json +963 -0
- package/fixtures/core-v1/problems/auth-invalid.json +9 -0
- package/fixtures/core-v1/problems/authentication-required.json +9 -0
- package/fixtures/core-v1/problems/cancellation-too-late.json +9 -0
- package/fixtures/core-v1/problems/endpoint-rotation-conflict.json +10 -0
- package/fixtures/core-v1/problems/environment-mismatch.json +9 -0
- package/fixtures/core-v1/problems/idempotency-conflict.json +9 -0
- package/fixtures/core-v1/problems/idempotency-key-required.json +9 -0
- package/fixtures/core-v1/problems/project-slug-conflict.json +16 -0
- package/fixtures/core-v1/problems/provider-outcome-unknown.json +9 -0
- package/fixtures/core-v1/problems/provider-retryable.json +10 -0
- package/fixtures/core-v1/problems/provider-terminal.json +9 -0
- package/fixtures/core-v1/problems/rate-limited.json +10 -0
- package/fixtures/core-v1/problems/replay-unavailable-not-terminal.json +16 -0
- package/fixtures/core-v1/problems/replay-unavailable-payload.json +16 -0
- package/fixtures/core-v1/problems/replay-unavailable-rollout.json +16 -0
- package/fixtures/core-v1/problems/request-invalid.json +16 -0
- package/fixtures/core-v1/problems/resource-expired.json +9 -0
- package/fixtures/core-v1/problems/resource-not-found.json +9 -0
- package/fixtures/core-v1/problems/scope-forbidden.json +9 -0
- package/fixtures/core-v1/problems/subscriber-unbound.json +9 -0
- package/fixtures/core-v1/requests/create-api-credential.json +9 -0
- package/fixtures/core-v1/requests/create-customer-endpoint.json +5 -0
- package/fixtures/core-v1/requests/create-message.json +15 -0
- package/fixtures/core-v1/requests/create-project.json +4 -0
- package/fixtures/core-v1/requests/create-subscription-link.json +5 -0
- package/fixtures/core-v1/requests/rotate-api-credential.json +3 -0
- package/fixtures/core-v1/requests/rotate-customer-endpoint.json +3 -0
- package/fixtures/core-v1/scenarios/api-credential-invalid-indistinguishable.json +14 -0
- package/fixtures/core-v1/scenarios/api-credential-one-time-reveal.json +21 -0
- package/fixtures/core-v1/scenarios/api-credential-revocation-replay.json +17 -0
- package/fixtures/core-v1/scenarios/api-credential-rotation-overlap.json +18 -0
- package/fixtures/core-v1/scenarios/cancellation-too-late.json +15 -0
- package/fixtures/core-v1/scenarios/cross-account-project.json +14 -0
- package/fixtures/core-v1/scenarios/cross-project-resource.json +17 -0
- package/fixtures/core-v1/scenarios/environment-mismatch.json +17 -0
- package/fixtures/core-v1/scenarios/idempotency-conflict.json +35 -0
- package/fixtures/core-v1/scenarios/idempotency-equivalent-replay.json +27 -0
- package/fixtures/core-v1/scenarios/malformed-bearer.json +14 -0
- package/fixtures/core-v1/scenarios/metadata-overrun.json +14 -0
- package/fixtures/core-v1/scenarios/missing-scope.json +15 -0
- package/fixtures/core-v1/scenarios/project-create-equivalent-replay.json +26 -0
- package/fixtures/core-v1/scenarios/project-slug-conflict.json +21 -0
- package/fixtures/core-v1/scenarios/provider-outcome-unknown.json +15 -0
- package/fixtures/core-v1/scenarios/subscription-activation-replay.json +18 -0
- package/fixtures/core-v1/scenarios/subscription-expired-replay.json +16 -0
- package/fixtures/core-v1/success/account-projection.json +11 -0
- package/fixtures/core-v1/success/api-credential-collection.json +22 -0
- package/fixtures/core-v1/success/api-credential-created.json +20 -0
- package/fixtures/core-v1/success/api-credential-revoked.json +19 -0
- package/fixtures/core-v1/success/api-credential-rotated.json +41 -0
- package/fixtures/core-v1/success/customer-endpoint-attempts.json +15 -0
- package/fixtures/core-v1/success/customer-endpoint-collection.json +26 -0
- package/fixtures/core-v1/success/customer-endpoint-create-replayed.json +16 -0
- package/fixtures/core-v1/success/customer-endpoint-created.json +17 -0
- package/fixtures/core-v1/success/customer-endpoint-rotated.json +21 -0
- package/fixtures/core-v1/success/message-accepted.json +30 -0
- package/fixtures/core-v1/success/message-cancellation-too-late.json +27 -0
- package/fixtures/core-v1/success/message-cancelled.json +27 -0
- package/fixtures/core-v1/success/message-outcome-unknown.json +33 -0
- package/fixtures/core-v1/success/message-provider-accepted.json +30 -0
- package/fixtures/core-v1/success/project-collection.json +42 -0
- package/fixtures/core-v1/success/project-created.json +38 -0
- package/fixtures/core-v1/success/subscription-link-activated.json +22 -0
- package/fixtures/core-v1/success/subscription-link-cancelled.json +14 -0
- package/fixtures/core-v1/success/subscription-link-paused.json +22 -0
- package/fixtures/core-v1/success/subscription-link-pending.json +14 -0
- package/fixtures/core-v1/success/subscription-link-revoked.json +22 -0
- package/fixtures/interaction-machine-v1/capabilities/providers.json +57 -0
- package/fixtures/interaction-machine-v1/capabilities/telegram.json +52 -0
- package/fixtures/interaction-machine-v1/events/confirm-answered.json +22 -0
- package/fixtures/interaction-machine-v1/events/input-answered.json +22 -0
- package/fixtures/interaction-machine-v1/events/input-redacted.json +18 -0
- package/fixtures/interaction-machine-v1/events/select-answered.json +22 -0
- package/fixtures/interaction-machine-v1/requests/ack-processed.json +5 -0
- package/fixtures/interaction-machine-v1/requests/ack-quarantined.json +5 -0
- package/fixtures/interaction-machine-v1/requests/create-machine-client.json +6 -0
- package/fixtures/interaction-machine-v1/requests/create-message-confirm.json +18 -0
- package/fixtures/interaction-machine-v1/requests/create-message-input.json +16 -0
- package/fixtures/interaction-machine-v1/requests/create-message-select.json +26 -0
- package/fixtures/interaction-machine-v1/requests/register-machine-credential.json +4 -0
- package/fixtures/interaction-machine-v1/scenarios/ack-conflict.json +14 -0
- package/fixtures/interaction-machine-v1/scenarios/ack-cross-machine.json +15 -0
- package/fixtures/interaction-machine-v1/scenarios/ack-out-of-order.json +15 -0
- package/fixtures/interaction-machine-v1/scenarios/ack-replay.json +17 -0
- package/fixtures/interaction-machine-v1/scenarios/ambiguous-free-text.json +15 -0
- package/fixtures/interaction-machine-v1/scenarios/callback-token-opacity.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/credential-registration-conflict.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/credential-registration-replay.json +15 -0
- package/fixtures/interaction-machine-v1/scenarios/credential-revocation-replay.json +14 -0
- package/fixtures/interaction-machine-v1/scenarios/credential-rotation.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/first-credential-activates.json +15 -0
- package/fixtures/interaction-machine-v1/scenarios/interaction-duplicate.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/interaction-expired.json +17 -0
- package/fixtures/interaction-machine-v1/scenarios/interaction-mismatch.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/interaction-retired.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/interaction-stale.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/machine-admin-denied.json +14 -0
- package/fixtures/interaction-machine-v1/scenarios/machine-bound-message-create.json +22 -0
- package/fixtures/interaction-machine-v1/scenarios/machine-cancel-denied.json +14 -0
- package/fixtures/interaction-machine-v1/scenarios/machine-crash-before-ack.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/machine-notifier-mismatch.json +14 -0
- package/fixtures/interaction-machine-v1/scenarios/machine-other-message-read.json +14 -0
- package/fixtures/interaction-machine-v1/scenarios/machine-owned-message-read.json +22 -0
- package/fixtures/interaction-machine-v1/scenarios/machine-subscriber-mismatch.json +14 -0
- package/fixtures/interaction-machine-v1/scenarios/poll-uncommitted-cursor-rejected.json +17 -0
- package/fixtures/interaction-machine-v1/scenarios/project-credential-machine-endpoint-denied.json +14 -0
- package/fixtures/interaction-machine-v1/scenarios/quarantine-integrity-rejected.json +15 -0
- package/fixtures/interaction-machine-v1/scenarios/quarantine-signature-rejected.json +16 -0
- package/fixtures/interaction-machine-v1/scenarios/quarantine-valid.json +15 -0
- package/fixtures/interaction-machine-v1/scenarios/revoked-machine-credential.json +15 -0
- package/fixtures/interaction-machine-v1/success/ack-out-of-order.json +8 -0
- package/fixtures/interaction-machine-v1/success/ack-processed.json +8 -0
- package/fixtures/interaction-machine-v1/success/ack-quarantined.json +9 -0
- package/fixtures/interaction-machine-v1/success/interaction-answered-confirm.json +10 -0
- package/fixtures/interaction-machine-v1/success/interaction-answered-input.json +10 -0
- package/fixtures/interaction-machine-v1/success/interaction-answered-redacted.json +8 -0
- package/fixtures/interaction-machine-v1/success/interaction-answered-select.json +10 -0
- package/fixtures/interaction-machine-v1/success/interaction-open.json +7 -0
- package/fixtures/interaction-machine-v1/success/machine-client-active.json +20 -0
- package/fixtures/interaction-machine-v1/success/machine-client-provisioning.json +19 -0
- package/fixtures/interaction-machine-v1/success/machine-client-revoked.json +21 -0
- package/fixtures/interaction-machine-v1/success/machine-credential-active.json +13 -0
- package/fixtures/interaction-machine-v1/success/machine-credential-revoked.json +14 -0
- package/fixtures/interaction-machine-v1/success/message-interaction-open.json +33 -0
- package/fixtures/interaction-machine-v1/success/poll-empty.json +6 -0
- package/fixtures/interaction-machine-v1/success/poll-events.json +51 -0
- package/fixtures/interaction-machine-v1/tokens/callback-token.json +1 -0
- package/fixtures/whooshbang-events-v1/delivery/cases.json +418 -0
- package/fixtures/whooshbang-events-v1/events/agent-binding-degraded.json +17 -0
- package/fixtures/whooshbang-events-v1/events/interaction-expired.json +16 -0
- package/fixtures/whooshbang-events-v1/events/interaction-received.json +23 -0
- package/fixtures/whooshbang-events-v1/events/message-delivered.json +16 -0
- package/fixtures/whooshbang-events-v1/events/message-expired.json +15 -0
- package/fixtures/whooshbang-events-v1/events/message-failed.json +17 -0
- package/fixtures/whooshbang-events-v1/events/message-provider-accepted.json +15 -0
- package/fixtures/whooshbang-events-v1/events/subscription-activated.json +18 -0
- package/fixtures/whooshbang-events-v1/events/subscription-revoked.json +18 -0
- package/fixtures/whooshbang-events-v1/signing/vectors.json +852 -0
- package/openapi/.gitkeep +1 -0
- package/openapi/generated/whooshbang-v1.bundle.json +5913 -0
- package/openapi/whooshbang-v1.yaml +2334 -0
- package/package.json +63 -0
- package/schemas/contract-manifest.schema.json +140 -0
- package/schemas/core-scenario.schema.json +38 -0
- package/schemas/events/agent-binding-degraded-v1.schema.json +8 -0
- package/schemas/events/interaction-expired-v1.schema.json +8 -0
- package/schemas/events/interaction-received-v1.schema.json +8 -0
- package/schemas/events/message-delivered-v1.schema.json +8 -0
- package/schemas/events/message-expired-v1.schema.json +8 -0
- package/schemas/events/message-failed-v1.schema.json +8 -0
- package/schemas/events/message-provider-accepted-v1.schema.json +8 -0
- package/schemas/events/subscription-activated-v1.schema.json +8 -0
- package/schemas/events/subscription-revoked-v1.schema.json +8 -0
- package/schemas/events/whooshbang-event-v1.schema.json +689 -0
- package/schemas/fixture-index.schema.json +67 -0
- package/schemas/interaction-machine-scenario.schema.json +700 -0
- package/schemas/interaction-v1.schema.json +483 -0
- package/schemas/machine-v1.schema.json +430 -0
- package/schemas/provider-capabilities-v1.schema.json +263 -0
- package/schemas/webhooks/webhook-delivery-case-v1.schema.json +167 -0
- package/schemas/webhooks/webhook-signing-vector-v1.schema.json +197 -0
- package/schemas/whooshbang-v1.schema.json +2173 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
export const DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
2
|
+
export const MAX_WEBHOOK_SIGNATURES = 2;
|
|
3
|
+
export const MAX_WEBHOOK_SECRETS = 2;
|
|
4
|
+
const runtimeGlobal = globalThis;
|
|
5
|
+
const eventIdentifier = /^evt_[A-Za-z0-9_-]+$/u;
|
|
6
|
+
const canonicalTimestamp = /^(?:0|[1-9][0-9]{0,14})$/u;
|
|
7
|
+
const canonicalAttempt = /^[1-8]$/u;
|
|
8
|
+
const signatureToken = /^([A-Za-z0-9]+),([A-Za-z0-9+/]+={0,2})$/u;
|
|
9
|
+
const canonicalBase64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
|
|
10
|
+
function reject(reason) {
|
|
11
|
+
return { reason, verified: false };
|
|
12
|
+
}
|
|
13
|
+
function collectHeaderValues(headers, wantedName) {
|
|
14
|
+
const values = [];
|
|
15
|
+
const runtimeHeaders = headers;
|
|
16
|
+
if (Array.isArray(runtimeHeaders)) {
|
|
17
|
+
for (const entry of runtimeHeaders) {
|
|
18
|
+
if (!Array.isArray(entry) ||
|
|
19
|
+
entry.length !== 2 ||
|
|
20
|
+
typeof entry[0] !== "string" ||
|
|
21
|
+
typeof entry[1] !== "string") {
|
|
22
|
+
return { status: "malformed" };
|
|
23
|
+
}
|
|
24
|
+
if (entry[0].toLowerCase() === wantedName) {
|
|
25
|
+
values.push(entry[1]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (typeof runtimeHeaders === "object" && runtimeHeaders !== null) {
|
|
30
|
+
for (const [name, rawValue] of Object.entries(runtimeHeaders)) {
|
|
31
|
+
if (name.toLowerCase() !== wantedName || rawValue === undefined) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (typeof rawValue === "string") {
|
|
35
|
+
values.push(rawValue);
|
|
36
|
+
}
|
|
37
|
+
else if (Array.isArray(rawValue) &&
|
|
38
|
+
rawValue.every((value) => typeof value === "string")) {
|
|
39
|
+
values.push(...rawValue);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
return { status: "malformed" };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
return { status: "malformed" };
|
|
48
|
+
}
|
|
49
|
+
if (values.length === 0) {
|
|
50
|
+
return { status: "missing" };
|
|
51
|
+
}
|
|
52
|
+
if (values.length !== 1) {
|
|
53
|
+
return { status: "duplicate" };
|
|
54
|
+
}
|
|
55
|
+
const value = values[0];
|
|
56
|
+
return value === undefined
|
|
57
|
+
? { status: "malformed" }
|
|
58
|
+
: { status: "ok", value };
|
|
59
|
+
}
|
|
60
|
+
function headerFailure(result) {
|
|
61
|
+
if (result.status === "missing") {
|
|
62
|
+
return reject("missing_header");
|
|
63
|
+
}
|
|
64
|
+
if (result.status === "duplicate") {
|
|
65
|
+
return reject("duplicate_header");
|
|
66
|
+
}
|
|
67
|
+
return reject("malformed_header");
|
|
68
|
+
}
|
|
69
|
+
function decodeCanonicalBase64(value) {
|
|
70
|
+
if (!canonicalBase64.test(value)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const decoded = atob(value);
|
|
75
|
+
if (btoa(decoded) !== value) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function parseSecret(value) {
|
|
85
|
+
if (!value.startsWith("whsec_")) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const decoded = decodeCanonicalBase64(value.slice("whsec_".length));
|
|
89
|
+
return decoded !== undefined &&
|
|
90
|
+
decoded.byteLength >= 24 &&
|
|
91
|
+
decoded.byteLength <= 64
|
|
92
|
+
? decoded
|
|
93
|
+
: undefined;
|
|
94
|
+
}
|
|
95
|
+
function parseSignatures(value) {
|
|
96
|
+
if (value.length === 0 ||
|
|
97
|
+
value.startsWith(" ") ||
|
|
98
|
+
value.endsWith(" ") ||
|
|
99
|
+
value.includes(" ")) {
|
|
100
|
+
return "malformed_signature";
|
|
101
|
+
}
|
|
102
|
+
const tokens = value.split(" ");
|
|
103
|
+
if (tokens.length > MAX_WEBHOOK_SIGNATURES) {
|
|
104
|
+
return "too_many_signatures";
|
|
105
|
+
}
|
|
106
|
+
const signatures = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const token of tokens) {
|
|
109
|
+
const match = signatureToken.exec(token);
|
|
110
|
+
const version = match?.[1];
|
|
111
|
+
const encoded = match?.[2];
|
|
112
|
+
if (version === undefined || encoded === undefined) {
|
|
113
|
+
return "malformed_signature";
|
|
114
|
+
}
|
|
115
|
+
if (version !== "v1") {
|
|
116
|
+
return "unsupported_signature_version";
|
|
117
|
+
}
|
|
118
|
+
if (seen.has(token)) {
|
|
119
|
+
return "duplicate_signature";
|
|
120
|
+
}
|
|
121
|
+
seen.add(token);
|
|
122
|
+
const bytes = decodeCanonicalBase64(encoded);
|
|
123
|
+
if (bytes === undefined || bytes.byteLength !== 32) {
|
|
124
|
+
return "malformed_signature";
|
|
125
|
+
}
|
|
126
|
+
signatures.push({ bytes, encoded });
|
|
127
|
+
}
|
|
128
|
+
return signatures;
|
|
129
|
+
}
|
|
130
|
+
function signingBytes(eventId, timestamp, rawBody) {
|
|
131
|
+
const prefix = new TextEncoder().encode(`${eventId}.${timestamp}.`);
|
|
132
|
+
const result = new Uint8Array(prefix.byteLength + rawBody.byteLength);
|
|
133
|
+
result.set(prefix);
|
|
134
|
+
result.set(rawBody, prefix.byteLength);
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
function constantTimeEqual(left, right) {
|
|
138
|
+
let mismatch = left.byteLength ^ right.byteLength;
|
|
139
|
+
const length = Math.max(left.byteLength, right.byteLength);
|
|
140
|
+
for (let index = 0; index < length; index += 1) {
|
|
141
|
+
mismatch |=
|
|
142
|
+
(left[index % left.byteLength] ?? 0) ^
|
|
143
|
+
(right[index % right.byteLength] ?? 0);
|
|
144
|
+
}
|
|
145
|
+
return mismatch === 0;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Verifies the Standard Webhooks V1 signature over the exact request bytes.
|
|
149
|
+
*
|
|
150
|
+
* Header entries are accepted as tuples so callers can preserve and reject
|
|
151
|
+
* duplicate security headers before framework normalization.
|
|
152
|
+
*/
|
|
153
|
+
export async function verifyWebhookSignature(options) {
|
|
154
|
+
if (!(options.rawBody instanceof Uint8Array)) {
|
|
155
|
+
return reject("invalid_body");
|
|
156
|
+
}
|
|
157
|
+
const runtimeCrypto = options.crypto ?? runtimeGlobal.crypto;
|
|
158
|
+
if (runtimeCrypto === undefined) {
|
|
159
|
+
return reject("crypto_unavailable");
|
|
160
|
+
}
|
|
161
|
+
const idHeader = collectHeaderValues(options.headers, "webhook-id");
|
|
162
|
+
if (idHeader.status !== "ok") {
|
|
163
|
+
return headerFailure(idHeader);
|
|
164
|
+
}
|
|
165
|
+
if (idHeader.value.length < 8 ||
|
|
166
|
+
idHeader.value.length > 128 ||
|
|
167
|
+
!eventIdentifier.test(idHeader.value)) {
|
|
168
|
+
return reject("malformed_webhook_id");
|
|
169
|
+
}
|
|
170
|
+
const timestampHeader = collectHeaderValues(options.headers, "webhook-timestamp");
|
|
171
|
+
if (timestampHeader.status !== "ok") {
|
|
172
|
+
return headerFailure(timestampHeader);
|
|
173
|
+
}
|
|
174
|
+
if (!canonicalTimestamp.test(timestampHeader.value)) {
|
|
175
|
+
return reject("malformed_timestamp");
|
|
176
|
+
}
|
|
177
|
+
const timestamp = Number(timestampHeader.value);
|
|
178
|
+
if (!Number.isSafeInteger(timestamp)) {
|
|
179
|
+
return reject("malformed_timestamp");
|
|
180
|
+
}
|
|
181
|
+
const signatureHeader = collectHeaderValues(options.headers, "webhook-signature");
|
|
182
|
+
if (signatureHeader.status !== "ok") {
|
|
183
|
+
return headerFailure(signatureHeader);
|
|
184
|
+
}
|
|
185
|
+
const signatures = parseSignatures(signatureHeader.value);
|
|
186
|
+
if (typeof signatures === "string") {
|
|
187
|
+
return reject(signatures);
|
|
188
|
+
}
|
|
189
|
+
const attemptHeader = collectHeaderValues(options.headers, "webhook-attempt");
|
|
190
|
+
if (attemptHeader.status !== "ok") {
|
|
191
|
+
return headerFailure(attemptHeader);
|
|
192
|
+
}
|
|
193
|
+
if (!canonicalAttempt.test(attemptHeader.value)) {
|
|
194
|
+
return reject("malformed_attempt");
|
|
195
|
+
}
|
|
196
|
+
const attempt = Number(attemptHeader.value);
|
|
197
|
+
const tolerance = options.toleranceSeconds ?? DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS;
|
|
198
|
+
if (!Number.isSafeInteger(tolerance) ||
|
|
199
|
+
tolerance < 1 ||
|
|
200
|
+
tolerance > DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS) {
|
|
201
|
+
return reject("invalid_tolerance");
|
|
202
|
+
}
|
|
203
|
+
const nowSeconds = options.nowSeconds === undefined
|
|
204
|
+
? Math.floor(Date.now() / 1000)
|
|
205
|
+
: options.nowSeconds;
|
|
206
|
+
if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) {
|
|
207
|
+
return reject("invalid_clock");
|
|
208
|
+
}
|
|
209
|
+
if (Math.abs(nowSeconds - timestamp) > tolerance) {
|
|
210
|
+
return reject("timestamp_outside_tolerance");
|
|
211
|
+
}
|
|
212
|
+
if (!Array.isArray(options.secrets) ||
|
|
213
|
+
!options.secrets.every((secret) => typeof secret === "string")) {
|
|
214
|
+
return reject("invalid_secret");
|
|
215
|
+
}
|
|
216
|
+
if (options.secrets.length > MAX_WEBHOOK_SECRETS) {
|
|
217
|
+
return reject("too_many_secrets");
|
|
218
|
+
}
|
|
219
|
+
if (options.secrets.length === 0) {
|
|
220
|
+
return reject("invalid_secret");
|
|
221
|
+
}
|
|
222
|
+
if (new Set(options.secrets).size !== options.secrets.length) {
|
|
223
|
+
return reject("duplicate_secret");
|
|
224
|
+
}
|
|
225
|
+
const decodedSecrets = options.secrets.map(parseSecret);
|
|
226
|
+
if (decodedSecrets.some((secret) => secret === undefined)) {
|
|
227
|
+
return reject("invalid_secret");
|
|
228
|
+
}
|
|
229
|
+
const message = signingBytes(idHeader.value, timestampHeader.value, options.rawBody);
|
|
230
|
+
let matched = false;
|
|
231
|
+
try {
|
|
232
|
+
for (const possibleSecret of decodedSecrets) {
|
|
233
|
+
if (possibleSecret === undefined) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const key = await runtimeCrypto.subtle.importKey("raw", Uint8Array.from(possibleSecret), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
|
|
237
|
+
const digest = new Uint8Array(await runtimeCrypto.subtle.sign("HMAC", key, Uint8Array.from(message)));
|
|
238
|
+
for (const signature of signatures) {
|
|
239
|
+
matched = constantTimeEqual(digest, signature.bytes) || matched;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return reject("crypto_unavailable");
|
|
245
|
+
}
|
|
246
|
+
return matched
|
|
247
|
+
? {
|
|
248
|
+
attempt,
|
|
249
|
+
eventId: idHeader.value,
|
|
250
|
+
timestamp,
|
|
251
|
+
verified: true,
|
|
252
|
+
}
|
|
253
|
+
: reject("signature_mismatch");
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=webhook-signature.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-signature.js","sourceRoot":"","sources":["../src/webhook-signature.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify WhooshBang Standard Webhooks fixtures with the stdlib only."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import binascii
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
17
|
+
MAX_SIGNATURES = 2
|
|
18
|
+
MAX_SECRETS = 2
|
|
19
|
+
|
|
20
|
+
EVENT_IDENTIFIER = re.compile(r"^evt_[A-Za-z0-9_-]+$", re.ASCII)
|
|
21
|
+
CANONICAL_TIMESTAMP = re.compile(r"^(?:0|[1-9][0-9]{0,14})$", re.ASCII)
|
|
22
|
+
CANONICAL_ATTEMPT = re.compile(r"^[1-8]$", re.ASCII)
|
|
23
|
+
SIGNATURE_TOKEN = re.compile(
|
|
24
|
+
r"^([A-Za-z0-9]+),([A-Za-z0-9+/]+={0,2})$", re.ASCII
|
|
25
|
+
)
|
|
26
|
+
CANONICAL_BASE64 = re.compile(
|
|
27
|
+
r"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",
|
|
28
|
+
re.ASCII,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rejected(reason: str) -> dict[str, Any]:
|
|
33
|
+
"""Return a deliberately redacted verification failure."""
|
|
34
|
+
|
|
35
|
+
return {"verified": False, "reason": reason}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def single_header(
|
|
39
|
+
headers: Any, wanted_name: str
|
|
40
|
+
) -> tuple[str | None, str | None]:
|
|
41
|
+
if not isinstance(headers, list):
|
|
42
|
+
return None, "malformed_header"
|
|
43
|
+
|
|
44
|
+
values: list[str] = []
|
|
45
|
+
for entry in headers:
|
|
46
|
+
if (
|
|
47
|
+
not isinstance(entry, (list, tuple))
|
|
48
|
+
or len(entry) != 2
|
|
49
|
+
or not isinstance(entry[0], str)
|
|
50
|
+
or not isinstance(entry[1], str)
|
|
51
|
+
):
|
|
52
|
+
return None, "malformed_header"
|
|
53
|
+
if entry[0].lower() == wanted_name:
|
|
54
|
+
values.append(entry[1])
|
|
55
|
+
|
|
56
|
+
if not values:
|
|
57
|
+
return None, "missing_header"
|
|
58
|
+
if len(values) != 1:
|
|
59
|
+
return None, "duplicate_header"
|
|
60
|
+
return values[0], None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_canonical_base64(value: str) -> bytes | None:
|
|
64
|
+
if CANONICAL_BASE64.fullmatch(value) is None:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
decoded = base64.b64decode(value, validate=True)
|
|
68
|
+
except (ValueError, binascii.Error):
|
|
69
|
+
return None
|
|
70
|
+
if base64.b64encode(decoded).decode("ascii") != value:
|
|
71
|
+
return None
|
|
72
|
+
return decoded
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_secret(value: str) -> bytes | None:
|
|
76
|
+
if not value.startswith("whsec_"):
|
|
77
|
+
return None
|
|
78
|
+
decoded = decode_canonical_base64(value[len("whsec_") :])
|
|
79
|
+
if decoded is None or not 24 <= len(decoded) <= 64:
|
|
80
|
+
return None
|
|
81
|
+
return decoded
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_signatures(value: str) -> tuple[list[bytes] | None, str | None]:
|
|
85
|
+
if (
|
|
86
|
+
not value
|
|
87
|
+
or value.startswith(" ")
|
|
88
|
+
or value.endswith(" ")
|
|
89
|
+
or " " in value
|
|
90
|
+
):
|
|
91
|
+
return None, "malformed_signature"
|
|
92
|
+
|
|
93
|
+
tokens = value.split(" ")
|
|
94
|
+
if len(tokens) > MAX_SIGNATURES:
|
|
95
|
+
return None, "too_many_signatures"
|
|
96
|
+
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
signatures: list[bytes] = []
|
|
99
|
+
for token in tokens:
|
|
100
|
+
match = SIGNATURE_TOKEN.fullmatch(token)
|
|
101
|
+
if match is None:
|
|
102
|
+
return None, "malformed_signature"
|
|
103
|
+
version, encoded = match.groups()
|
|
104
|
+
if version != "v1":
|
|
105
|
+
return None, "unsupported_signature_version"
|
|
106
|
+
if token in seen:
|
|
107
|
+
return None, "duplicate_signature"
|
|
108
|
+
seen.add(token)
|
|
109
|
+
decoded = decode_canonical_base64(encoded)
|
|
110
|
+
if decoded is None or len(decoded) != 32:
|
|
111
|
+
return None, "malformed_signature"
|
|
112
|
+
signatures.append(decoded)
|
|
113
|
+
return signatures, None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_plain_integer(value: Any) -> bool:
|
|
117
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def verify_webhook(
|
|
121
|
+
*,
|
|
122
|
+
raw_body: Any,
|
|
123
|
+
headers: Any,
|
|
124
|
+
secrets: Any,
|
|
125
|
+
now_seconds: Any,
|
|
126
|
+
tolerance_seconds: Any = DEFAULT_TOLERANCE_SECONDS,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Verify one attempt without parsing or re-serializing its JSON body."""
|
|
129
|
+
|
|
130
|
+
if not isinstance(raw_body, bytes):
|
|
131
|
+
return rejected("invalid_body")
|
|
132
|
+
|
|
133
|
+
event_id, error = single_header(headers, "webhook-id")
|
|
134
|
+
if error is not None:
|
|
135
|
+
return rejected(error)
|
|
136
|
+
if (
|
|
137
|
+
event_id is None
|
|
138
|
+
or not 8 <= len(event_id) <= 128
|
|
139
|
+
or EVENT_IDENTIFIER.fullmatch(event_id) is None
|
|
140
|
+
):
|
|
141
|
+
return rejected("malformed_webhook_id")
|
|
142
|
+
|
|
143
|
+
timestamp_text, error = single_header(headers, "webhook-timestamp")
|
|
144
|
+
if error is not None:
|
|
145
|
+
return rejected(error)
|
|
146
|
+
if (
|
|
147
|
+
timestamp_text is None
|
|
148
|
+
or CANONICAL_TIMESTAMP.fullmatch(timestamp_text) is None
|
|
149
|
+
):
|
|
150
|
+
return rejected("malformed_timestamp")
|
|
151
|
+
timestamp = int(timestamp_text)
|
|
152
|
+
|
|
153
|
+
signature_text, error = single_header(headers, "webhook-signature")
|
|
154
|
+
if error is not None:
|
|
155
|
+
return rejected(error)
|
|
156
|
+
if signature_text is None:
|
|
157
|
+
return rejected("malformed_signature")
|
|
158
|
+
signatures, error = parse_signatures(signature_text)
|
|
159
|
+
if error is not None:
|
|
160
|
+
return rejected(error)
|
|
161
|
+
if signatures is None:
|
|
162
|
+
return rejected("malformed_signature")
|
|
163
|
+
|
|
164
|
+
attempt_text, error = single_header(headers, "webhook-attempt")
|
|
165
|
+
if error is not None:
|
|
166
|
+
return rejected(error)
|
|
167
|
+
if (
|
|
168
|
+
attempt_text is None
|
|
169
|
+
or CANONICAL_ATTEMPT.fullmatch(attempt_text) is None
|
|
170
|
+
):
|
|
171
|
+
return rejected("malformed_attempt")
|
|
172
|
+
attempt = int(attempt_text)
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
not is_plain_integer(tolerance_seconds)
|
|
176
|
+
or not 1 <= tolerance_seconds <= DEFAULT_TOLERANCE_SECONDS
|
|
177
|
+
):
|
|
178
|
+
return rejected("invalid_tolerance")
|
|
179
|
+
if not is_plain_integer(now_seconds) or now_seconds < 0:
|
|
180
|
+
return rejected("invalid_clock")
|
|
181
|
+
if abs(now_seconds - timestamp) > tolerance_seconds:
|
|
182
|
+
return rejected("timestamp_outside_tolerance")
|
|
183
|
+
|
|
184
|
+
if not isinstance(secrets, list) or not all(
|
|
185
|
+
isinstance(secret, str) for secret in secrets
|
|
186
|
+
):
|
|
187
|
+
return rejected("invalid_secret")
|
|
188
|
+
if len(secrets) > MAX_SECRETS:
|
|
189
|
+
return rejected("too_many_secrets")
|
|
190
|
+
if not secrets:
|
|
191
|
+
return rejected("invalid_secret")
|
|
192
|
+
if len(set(secrets)) != len(secrets):
|
|
193
|
+
return rejected("duplicate_secret")
|
|
194
|
+
decoded_secrets = [parse_secret(secret) for secret in secrets]
|
|
195
|
+
if any(secret is None for secret in decoded_secrets):
|
|
196
|
+
return rejected("invalid_secret")
|
|
197
|
+
|
|
198
|
+
signed_bytes = (
|
|
199
|
+
event_id.encode("ascii")
|
|
200
|
+
+ b"."
|
|
201
|
+
+ timestamp_text.encode("ascii")
|
|
202
|
+
+ b"."
|
|
203
|
+
+ raw_body
|
|
204
|
+
)
|
|
205
|
+
matched = False
|
|
206
|
+
for possible_secret in decoded_secrets:
|
|
207
|
+
if possible_secret is None:
|
|
208
|
+
continue
|
|
209
|
+
digest = hmac.new(
|
|
210
|
+
possible_secret, signed_bytes, hashlib.sha256
|
|
211
|
+
).digest()
|
|
212
|
+
for signature in signatures:
|
|
213
|
+
matched = hmac.compare_digest(digest, signature) or matched
|
|
214
|
+
|
|
215
|
+
if not matched:
|
|
216
|
+
return rejected("signature_mismatch")
|
|
217
|
+
return {
|
|
218
|
+
"verified": True,
|
|
219
|
+
"event_id": event_id,
|
|
220
|
+
"timestamp": timestamp,
|
|
221
|
+
"attempt": attempt,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def run_vectors(vector_path: Path) -> dict[str, Any]:
|
|
226
|
+
vector_set = json.loads(vector_path.read_text(encoding="utf-8"))
|
|
227
|
+
keys = vector_set["synthetic_verification_keys"]
|
|
228
|
+
bodies = vector_set["raw_bodies"]
|
|
229
|
+
cases = vector_set["cases"]
|
|
230
|
+
|
|
231
|
+
for vector in cases:
|
|
232
|
+
result = verify_webhook(
|
|
233
|
+
raw_body=bodies[vector["raw_body"]].encode("utf-8"),
|
|
234
|
+
headers=[
|
|
235
|
+
(header["name"], header["value"])
|
|
236
|
+
for header in vector["headers"]
|
|
237
|
+
],
|
|
238
|
+
secrets=[keys[name] for name in vector["trusted_keys"]],
|
|
239
|
+
now_seconds=vector["now_seconds"],
|
|
240
|
+
tolerance_seconds=vector["tolerance_seconds"],
|
|
241
|
+
)
|
|
242
|
+
if result != vector["expected"]:
|
|
243
|
+
return {
|
|
244
|
+
"status": "fail",
|
|
245
|
+
"code": "WEBHOOK_VECTOR_MISMATCH",
|
|
246
|
+
"case_id": vector["id"],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
"status": "pass",
|
|
251
|
+
"code": "WEBHOOK_VECTORS_OK",
|
|
252
|
+
"cases": len(cases),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main() -> int:
|
|
257
|
+
if len(sys.argv) != 2:
|
|
258
|
+
sys.stderr.write(
|
|
259
|
+
json.dumps(
|
|
260
|
+
{
|
|
261
|
+
"status": "fail",
|
|
262
|
+
"code": "WEBHOOK_VECTOR_PATH_REQUIRED",
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
+ "\n"
|
|
266
|
+
)
|
|
267
|
+
return 2
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
summary = run_vectors(Path(sys.argv[1]))
|
|
271
|
+
except Exception:
|
|
272
|
+
summary = {
|
|
273
|
+
"status": "fail",
|
|
274
|
+
"code": "WEBHOOK_VECTOR_RUN_FAILED",
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
stream = sys.stdout if summary["status"] == "pass" else sys.stderr
|
|
278
|
+
stream.write(json.dumps(summary, sort_keys=True) + "\n")
|
|
279
|
+
return 0 if summary["status"] == "pass" else 1
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|