@volter/twin-veriff 0.1.0

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.
@@ -0,0 +1,86 @@
1
+ import { nodeBuiltin } from '@volter/world-core';
2
+ // Veriff's HMAC request/webhook signing — REAL crypto, reproduced byte-for-byte.
3
+ //
4
+ // Veriff authenticates two directions with the SAME primitive and two different payloads:
5
+ //
6
+ // 1. OUTBOUND (you → Veriff). `X-AUTH-CLIENT` carries the public API key. Endpoints that require
7
+ // signing add `X-HMAC-SIGNATURE` = lowercase-hex HMAC-SHA256 over the **payload**, keyed by
8
+ // the account's shared secret. The payload is the raw JSON request BODY for a request that has
9
+ // one, and the **path parameter itself** (the session id / attempt id / media id) for a GET,
10
+ // which has no body. The real consumer does exactly this — dub's
11
+ // `apps/web/lib/veriff/client.ts` signs `fetchSessionDecision` with
12
+ // `createHmac('sha256', VERIFF_SHARED_SECRET).update(sessionId).digest('hex')` and sends it as
13
+ // `X-HMAC-SIGNATURE`.
14
+ //
15
+ // 2. INBOUND (Veriff → you). Every webhook delivery carries `x-auth-client` (the same public API
16
+ // key, so a receiver can tell WHICH account) and `x-hmac-signature` = lowercase-hex
17
+ // HMAC-SHA256 over the **raw delivered body**, keyed by the same shared secret. dub's
18
+ // `app/api/veriff/webhook/route.ts` verifies exactly this, timing-safe, over `req.text()`.
19
+ //
20
+ // Both are pure local computations over a secret, so a twin can hold the scheme HONESTLY rather
21
+ // than faking it: a consumer that signs wrongly gets the vendor's rejection here too, and a
22
+ // consumer verifying a twin-delivered webhook with the real `crypto` code path passes unchanged.
23
+ //
24
+ // `node:crypto` is required LAZILY (server-only) so this module is safe to import anywhere.
25
+ type NodeCrypto = typeof import('node:crypto');
26
+ function nodeCrypto(): NodeCrypto {
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
28
+ return nodeBuiltin('node:crypto') as NodeCrypto;
29
+ }
30
+
31
+ /** The header Veriff carries the public API key in (case-insensitive on the wire). */
32
+ export const AUTH_CLIENT_HEADER = 'x-auth-client';
33
+ /** The header Veriff carries the HMAC in (case-insensitive on the wire). */
34
+ export const HMAC_SIGNATURE_HEADER = 'x-hmac-signature';
35
+
36
+ /**
37
+ * Compute Veriff's signature for a payload: lowercase-hex HMAC-SHA256(sharedSecret, payload).
38
+ *
39
+ * `payload` is the raw request/webhook body for anything with a body, and the bare path parameter
40
+ * (session id, attempt id, media id) for a GET. Callers pass the exact bytes — this function never
41
+ * re-serializes, because a re-serialized body signs different bytes than the ones sent.
42
+ */
43
+ export function veriffSignature(payload: string, sharedSecret: string): string {
44
+ return nodeCrypto().createHmac('sha256', sharedSecret).update(payload, 'utf8').digest('hex');
45
+ }
46
+
47
+ /**
48
+ * The same signature over RAW BYTES rather than a string.
49
+ *
50
+ * Needed because one response is not JSON: `GET /v1/media/{id}` answers with the media itself under
51
+ * its own mimetype, and the documented `X-HMAC-SIGNATURE` header is "Response body signed with the
52
+ * shared secret key" — the body a receiver actually got. Signing the base64 TEXT the twin happens to
53
+ * store it as produces a digest that verifies against nothing the consumer holds; a §9 round-two
54
+ * review measured exactly that mismatch before this existed.
55
+ */
56
+ export function veriffSignatureBytes(payload: Uint8Array, sharedSecret: string): string {
57
+ return nodeCrypto().createHmac('sha256', sharedSecret).update(payload).digest('hex');
58
+ }
59
+
60
+ /**
61
+ * Constant-time compare of two signature strings.
62
+ *
63
+ * Length is compared first and NOT with `timingSafeEqual` — the node primitive THROWS on a length
64
+ * mismatch rather than returning false, so an unequal-length forgery would crash the handler
65
+ * instead of being rejected. The real consumer (dub) guards the same way.
66
+ */
67
+ export function signaturesMatch(a: string, b: string): boolean {
68
+ const ab = Buffer.from(a, 'utf8');
69
+ const bb = Buffer.from(b, 'utf8');
70
+ if (ab.length !== bb.length) return false;
71
+ return nodeCrypto().timingSafeEqual(Uint8Array.from(ab), Uint8Array.from(bb));
72
+ }
73
+
74
+ /**
75
+ * Verify a presented signature against the payload. Returns the reason it failed, or `null` when
76
+ * it is valid — so a caller can map a MISSING header and a WRONG one to the different statuses
77
+ * Veriff uses for them.
78
+ */
79
+ export function verifyVeriffSignature(
80
+ payload: string,
81
+ presented: string | undefined | null,
82
+ sharedSecret: string,
83
+ ): 'missing' | 'mismatch' | null {
84
+ if (presented === undefined || presented === null || presented === '') return 'missing';
85
+ return signaturesMatch(veriffSignature(payload, sharedSecret), presented) ? null : 'mismatch';
86
+ }