@heyanon-arp/sdk 0.0.3 → 0.0.4

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/index.js CHANGED
@@ -273,8 +273,39 @@ function buildWebhookSignatureHeader(input, sharedSecret) {
273
273
  return `${Purpose.WEBHOOK}=${base.base64.encode(mac)}`;
274
274
  }
275
275
  function verifyWebhookSignatureHeader(headerValue, input, sharedSecret) {
276
- const expected = buildWebhookSignatureHeader(input, sharedSecret);
277
- return constantTimeEqual(expected, headerValue);
276
+ const secrets = Array.isArray(sharedSecret) ? sharedSecret : [sharedSecret];
277
+ if (secrets.length === 0) return false;
278
+ let matched = false;
279
+ for (const secret of secrets) {
280
+ const expected = buildWebhookSignatureHeader(input, secret);
281
+ matched = constantTimeEqual(expected, headerValue) || matched;
282
+ }
283
+ return matched;
284
+ }
285
+ function verifyAndCheckPayload(args) {
286
+ const tolerance = args.servedAtToleranceMs === void 0 ? 5 * 6e4 : args.servedAtToleranceMs;
287
+ if (tolerance !== null) {
288
+ const servedAtMs = Date.parse(args.input.served_at);
289
+ const nowMs = (args.now ?? /* @__PURE__ */ new Date()).getTime();
290
+ if (Number.isNaN(servedAtMs) || Math.abs(nowMs - servedAtMs) > tolerance) return "stale_attempt";
291
+ }
292
+ const fullInput = { ...args.input, payload_sha256: sha256HexLower(args.rawBody) };
293
+ if (!verifyWebhookSignatureHeader(args.headerValue, fullInput, args.secrets)) return "invalid_signature";
294
+ return "ok";
295
+ }
296
+ function isProbeRequest(headers) {
297
+ for (const [name, value] of Object.entries(headers)) {
298
+ if (name.toLowerCase() !== "x-arp-probe") continue;
299
+ const v = Array.isArray(value) ? value[0] : value;
300
+ if (v === "1" || v === "true") return true;
301
+ }
302
+ return false;
303
+ }
304
+ function sha256HexLower(bytes) {
305
+ const digest = sha2.sha256(bytes);
306
+ let hex = "";
307
+ for (const b of digest) hex += b.toString(16).padStart(2, "0");
308
+ return hex;
278
309
  }
279
310
  function constantTimeEqual(a, b) {
280
311
  if (a.length !== b.length) return false;
@@ -864,6 +895,7 @@ exports.generateKeyPair = generateKeyPair;
864
895
  exports.getPublicKey = getPublicKey2;
865
896
  exports.isAssetIdentifier = isAssetIdentifier;
866
897
  exports.isDeclineReason = isDeclineReason;
898
+ exports.isProbeRequest = isProbeRequest;
867
899
  exports.isValidDid = isValidDid;
868
900
  exports.parseCaip19SolanaAssetId = parseCaip19SolanaAssetId;
869
901
  exports.parseDid = parseDid;
@@ -874,6 +906,7 @@ exports.scryptPasswordProofSign = scryptPasswordProofSign;
874
906
  exports.scryptPasswordProofVerify = scryptPasswordProofVerify;
875
907
  exports.senderNonce = senderNonce;
876
908
  exports.serverEventHash = serverEventHash;
909
+ exports.sha256HexLower = sha256HexLower;
877
910
  exports.sign = sign2;
878
911
  exports.signChallenge = signChallenge;
879
912
  exports.signCosignature = signCosignature;
@@ -883,6 +916,7 @@ exports.signKeyRotationAttestation = signKeyRotationAttestation;
883
916
  exports.signedMessageHash = signedMessageHash;
884
917
  exports.uuidV4 = uuidV4;
885
918
  exports.verify = verify2;
919
+ exports.verifyAndCheckPayload = verifyAndCheckPayload;
886
920
  exports.verifyChallenge = verifyChallenge;
887
921
  exports.verifyCosignature = verifyCosignature;
888
922
  exports.verifyEnvelope = verifyEnvelope;
package/dist/index.mjs CHANGED
@@ -248,8 +248,39 @@ function buildWebhookSignatureHeader(input, sharedSecret) {
248
248
  return `${Purpose.WEBHOOK}=${base64.encode(mac)}`;
249
249
  }
250
250
  function verifyWebhookSignatureHeader(headerValue, input, sharedSecret) {
251
- const expected = buildWebhookSignatureHeader(input, sharedSecret);
252
- return constantTimeEqual(expected, headerValue);
251
+ const secrets = Array.isArray(sharedSecret) ? sharedSecret : [sharedSecret];
252
+ if (secrets.length === 0) return false;
253
+ let matched = false;
254
+ for (const secret of secrets) {
255
+ const expected = buildWebhookSignatureHeader(input, secret);
256
+ matched = constantTimeEqual(expected, headerValue) || matched;
257
+ }
258
+ return matched;
259
+ }
260
+ function verifyAndCheckPayload(args) {
261
+ const tolerance = args.servedAtToleranceMs === void 0 ? 5 * 6e4 : args.servedAtToleranceMs;
262
+ if (tolerance !== null) {
263
+ const servedAtMs = Date.parse(args.input.served_at);
264
+ const nowMs = (args.now ?? /* @__PURE__ */ new Date()).getTime();
265
+ if (Number.isNaN(servedAtMs) || Math.abs(nowMs - servedAtMs) > tolerance) return "stale_attempt";
266
+ }
267
+ const fullInput = { ...args.input, payload_sha256: sha256HexLower(args.rawBody) };
268
+ if (!verifyWebhookSignatureHeader(args.headerValue, fullInput, args.secrets)) return "invalid_signature";
269
+ return "ok";
270
+ }
271
+ function isProbeRequest(headers) {
272
+ for (const [name, value] of Object.entries(headers)) {
273
+ if (name.toLowerCase() !== "x-arp-probe") continue;
274
+ const v = Array.isArray(value) ? value[0] : value;
275
+ if (v === "1" || v === "true") return true;
276
+ }
277
+ return false;
278
+ }
279
+ function sha256HexLower(bytes) {
280
+ const digest = sha256(bytes);
281
+ let hex = "";
282
+ for (const b of digest) hex += b.toString(16).padStart(2, "0");
283
+ return hex;
253
284
  }
254
285
  function constantTimeEqual(a, b) {
255
286
  if (a.length !== b.length) return false;
@@ -795,4 +826,4 @@ function computeCreateLockDiscriminator() {
795
826
  return h.slice(0, 8);
796
827
  }
797
828
 
798
- export { CAIP19_REGEX, COSIGNATURE_PURPOSES, CREATE_LOCK_DISCRIMINATOR, DECLINE_REASONS, PROTECTED_PURPOSES, PURPOSE_PARTIAL_RELEASE_STRING, PURPOSE_REFUND_STRING, PURPOSE_RELEASE_STRING, Purpose, REFUND_REASON_BYTES, SCRYPT_PARAMS, SETTLEMENT_PURPOSES, SLIP44_SOLANA, SOLANA_CLUSTER_IDS, SPL_TOKEN_PROGRAM_ID_BASE58, TOKEN_2022_PROGRAM_ID_BASE58, USDC_MINTS, WELL_KNOWN_ASSETS, WELL_KNOWN_ASSET_KEYS, base58btcDecode, base58btcEncode, buildCreateLockIxData, buildPartialReleaseDigest, buildRefundDigest, buildReleaseDigest, buildWebhookSignatureHeader, bytes16ToDelegationId, canonicalBytes, canonicalJson, canonicalSha256Hex, computeCreateLockDiscriminator, delegationIdToBytes16, deriveConditionHash, deriveLockId, deriveScryptKey, detectTokenProgramFromOwner, detectTokenProgramFromOwnerBytes, expiresAt, findFirstChainDivergence, formatDid, generateKeyPair, getPublicKey2 as getPublicKey, isAssetIdentifier, isDeclineReason, isValidDid, parseCaip19SolanaAssetId, parseDid, pollUntil, resolveAsset, rfc3339, scryptPasswordProofSign, scryptPasswordProofVerify, senderNonce, serverEventHash, sign2 as sign, signChallenge, signCosignature, signEnvelope, signKeyLinkAttestation, signKeyRotationAttestation, signedMessageHash, uuidV4, verify2 as verify, verifyChallenge, verifyCosignature, verifyEnvelope, verifyKeyLinkAttestation, verifyKeyRotationAttestation, verifyWebhookSignatureHeader };
829
+ export { CAIP19_REGEX, COSIGNATURE_PURPOSES, CREATE_LOCK_DISCRIMINATOR, DECLINE_REASONS, PROTECTED_PURPOSES, PURPOSE_PARTIAL_RELEASE_STRING, PURPOSE_REFUND_STRING, PURPOSE_RELEASE_STRING, Purpose, REFUND_REASON_BYTES, SCRYPT_PARAMS, SETTLEMENT_PURPOSES, SLIP44_SOLANA, SOLANA_CLUSTER_IDS, SPL_TOKEN_PROGRAM_ID_BASE58, TOKEN_2022_PROGRAM_ID_BASE58, USDC_MINTS, WELL_KNOWN_ASSETS, WELL_KNOWN_ASSET_KEYS, base58btcDecode, base58btcEncode, buildCreateLockIxData, buildPartialReleaseDigest, buildRefundDigest, buildReleaseDigest, buildWebhookSignatureHeader, bytes16ToDelegationId, canonicalBytes, canonicalJson, canonicalSha256Hex, computeCreateLockDiscriminator, delegationIdToBytes16, deriveConditionHash, deriveLockId, deriveScryptKey, detectTokenProgramFromOwner, detectTokenProgramFromOwnerBytes, expiresAt, findFirstChainDivergence, formatDid, generateKeyPair, getPublicKey2 as getPublicKey, isAssetIdentifier, isDeclineReason, isProbeRequest, isValidDid, parseCaip19SolanaAssetId, parseDid, pollUntil, resolveAsset, rfc3339, scryptPasswordProofSign, scryptPasswordProofVerify, senderNonce, serverEventHash, sha256HexLower, sign2 as sign, signChallenge, signCosignature, signEnvelope, signKeyLinkAttestation, signKeyRotationAttestation, signedMessageHash, uuidV4, verify2 as verify, verifyAndCheckPayload, verifyChallenge, verifyCosignature, verifyEnvelope, verifyKeyLinkAttestation, verifyKeyRotationAttestation, verifyWebhookSignatureHeader };
package/dist/purpose.d.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  * "this byte string was signed" assertion is not enough; the bytes
8
8
  * themselves carry the role).
9
9
  *
10
- * Adding a new purpose = add an entry here AND amend
11
- * [00-core/protocol.md](../../00-core/protocol.md) in the same PR.
10
+ * Adding a new purpose means adding an entry here and keeping
11
+ * [00-core/protocol.md](../../00-core/protocol.md) in sync.
12
12
  */
13
13
  export declare const Purpose: {
14
14
  /** Default for `protected.purpose` on body messages. */
@@ -327,7 +327,7 @@ export interface SettlementSignatureContent {
327
327
  * Unix seconds — the `expires_at` value the payee bound into the
328
328
  * digest. Server re-uses it when reconstructing the digest and
329
329
  * cross-checks against `expires_at > now` + `expires_at <=
330
- * lock.expiry - DISPUTE_BUFFER_SECONDS` (ADR-12 §4). The payer
330
+ * lock.expiry - DISPUTE_BUFFER_SECONDS`. The payer
331
331
  * echoes the SAME value on the cosign envelope's settlement_signatures
332
332
  * block so both sides sign the same bytes.
333
333
  */
@@ -1,2 +1,3 @@
1
- export { buildWebhookSignatureHeader, verifyWebhookSignatureHeader } from './webhook';
2
- export type { WebhookSignableInput } from './webhook';
1
+ export { buildWebhookSignatureHeader, isProbeRequest, sha256HexLower, verifyAndCheckPayload, verifyWebhookSignatureHeader } from './webhook';
2
+ export type { WebhookSignableInput, WebhookSignableSource } from './webhook';
3
+ export type { ChainEventRecoveryRow, ChainEventRecoveryRowCommon, ChainPayloadCommon, ChainWebhookPayload, DisputeResolvedWebhookPayload, EnvelopeWebhookPayload, LockCreatedWebhookPayload, LockPartiallyReleasedWebhookPayload, LockRefundedWebhookPayload, LockReleasedWebhookPayload, WebhookPayload, } from './payload-types';
@@ -0,0 +1,198 @@
1
+ /**
2
+ * SDK consumer types for the on-the-wire body the OutboxDeliveryWorker
3
+ * POSTs to a recipient's `webhookUrl`. Mirrors the JSON shape the
4
+ * server's `OutboxDeliveryWorkerService.deliver` builds.
5
+ *
6
+ * The body is a discriminated union on `event_type`:
7
+ *
8
+ * - `envelope.<type>` → `payload` is the envelope `EventPublic`
9
+ * shape (eventId, messageId, sender/recipient DIDs, protected
10
+ * block, body, attachments, server-derived hashes).
11
+ * - `escrow.<event>` → `payload` is the per-event-type chain shape
12
+ * (delegation/lock IDs, amounts, verdict, etc.).
13
+ *
14
+ * Recipient TypeScript consumers `switch (payload.event_type)` to
15
+ * branch exhaustively without `default` — drift on either side
16
+ * surfaces as a missing-case compile error.
17
+ */
18
+ /**
19
+ * What the server-side `events` collection row looks like on the wire
20
+ * after `buildEnvelopeDeliveryPayload` formatting. Recipients see the
21
+ * full canonical envelope so they can re-verify the signature locally
22
+ * with the SDK (same call they'd make on `/inbox?since=` rows).
23
+ *
24
+ * Body shape varies by `type` — keep `body` open (the SDK's
25
+ * type-narrowing for each `BodyType` lives in
26
+ * `packages/sdk/src/envelope/*` and is consumed when the recipient
27
+ * branches on body.type).
28
+ */
29
+ export interface EnvelopeWebhookPayload {
30
+ event_type: `envelope.${string}`;
31
+ delivery_id: string;
32
+ attempt_n: number;
33
+ served_at: string;
34
+ payload: {
35
+ eventId: string;
36
+ messageId: string;
37
+ senderDid: string;
38
+ recipientDid: string;
39
+ relationshipId: string;
40
+ senderSequence: number;
41
+ protocolVersion: string;
42
+ purpose: string;
43
+ type: string;
44
+ protectedBlock: Record<string, unknown>;
45
+ body: Record<string, unknown>;
46
+ attachments?: Record<string, unknown>;
47
+ senderSignature: string;
48
+ relationshipEventIndex: number;
49
+ prevServerEventHash: string | null;
50
+ serverTimestamp: string;
51
+ signedMessageHash: string;
52
+ serverEventHash: string;
53
+ };
54
+ }
55
+ /**
56
+ * Fields every chain-source payload carries. `chain_event_id` +
57
+ * `instruction_idx` let the recipient reconstruct
58
+ * `WebhookSignableInput.source` for HMAC verification from the body
59
+ * alone — no reverse-parse of `delivery_id` required.
60
+ */
61
+ export interface ChainPayloadCommon {
62
+ chain_event_id: string;
63
+ instruction_idx: number;
64
+ delegation_id: string;
65
+ relationship_id: string;
66
+ payer_did: string;
67
+ payee_did: string;
68
+ tx_signature: string;
69
+ slot: number;
70
+ block_time_iso?: string;
71
+ lock_id: string;
72
+ }
73
+ export interface LockCreatedWebhookPayload {
74
+ event_type: 'escrow.lock_created';
75
+ delivery_id: string;
76
+ attempt_n: number;
77
+ served_at: string;
78
+ payload: ChainPayloadCommon & {
79
+ amount: string;
80
+ asset_id?: string;
81
+ expiry_unix?: number;
82
+ fee_bps?: number;
83
+ fee_recipient?: string;
84
+ };
85
+ }
86
+ export interface LockReleasedWebhookPayload {
87
+ event_type: 'escrow.lock_released';
88
+ delivery_id: string;
89
+ attempt_n: number;
90
+ served_at: string;
91
+ payload: ChainPayloadCommon & {
92
+ released_amount: string;
93
+ };
94
+ }
95
+ export interface LockPartiallyReleasedWebhookPayload {
96
+ event_type: 'escrow.lock_partially_released';
97
+ delivery_id: string;
98
+ attempt_n: number;
99
+ served_at: string;
100
+ payload: ChainPayloadCommon & {
101
+ released_amount: string;
102
+ refunded_amount: string;
103
+ };
104
+ }
105
+ export interface LockRefundedWebhookPayload {
106
+ event_type: 'escrow.lock_refunded';
107
+ delivery_id: string;
108
+ attempt_n: number;
109
+ served_at: string;
110
+ payload: ChainPayloadCommon & {
111
+ refunded_amount: string;
112
+ reason?: string;
113
+ };
114
+ }
115
+ export interface DisputeResolvedWebhookPayload {
116
+ event_type: 'escrow.dispute_resolved';
117
+ delivery_id: string;
118
+ attempt_n: number;
119
+ served_at: string;
120
+ payload: ChainPayloadCommon & {
121
+ verdict: string;
122
+ };
123
+ }
124
+ /**
125
+ * Discriminated union of every webhook delivery the recipient might
126
+ * receive. Use exhaustive `switch` on `event_type`:
127
+ *
128
+ * ```ts
129
+ * function handle(p: WebhookPayload) {
130
+ * switch (p.event_type) {
131
+ * case 'escrow.lock_created': return onLockCreated(p);
132
+ * case 'escrow.lock_released': return onLockReleased(p);
133
+ * case 'escrow.lock_partially_released': return onPartial(p);
134
+ * case 'escrow.lock_refunded': return onRefund(p);
135
+ * case 'escrow.dispute_resolved': return onDispute(p);
136
+ * default:
137
+ * // p.event_type is `envelope.${string}` here
138
+ * return onEnvelope(p);
139
+ * }
140
+ * }
141
+ * ```
142
+ *
143
+ * The chain branches narrow exhaustively because each is a string-
144
+ * literal type; the envelope branch carries the catch-all
145
+ * `envelope.${string}` template-literal type so recipients can
146
+ * still narrow on `payload.type` if they care about specific body
147
+ * types.
148
+ */
149
+ export type ChainWebhookPayload = LockCreatedWebhookPayload | LockReleasedWebhookPayload | LockPartiallyReleasedWebhookPayload | LockRefundedWebhookPayload | DisputeResolvedWebhookPayload;
150
+ export type WebhookPayload = EnvelopeWebhookPayload | ChainWebhookPayload;
151
+ /**
152
+ * Fields every recovery row carries irrespective of event type — the
153
+ * envelope around the per-event-type `payload`.
154
+ */
155
+ export interface ChainEventRecoveryRowCommon {
156
+ chain_event_id: string;
157
+ delegation_id: string;
158
+ relationship_id: string;
159
+ lock_id: string;
160
+ tx_signature: string;
161
+ slot: number;
162
+ block_time_iso?: string;
163
+ created_at: string;
164
+ }
165
+ /**
166
+ * Recovery channel for chain events. Returned by the
167
+ * `GET /v1/agents/me/chain-events?since=…` endpoint. Recipients call
168
+ * this on handler restart / reconnection to catch any chain events
169
+ * they missed while their handler was down.
170
+ *
171
+ * **Discriminated union**: both `event_type` and `payload` come from
172
+ * the SAME tagged union — `switch (row.event_type)` narrows
173
+ * `row.payload` to the right per-event-type shape so the advertised
174
+ * "share a single handler between push and pull" actually type-checks.
175
+ *
176
+ * switch (row.event_type) {
177
+ * case 'escrow.lock_released':
178
+ * // row.payload is LockReleasedWebhookPayload['payload'] here
179
+ * break;
180
+ * ...
181
+ * }
182
+ */
183
+ export type ChainEventRecoveryRow = ChainEventRecoveryRowCommon & ({
184
+ event_type: 'escrow.lock_created';
185
+ payload: LockCreatedWebhookPayload['payload'];
186
+ } | {
187
+ event_type: 'escrow.lock_released';
188
+ payload: LockReleasedWebhookPayload['payload'];
189
+ } | {
190
+ event_type: 'escrow.lock_partially_released';
191
+ payload: LockPartiallyReleasedWebhookPayload['payload'];
192
+ } | {
193
+ event_type: 'escrow.lock_refunded';
194
+ payload: LockRefundedWebhookPayload['payload'];
195
+ } | {
196
+ event_type: 'escrow.dispute_resolved';
197
+ payload: DisputeResolvedWebhookPayload['payload'];
198
+ });
@@ -2,37 +2,157 @@
2
2
  * `ARP-WEBHOOK-v1` HMAC over the canonical webhook envelope. Used by
3
3
  * the OutboxDeliveryWorker for the `X-ARP-Signature` header.
4
4
  *
5
- * Inputs (per backend's outbox spec):
6
- * - delivery_id — outbox row id
7
- * - recipient_did
8
- * - envelope_message_id — envelope being delivered
9
- * - server_event_hash — chain head at delivery time
10
- * - attempt_n — 1-based retry counter
11
- * - served_at — RFC3339, when this attempt was generated
12
- *
13
- * Each retry produces a different MAC because `attempt_n` and
14
- * `served_at` participate in canonical input replays of an old
15
- * header on a fresh attempt fail HMAC verification.
5
+ * The signable input is a discriminated `source` union covering BOTH
6
+ * envelope deliveries (`source.kind === 'envelope'`) AND chain
7
+ * settlement events (`source.kind === 'chain'`). The single
8
+ * canonical-JSON output binds the HMAC to the source variant so a
9
+ * recipient receiving a chain webhook can't accidentally verify
10
+ * against an envelope-shaped signable, and vice versa.
11
+ *
12
+ * The body-tampering defence (`payload_sha256`) is mandatory on the
13
+ * new shape recipients SHOULD verify the hash matches their
14
+ * received raw body BEFORE the HMAC check (cheap, and surfaces
15
+ * "wrong body" as a separate diagnostic from "wrong HMAC").
16
16
  */
17
+ export type WebhookSignableSource = {
18
+ kind: 'envelope';
19
+ envelope_message_id: string;
20
+ server_event_hash: string;
21
+ } | {
22
+ kind: 'chain';
23
+ chain_event_id: string;
24
+ tx_signature: string;
25
+ instruction_idx: number;
26
+ };
17
27
  export interface WebhookSignableInput {
28
+ /** Outbox row id (deterministic per delivery attempt). */
18
29
  delivery_id: string;
19
30
  recipient_did: string;
20
- envelope_message_id: string;
21
- server_event_hash: string;
31
+ /** Public event-type discriminator (`envelope.<type>` or `escrow.<event>`). */
32
+ event_type: string;
33
+ /** Source-variant discriminated union. Binds the HMAC to the kind. */
34
+ source: WebhookSignableSource;
35
+ /** SHA-256 (lowercase hex, no `sha256:` prefix) of the canonical raw body bytes the recipient receives. */
36
+ payload_sha256: string;
37
+ /** 1-based retry counter — participates in canonical input so replays of an old header on a fresh attempt fail. */
22
38
  attempt_n: number;
39
+ /** RFC 3339 wall-clock time the server generated this attempt. Recipient policy MAY reject outside a tolerance window. */
23
40
  served_at: string;
24
41
  }
25
42
  /**
26
- * Compute `X-ARP-Signature` value: `<purpose>=<base64(HMAC-SHA256(secret, sha256(canonical_json(input))))>`.
43
+ * Compute `X-ARP-Signature` value:
44
+ * `<purpose>=<base64(HMAC-SHA256(secret, sha256(canonical_json(input))))>`.
27
45
  *
28
- * Recipients lookup their per-sender shared secret, recompute the
46
+ * Recipients lookup their per-sender shared secret(s), recompute the
29
47
  * HMAC over the same canonical input, and compare against the
30
- * received header.
48
+ * received header. See `verifyWebhookSignatureHeader` for the
49
+ * standard recipient flow with rotation grace.
31
50
  */
32
51
  export declare function buildWebhookSignatureHeader(input: WebhookSignableInput, sharedSecret: Uint8Array): string;
33
52
  /**
34
- * Constant-time compare an inbound `X-ARP-Signature` header against
35
- * the expected one. Returns `false` on shape mismatch, missing
36
- * purpose label, or HMAC mismatch never throws.
53
+ * Verify an inbound `X-ARP-Signature` header against the reconstructed
54
+ * signable input. The 3-argument form is the convenience for
55
+ * single-secret consumers (most use); the array form is the rotation-
56
+ * grace path (two-phase rotate: recipient verifies against
57
+ * `[current, pending, previous]` during the 1h grace window).
58
+ *
59
+ * Returns `false` on shape mismatch, missing purpose label, OR HMAC
60
+ * mismatch under EVERY supplied secret. Never throws.
61
+ *
62
+ * **Timing-uniform across rotation slots** — the array form runs ALL
63
+ * HMAC computations + constant-time compares before returning, so the
64
+ * total time is proportional to N (number of secrets) regardless of
65
+ * which slot matched. A short-circuiting implementation would leak
66
+ * the position of the matching key via total time. Practical impact
67
+ * is small (attacker would only learn "current vs pending vs previous
68
+ * matched", not key material), but timing-uniform is the right
69
+ * default for a verification primitive.
70
+ *
71
+ * Recipient flow:
72
+ * 1. Receive POST with `X-ARP-Signature` header + raw body bytes.
73
+ * 2. Compute `payload_sha256 = sha256-hex(rawBodyBytes)`.
74
+ * 3. Reconstruct `WebhookSignableInput` from headers + payload sha256.
75
+ * 4. Call `verifyWebhookSignatureHeader(header, input, [current, pending, previous].filter(Boolean))`.
76
+ * 5. On `true`, accept. On `false`, 401 the request.
77
+ *
78
+ * Helper `verifyAndCheckPayload` (below) bundles steps 2-4 into one
79
+ * call for the common path.
80
+ */
81
+ export declare function verifyWebhookSignatureHeader(headerValue: string, input: WebhookSignableInput, sharedSecret: Uint8Array | Uint8Array[]): boolean;
82
+ /**
83
+ * Recipient convenience: combine body-hash binding + HMAC verify +
84
+ * optional `served_at` clock-skew tolerance into one call.
85
+ *
86
+ * 1. Compute `payload_sha256 = sha256-hex(rawBody)` and OVERLAY it
87
+ * onto `input.payload_sha256` (caller's value, if any, is
88
+ * replaced).
89
+ * 2. Verify `served_at` is within `toleranceMs` of `now()` (default
90
+ * ±5 minutes; pass `null` to skip).
91
+ * 3. Verify HMAC against each secret in `secrets`.
92
+ *
93
+ * Returns a tagged outcome the caller can branch on:
94
+ * - `'ok'` — accept the delivery
95
+ * - `'stale_attempt'` — served_at outside the tolerance window
96
+ * - `'invalid_signature'` — HMAC didn't verify under any secret
97
+ *
98
+ * **Why no `body_mismatch` outcome** — `payload_sha256` is a derived
99
+ * field bound to the raw body. The HMAC signs the input INCLUDING
100
+ * `payload_sha256`, so a tampered body causes the recipient's
101
+ * recomputed hash to differ from the server-signed one, which fails
102
+ * the HMAC check as `'invalid_signature'`. Body tampering is
103
+ * detected via the HMAC failure; if you need a dedicated diagnostic,
104
+ * compute `sha256HexLower(rawBody)` yourself and compare to an
105
+ * out-of-band trusted source (e.g. a signed mirror of the body).
106
+ */
107
+ export declare function verifyAndCheckPayload(args: {
108
+ headerValue: string;
109
+ /** Reconstructed signable input MINUS `payload_sha256` — helper computes that field from `rawBody`. */
110
+ input: Omit<WebhookSignableInput, 'payload_sha256'>;
111
+ rawBody: Uint8Array;
112
+ secrets: Uint8Array | Uint8Array[];
113
+ servedAtToleranceMs?: number | null;
114
+ now?: Date;
115
+ }): 'ok' | 'stale_attempt' | 'invalid_signature';
116
+ /**
117
+ * Probe-skip helper for recipient handlers. The `POST /v1/agents/me/webhook-config`
118
+ * flow fires an UNSIGNED probe POST with the `X-ARP-Probe: 1` header.
119
+ * Recipient handlers MUST 2xx without HMAC verification on probe
120
+ * requests — otherwise the operator can't set their webhook URL
121
+ * (the probe gets rejected as unsigned, the URL persist step fails).
122
+ *
123
+ * Use this at the top of your handler:
124
+ *
125
+ * ```ts
126
+ * if (isProbeRequest(req.headers)) {
127
+ * res.statusCode = 200;
128
+ * res.end();
129
+ * return;
130
+ * }
131
+ * // Otherwise: verify signature normally.
132
+ * ```
133
+ *
134
+ * Header values are inspected case-insensitively per Node's
135
+ * IncomingMessage convention.
136
+ *
137
+ * **SECURITY (spoofability)** — the helper is a pure header check;
138
+ * it cannot itself defend against an attacker setting
139
+ * `X-ARP-Probe: 1` to bypass HMAC verification. The probe branch
140
+ * in your handler MUST be side-effect-free:
141
+ *
142
+ * - Respond 2xx with an empty body — no DB write, no downstream
143
+ * fan-out, no metric increment that an attacker could amplify.
144
+ * - Consider rate-limiting probe-skipped requests per source IP if
145
+ * the surrounding stack doesn't already.
146
+ * - Do NOT log full request bodies on the probe branch (an
147
+ * attacker can pump log noise).
148
+ * - If your handler genuinely has zero side-effects on a 200
149
+ * response, this is automatic; otherwise treat the probe branch
150
+ * as you would an unauthenticated health-check endpoint.
151
+ */
152
+ export declare function isProbeRequest(headers: Record<string, string | string[] | undefined>): boolean;
153
+ /**
154
+ * SHA-256 hex digest of the input bytes, lowercase. Exported for unit
155
+ * tests + the rare consumer that needs to compute payload_sha256
156
+ * outside `verifyAndCheckPayload`.
37
157
  */
38
- export declare function verifyWebhookSignatureHeader(headerValue: string, input: WebhookSignableInput, sharedSecret: Uint8Array): boolean;
158
+ export declare function sha256HexLower(bytes: Uint8Array): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyanon-arp/sdk",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "TypeScript SDK for the Agent Relationship Protocol — canonical JSON, Ed25519 envelope sign/verify, did:arp identity, receipt co-signatures, scrypt key attestation, chain-audit helpers.",
5
5
  "license": "MIT",
6
6
  "keywords": [