@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.
- package/LICENSE +661 -0
- package/README.md +54 -0
- package/SECURITY.md +116 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/intro-crypto.d.ts +216 -0
- package/dist/intro-crypto.d.ts.map +1 -0
- package/dist/intro-crypto.js +378 -0
- package/dist/intro-safety-number.d.ts +42 -0
- package/dist/intro-safety-number.d.ts.map +1 -0
- package/dist/intro-safety-number.js +108 -0
- package/dist/intro-wire.d.ts +149 -0
- package/dist/intro-wire.d.ts.map +1 -0
- package/dist/intro-wire.js +160 -0
- package/package.json +72 -0
|
@@ -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
|
+
}
|