@oxpulse/intro-protocol 0.2.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 @@
1
+ {"version":3,"file":"intro-wire.d.ts","sourceRoot":"","sources":["../src/intro-wire.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA2DxB,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;iBAa/B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;iBAM9B,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;iBAI/B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;iBAI5B,CAAC;AAEH,eAAO,MAAM,qBAAqB;;;;iBAIhC,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;iBAU7B,CAAC;AAMH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAO7B,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAC9D,MAAM,MAAM,SAAS,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;AAM7C;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,YAAY,GAAG,MAAM,CAG5D;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,YAAY,CAEjE;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,YAAY,EACjB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAET"}
@@ -0,0 +1,160 @@
1
+ /**
2
+ * intro-wire.ts — Wire format codec for L2 introduction protocol messages.
3
+ *
4
+ * Encodes/decodes all 6 message types per sub-spec §4.1. Outer envelope
5
+ * is JSON (matches chat-sdk inner-payload pattern at sdk-inbox-bridge
6
+ * dispatch). Zod discriminated union validates at the receive boundary.
7
+ *
8
+ * ADR-002: JSON + Zod wire format (NOT wire-codec CBOR/zstd — the intro
9
+ * protocol is its own bounded context per ADR-010).
10
+ *
11
+ * Plan: docs/superpowers/plans/2026-05-22-discovery-l2-plan.md (S3)
12
+ */
13
+ import { z } from 'zod';
14
+ // Constant-time comparison — single source of truth (ADR-008, ADR-011).
15
+ import { timingSafePubkeyEqualB64u } from '@oxpulse/crypto-primitives';
16
+ // ---------------------------------------------------------------------------
17
+ // Primitive validators
18
+ // ---------------------------------------------------------------------------
19
+ /** 22-char URL-safe base64 — encodes 16 bytes (128-bit session ID) */
20
+ const SessionId = z.string().regex(/^[A-Za-z0-9_-]{22}$/);
21
+ /** 43-char URL-safe base64 — encodes 32 bytes (Ed25519/X25519 public key) */
22
+ const PubkeyB64u = z.string().regex(/^[A-Za-z0-9_-]{43}$/);
23
+ /** 43-char URL-safe base64 — X25519 ephemeral public key (same 32B → 43-char) */
24
+ const EphPubB64u = z.string().regex(/^[A-Za-z0-9_-]{43}$/);
25
+ /**
26
+ * Generic URL-safe base64 string (no fixed length) — used for variable-length
27
+ * payloads like profile_key_b64u. Constrains charset to base64url to fail
28
+ * fast on malformed input rather than passing through to downstream decode.
29
+ */
30
+ const B64uString = z.string().regex(/^[A-Za-z0-9_-]+$/).min(1);
31
+ /**
32
+ * Deterministically CBOR-encodable transport properties (ADR-013 / #218 nit #6).
33
+ *
34
+ * Constrained to scalar types that cborg's rfc8949EncodeOptions encode
35
+ * canonically: string | number | int | bool | null | arrays thereof.
36
+ * Rejects floats-with-NaN-payloads, bigint, Map, Set, and other shapes
37
+ * whose CBOR encoding can diverge between two parties constructing the
38
+ * "same" logical value (which would break transcript/MAC verification).
39
+ */
40
+ const TransportProps = z.record(z.string(), z.union([
41
+ z.string(),
42
+ z.number(),
43
+ z.bigint(),
44
+ z.boolean(),
45
+ z.null(),
46
+ z.array(z.unknown()),
47
+ ])).default({});
48
+ /**
49
+ * AEAD ciphertext: 24B nonce ‖ ciphertext ‖ 16B tag, URL-safe base64.
50
+ * Minimum 54 chars = ceil((24 + 0 + 16) * 4 / 3) = ceil(160/3) = 54.
51
+ */
52
+ const AeadCiphertextB64u = z
53
+ .string()
54
+ .regex(/^[A-Za-z0-9_-]+$/)
55
+ .min(54);
56
+ // ---------------------------------------------------------------------------
57
+ // Per-kind schemas
58
+ // ---------------------------------------------------------------------------
59
+ export const IntroRequestV1Schema = z.object({
60
+ kind: z.literal('intro_request_v1'),
61
+ sessionId: SessionId,
62
+ target: z.object({
63
+ pubkey_b64u: PubkeyB64u,
64
+ author_b64u: PubkeyB64u,
65
+ profile_key_b64u: B64uString,
66
+ short_id: z.string().optional(),
67
+ handle: z.string().optional(),
68
+ transport_props: TransportProps,
69
+ }),
70
+ note: z.string().max(280).optional(),
71
+ created_at: z.number().int().nonnegative(),
72
+ });
73
+ export const IntroAcceptV1Schema = z.object({
74
+ kind: z.literal('intro_accept_v1'),
75
+ sessionId: SessionId,
76
+ eph_pub_b64u: EphPubB64u,
77
+ accepted_at: z.number().int().nonnegative(),
78
+ transport_props: TransportProps,
79
+ });
80
+ export const IntroDeclineV1Schema = z.object({
81
+ kind: z.literal('intro_decline_v1'),
82
+ sessionId: SessionId,
83
+ reason: z.enum(['declined', 'unknown_introducer', 'silent', 'blocked']).optional(),
84
+ });
85
+ export const IntroAuthV1Schema = z.object({
86
+ kind: z.literal('intro_auth_v1'),
87
+ sessionId: SessionId,
88
+ aead_ciphertext: AeadCiphertextB64u,
89
+ });
90
+ export const IntroActivateV1Schema = z.object({
91
+ kind: z.literal('intro_activate_v1'),
92
+ sessionId: SessionId,
93
+ aead_ciphertext: AeadCiphertextB64u,
94
+ });
95
+ export const IntroAbortV1Schema = z.object({
96
+ kind: z.literal('intro_abort_v1'),
97
+ sessionId: SessionId,
98
+ reason: z.enum([
99
+ 'timeout',
100
+ 'invariant_violation',
101
+ 'user_cancel',
102
+ 'peer_declined',
103
+ 'cross_instance_unsupported',
104
+ ]),
105
+ });
106
+ // ---------------------------------------------------------------------------
107
+ // Discriminated union over all 6 kinds
108
+ // ---------------------------------------------------------------------------
109
+ export const IntroMessageSchema = z.discriminatedUnion('kind', [
110
+ IntroRequestV1Schema,
111
+ IntroAcceptV1Schema,
112
+ IntroDeclineV1Schema,
113
+ IntroAuthV1Schema,
114
+ IntroActivateV1Schema,
115
+ IntroAbortV1Schema,
116
+ ]);
117
+ // ---------------------------------------------------------------------------
118
+ // Codec functions
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * Encode an introduction protocol message to a JSON wire string.
122
+ *
123
+ * Validates before encoding (defensive — refuses to produce invalid wire
124
+ * payloads even when TypeScript types are satisfied at call site).
125
+ */
126
+ export function encodeIntroMessage(msg) {
127
+ const validated = IntroMessageSchema.parse(msg);
128
+ return JSON.stringify(validated);
129
+ }
130
+ /**
131
+ * Decode an introduction protocol message from an unknown payload.
132
+ *
133
+ * The payload is the JSON-parsed object received from sdk-inbox-bridge
134
+ * onSealedPayload callback. Throws a ZodError on validation failure —
135
+ * callers must catch and treat as a rejected/malformed message.
136
+ */
137
+ export function decodeIntroMessage(payload) {
138
+ return IntroMessageSchema.parse(payload);
139
+ }
140
+ /**
141
+ * Verify the sessionId redundancy check per sub-spec §4.1.
142
+ *
143
+ * The receiver re-derives the sessionId locally from the introducer's
144
+ * public key + their own key material, then asserts equality against
145
+ * the value carried on the wire. A mismatch indicates either an
146
+ * introducer forgery attempt or a deterministic-derivation drift bug.
147
+ *
148
+ * Returns true if the wire sessionId matches the locally-derived value.
149
+ *
150
+ * *** SECURITY (ADR-011, CWE-208) ***
151
+ * The comparison uses `timingSafePubkeyEqualB64u` (constant-time XOR-reduce
152
+ * over the decoded bytes) instead of plain `===`. A plain `===` on the
153
+ * crypto-derived b64url sessionId leaks the first-mismatch byte position
154
+ * via timing, enabling a session-id oracle (OWASP ASVS V11.3.1, CWE-208).
155
+ * The sessionId is attacker-influenced (an introducer forgery attempt
156
+ * controls the wire value), so it MUST be compared in constant time.
157
+ */
158
+ export function verifySessionIdRedundancy(msg, derivedSessionId) {
159
+ return timingSafePubkeyEqualB64u(msg.sessionId, derivedSessionId);
160
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@oxpulse/intro-protocol",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "AGPL-3.0-or-later",
7
+ "author": "Anatoly Koptev",
8
+ "homepage": "https://github.com/anatolykoptev/oxpulse-chat-sdk/tree/main/packages/intro-protocol",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/anatolykoptev/oxpulse-chat-sdk.git",
12
+ "directory": "packages/intro-protocol"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/anatolykoptev/oxpulse-chat-sdk/issues"
16
+ },
17
+ "keywords": [
18
+ "oxpulse",
19
+ "chat",
20
+ "e2ee",
21
+ "crypto",
22
+ "introduction",
23
+ "contact",
24
+ "safety-number",
25
+ "timing-safe",
26
+ "cwe-208"
27
+ ],
28
+ "description": "L2 introduction protocol — Briar-faithful intro crypto (X25519+HKDF+AEAD), JSON+Zod wire codec, and Signal-style safety numbers. Fixes CWE-208 timing oracle in sessionId redundancy check. EXPERIMENTAL (0.1.0).",
29
+ "main": "dist/index.js",
30
+ "types": "dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js",
35
+ "default": "./dist/index.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "README.md",
41
+ "LICENSE",
42
+ "SECURITY.md"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "directory": "dist"
47
+ },
48
+ "scripts": {
49
+ "prepare": "tsc --outDir dist",
50
+ "build": "tsc --outDir dist",
51
+ "prepublishOnly": "npm run build",
52
+ "typecheck": "tsc --noEmit",
53
+ "test": "vitest run",
54
+ "test:watch": "vitest"
55
+ },
56
+ "dependencies": {
57
+ "@oxpulse/crypto-primitives": "^0.2.0",
58
+ "@noble/curves": "^2.2.0",
59
+ "@noble/hashes": "^2.2.0",
60
+ "@noble/ciphers": "^2.2.0",
61
+ "cborg": "^5.1.1",
62
+ "zod": "^4.4.3"
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "^22",
66
+ "typescript": "^6.0.3",
67
+ "vitest": "^4.1.5"
68
+ },
69
+ "engines": {
70
+ "node": ">=22"
71
+ }
72
+ }