@kangentic/protocol 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.
package/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ See the LICENSE file in the root of this repository:
2
+ https://github.com/Kangentic/kangentic/blob/main/LICENSE
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-only
5
+ Copyright (C) 2025-2026 VORPAHL LLC. All rights reserved.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # @kangentic/protocol
2
+
3
+ Shared wire protocol for the Kangentic mobile bridge: frame encoding, the Noise-based pairing
4
+ and session handshakes, secretstream-style AEAD framing, the signed device roster, capability
5
+ verbs, and the transcript/board/activity event contract types the mobile app consumes.
6
+
7
+ This package is the single source of truth for the desktop (`src/main/mobile-bridge/`) and
8
+ `kangentic-mobile` implementations of the protocol. It is pure TypeScript on top of
9
+ [`@noble/curves`](https://github.com/paulmillr/noble-curves),
10
+ [`@noble/hashes`](https://github.com/paulmillr/noble-hashes), and
11
+ [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) so the exact same handshake code
12
+ runs on Node (Electron main) and React Native (via the same pure-JS primitives), with no native
13
+ module coupling.
14
+
15
+ See `docs/mobile-bridge.md` in the main repository for the pairing ceremony, roster/revocation
16
+ model, and the honest relay-metadata statement.
17
+
18
+ ## Security
19
+
20
+ - `Noise_KK_25519_ChaChaPoly_BLAKE2s` for ongoing sessions (mutual authentication by construction,
21
+ no trust-on-first-use).
22
+ - A token-bound Noise PSK handshake for the one-time pairing ceremony, confirmed by a Matrix-style
23
+ short authentication string (SAS) on both screens.
24
+ - No shell, file, or arbitrary-command verb exists in the protocol.
25
+
26
+ This package has not undergone a third-party security audit. Report suspected vulnerabilities
27
+ per the repository's security policy rather than filing a public issue.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The complete set of capability verbs a paired device may be granted.
3
+ * There is deliberately NO shell, file-read, or arbitrary-command verb --
4
+ * absent from the protocol entirely, not filtered at runtime (the SSH
5
+ * forced-command lesson: Chrome Remote Desktop and VS Code tunnels are the
6
+ * counter-examples, identity-gated but capability-unscoped). Adding a new
7
+ * verb here is a protocol change; it does not, by itself, grant anything --
8
+ * the desktop's capability router (Phase 2) still has to implement and
9
+ * wire a handler, and a device's roster entry still has to include it in
10
+ * its CapabilitySet.
11
+ */
12
+ export declare const CAPABILITY_VERBS: readonly ["read-stream", "read-board", "read-diff", "send-user-message", "move-task", "answer-permission-prompt"];
13
+ export type CapabilityVerb = (typeof CAPABILITY_VERBS)[number];
14
+ export declare function isCapabilityVerb(value: string): value is CapabilityVerb;
15
+ /** Deny-by-default: a verb absent from the set is not authorized. */
16
+ export type CapabilitySet = ReadonlySet<CapabilityVerb>;
17
+ export declare function capabilitySetFromArray(verbs: readonly string[]): CapabilitySet;
18
+ export declare function capabilitySetToArray(capabilities: CapabilitySet): CapabilityVerb[];
@@ -0,0 +1,9 @@
1
+ export declare class CipherState {
2
+ private key;
3
+ private nonce;
4
+ initializeKey(key: Uint8Array | null): void;
5
+ hasKey(): boolean;
6
+ encryptWithAd(associatedData: Uint8Array, plaintext: Uint8Array): Uint8Array;
7
+ /** Nonce is only advanced on a SUCCESSFUL decrypt; aeadDecrypt throws on tag failure. */
8
+ decryptWithAd(associatedData: Uint8Array, ciphertext: Uint8Array): Uint8Array;
9
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Noise Protocol Framework section 5.3: the HandshakeState object. A
3
+ * generic token interpreter driven by a NoisePattern, so both the ongoing
4
+ * session pattern (KK) and the pairing pattern (IKpsk0) share this exact
5
+ * code - only the pattern table and the supplied key material differ.
6
+ */
7
+ import { type X25519KeyPair } from '../primitives';
8
+ import { CipherState } from './cipher-state';
9
+ import type { NoisePattern } from './patterns';
10
+ export interface HandshakeStateOptions {
11
+ pattern: NoisePattern;
12
+ initiator: boolean;
13
+ /** Bound into the transcript before any key material - kills downgrade attacks when it encodes the negotiated protocol version. */
14
+ prologue: Uint8Array;
15
+ /** Local static keypair. Required whenever the pattern's local role sends or has pre-shared an "s" token. */
16
+ s?: X25519KeyPair;
17
+ /** Local ephemeral keypair. Only ever set by a test harness; production code always lets writeMessage() generate it. */
18
+ e?: X25519KeyPair;
19
+ /** Remote static public key. Required whenever the pattern's local role receives an "s" pre-message (e.g. KK) or already knows it out-of-band (e.g. IK's responder key from a QR). */
20
+ rs?: Uint8Array;
21
+ re?: Uint8Array;
22
+ /** 32-byte pre-shared key. Required by patterns with a "psk" token. */
23
+ psk?: Uint8Array;
24
+ /** Test-only override for ephemeral keypair generation, to replay a fixed test vector. */
25
+ generateEphemeral?: () => X25519KeyPair;
26
+ }
27
+ export interface HandshakeWriteResult {
28
+ message: Uint8Array;
29
+ /** Present once the pattern's last message has been written. */
30
+ split?: [CipherState, CipherState];
31
+ }
32
+ export interface HandshakeReadResult {
33
+ payload: Uint8Array;
34
+ /** Present once the pattern's last message has been read. */
35
+ split?: [CipherState, CipherState];
36
+ }
37
+ export declare class HandshakeState {
38
+ private readonly symmetricState;
39
+ private readonly pattern;
40
+ private readonly initiator;
41
+ private readonly generateEphemeral;
42
+ private readonly psk?;
43
+ private s?;
44
+ private e?;
45
+ private rs?;
46
+ private re?;
47
+ private messageIndex;
48
+ constructor(options: HandshakeStateOptions);
49
+ private resolvePreMessageKey;
50
+ private requireLocalKeyPair;
51
+ private requireRemoteKey;
52
+ private requirePsk;
53
+ private dh;
54
+ private mixDhToken;
55
+ private nextTokens;
56
+ private maybeSplit;
57
+ writeMessage(payload: Uint8Array): HandshakeWriteResult;
58
+ readMessage(message: Uint8Array): HandshakeReadResult;
59
+ getHandshakeHash(): Uint8Array;
60
+ /** See SymmetricState.getChainingKey(): only meaningful once split() has occurred. */
61
+ getChainingKey(): Uint8Array;
62
+ /** The remote static public key, once learned (pre-shared or received via an "s" token). */
63
+ getRemoteStaticKey(): Uint8Array | undefined;
64
+ }
@@ -0,0 +1,11 @@
1
+ import type { X25519KeyPair } from '../primitives';
2
+ import { HandshakeState } from './handshake-state';
3
+ export interface KKHandshakeOptions {
4
+ initiator: boolean;
5
+ /** Local device's static identity keypair. */
6
+ localStatic: X25519KeyPair;
7
+ /** Peer's static public key, already pinned in the signed device roster. */
8
+ remoteStatic: Uint8Array;
9
+ protocolVersion?: string;
10
+ }
11
+ export declare function createKKHandshake(options: KKHandshakeOptions): HandshakeState;
@@ -0,0 +1,48 @@
1
+ /** Noise message-pattern tokens (section 5.3, plus "psk" from section 9). */
2
+ export type NoiseToken = 'e' | 's' | 'ee' | 'es' | 'se' | 'ss' | 'psk';
3
+ export interface NoisePattern {
4
+ /** Used to construct the protocol name, e.g. "KK" -> Noise_KK_25519_ChaChaPoly_BLAKE2s. */
5
+ readonly name: string;
6
+ readonly initiatorPreMessage: readonly NoiseToken[];
7
+ readonly responderPreMessage: readonly NoiseToken[];
8
+ readonly messages: readonly (readonly NoiseToken[])[];
9
+ /**
10
+ * Noise section 9.2: in a PSK handshake every "e" token calls
11
+ * MixKey(e.public_key) in addition to the MixHash(e.public_key) a
12
+ * non-PSK handshake always does. This must be a property of the
13
+ * PATTERN (not inferred from "was a psk option passed"), since it
14
+ * changes how the "e" token itself is processed, independent of
15
+ * whether the pattern's own "psk" token has been reached yet.
16
+ */
17
+ readonly usesPsk: boolean;
18
+ }
19
+ /**
20
+ * Both parties' static keys are known in advance (mutual authentication by
21
+ * construction, no trust-on-first-use). Used for ongoing bridge sessions
22
+ * once a device is in the signed roster.
23
+ *
24
+ * KK:
25
+ * -> s
26
+ * <- s
27
+ * ...
28
+ * -> e, es, ss
29
+ * <- e, ee, se
30
+ */
31
+ export declare const KK_PATTERN: NoisePattern;
32
+ /**
33
+ * The initiator (phone) knows the responder's (desktop's) static key from
34
+ * the QR in advance; the responder learns the initiator's static key from
35
+ * the "s" token in message 1, which is exactly how the desktop learns the
36
+ * phone's identity key during pairing. The "psk0" modifier mixes the
37
+ * pairing token in before any Diffie-Hellman step, so message 1 only
38
+ * decrypts (and the "s"/payload fields inside it only authenticate) for a
39
+ * peer that has the same token - a wrong guess fails at DecryptAndHash
40
+ * with no oracle beyond "handshake failed".
41
+ *
42
+ * IKpsk0:
43
+ * <- s
44
+ * ...
45
+ * -> psk, e, es, s, ss
46
+ * <- e, ee, se
47
+ */
48
+ export declare const IKPSK0_PATTERN: NoisePattern;
@@ -0,0 +1,28 @@
1
+ import { CipherState } from './cipher-state';
2
+ export declare class SymmetricState {
3
+ private readonly cipherState;
4
+ private chainingKey;
5
+ private hash;
6
+ constructor(protocolName: Uint8Array);
7
+ mixKey(inputKeyMaterial: Uint8Array): void;
8
+ mixHash(data: Uint8Array): void;
9
+ /** Processes a "psk" token: MixKeyAndHash(psk) per Noise section 9. */
10
+ mixKeyAndHash(inputKeyMaterial: Uint8Array): void;
11
+ getHandshakeHash(): Uint8Array;
12
+ /**
13
+ * The raw chaining key, meaningful only after the handshake's message
14
+ * patterns are exhausted (alongside split()). Exposed so a caller can
15
+ * derive ADDITIONAL independent key material (e.g. secretstream framing
16
+ * keys) via its own domain-separated HKDF label - see
17
+ * crypto/secretstream.ts's deriveTransportKey. This is standard KDF
18
+ * practice (TLS 1.3's key schedule does the same): reusing one secret
19
+ * for multiple purposes via distinct labels is safe; Split()'s own
20
+ * derivation (empty info) and a labeled derivation never collide.
21
+ */
22
+ getChainingKey(): Uint8Array;
23
+ encryptAndHash(plaintext: Uint8Array): Uint8Array;
24
+ decryptAndHash(ciphertext: Uint8Array): Uint8Array;
25
+ hasKey(): boolean;
26
+ /** Only valid once every message pattern has been processed. */
27
+ split(): [CipherState, CipherState];
28
+ }
@@ -0,0 +1,20 @@
1
+ import type { X25519KeyPair } from './primitives';
2
+ import { HandshakeState } from './noise/handshake-state';
3
+ export interface PairingInitiatorOptions {
4
+ /** The phone's own static identity keypair. */
5
+ localStatic: X25519KeyPair;
6
+ /** The desktop's static public key, read from the QR payload. */
7
+ remoteStatic: Uint8Array;
8
+ /** The 32-byte single-use pairing token from the QR payload, used as the Noise PSK. */
9
+ pairingToken: Uint8Array;
10
+ protocolVersion?: string;
11
+ }
12
+ export declare function createPairingInitiatorHandshake(options: PairingInitiatorOptions): HandshakeState;
13
+ export interface PairingResponderOptions {
14
+ /** The desktop's own static identity keypair (the one published in the QR). */
15
+ localStatic: X25519KeyPair;
16
+ /** The same 32-byte single-use pairing token minted for this QR. */
17
+ pairingToken: Uint8Array;
18
+ protocolVersion?: string;
19
+ }
20
+ export declare function createPairingResponderHandshake(options: PairingResponderOptions): HandshakeState;
@@ -0,0 +1,53 @@
1
+ import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
2
+ export declare const X25519_KEY_LENGTH = 32;
3
+ export declare const ED25519_KEY_LENGTH = 32;
4
+ export declare const ED25519_SIGNATURE_LENGTH = 64;
5
+ export declare const HASH_LENGTH = 32;
6
+ export declare const AEAD_KEY_LENGTH = 32;
7
+ export declare const CHACHA_NONCE_LENGTH = 12;
8
+ export declare const XCHACHA_NONCE_LENGTH = 24;
9
+ export declare const AEAD_TAG_LENGTH = 16;
10
+ export declare function randomBytes(length: number): Uint8Array;
11
+ export { hexToBytes, bytesToHex };
12
+ export interface X25519KeyPair {
13
+ secretKey: Uint8Array;
14
+ publicKey: Uint8Array;
15
+ }
16
+ export declare function generateX25519KeyPair(): X25519KeyPair;
17
+ export declare function x25519PublicKeyFrom(secretKey: Uint8Array): Uint8Array;
18
+ /** Diffie-Hellman: DH(localSecretKey, remotePublicKey). Throws on a low-order/invalid peer key. */
19
+ export declare function x25519SharedSecret(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array;
20
+ export interface Ed25519KeyPair {
21
+ secretKey: Uint8Array;
22
+ publicKey: Uint8Array;
23
+ }
24
+ export declare function generateEd25519KeyPair(): Ed25519KeyPair;
25
+ export declare function ed25519Sign(message: Uint8Array, secretKey: Uint8Array): Uint8Array;
26
+ export declare function ed25519Verify(signature: Uint8Array, message: Uint8Array, publicKey: Uint8Array): boolean;
27
+ export declare function hashBlake2s(data: Uint8Array): Uint8Array;
28
+ /**
29
+ * Noise's HKDF(chaining_key, input_key_material, num_outputs): HKDF-Extract
30
+ * with salt=chaining_key, ikm=input_key_material, then HKDF-Expand with an
31
+ * empty info, chunked into num_outputs HASH_LENGTH-byte blocks. This is
32
+ * exactly RFC 5869 HKDF with a zero-length info string, so the noble
33
+ * convenience function (extract+expand combined) is used directly rather
34
+ * than hand-rolling the counter loop.
35
+ */
36
+ export declare function noiseHkdf(chainingKey: Uint8Array, inputKeyMaterial: Uint8Array, numOutputs: 2 | 3): Uint8Array[];
37
+ /**
38
+ * Independent, domain-separated key derivation from a Noise chaining key,
39
+ * for material that is NOT part of the Noise handshake itself (e.g. the
40
+ * secretstream framing keys derived once a session's split() has run).
41
+ * Reusing one secret for multiple purposes via distinct labels is safe
42
+ * KDF practice as long as every purpose gets its own label; this is a
43
+ * plain HKDF-Expand-style derivation (salt=chainingKey, ikm=empty,
44
+ * info=label), independent of noiseHkdf's own empty-info convention.
45
+ */
46
+ export declare function deriveLabeledKey(chainingKey: Uint8Array, label: string, length: number): Uint8Array;
47
+ export declare function aeadEncrypt(key: Uint8Array, counter: bigint, associatedData: Uint8Array, plaintext: Uint8Array): Uint8Array;
48
+ export declare function aeadDecrypt(key: Uint8Array, counter: bigint, associatedData: Uint8Array, ciphertext: Uint8Array): Uint8Array;
49
+ /** XChaCha20-Poly1305 with a 24-byte nonce - used by the secretstream framing, not the Noise handshake. */
50
+ export declare function xaeadEncrypt(key: Uint8Array, nonce: Uint8Array, associatedData: Uint8Array, plaintext: Uint8Array): Uint8Array;
51
+ export declare function xaeadDecrypt(key: Uint8Array, nonce: Uint8Array, associatedData: Uint8Array, ciphertext: Uint8Array): Uint8Array;
52
+ export declare function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean;
53
+ export declare function concatBytes(...arrays: Uint8Array[]): Uint8Array;
@@ -0,0 +1,7 @@
1
+ export interface ShortAuthenticationString {
2
+ /** A 6-digit code, e.g. "042917". */
3
+ digits: string;
4
+ /** 5 emoji from a fixed 64-entry table. */
5
+ emoji: string[];
6
+ }
7
+ export declare function deriveShortAuthenticationString(handshakeHash: Uint8Array): ShortAuthenticationString;
@@ -0,0 +1,39 @@
1
+ export declare const FrameTag: {
2
+ readonly Message: 0;
3
+ readonly Rekey: 1;
4
+ readonly Final: 2;
5
+ };
6
+ export type FrameTag = (typeof FrameTag)[keyof typeof FrameTag];
7
+ export declare class SecretstreamState {
8
+ private key;
9
+ private readonly nonceHeader;
10
+ private counter;
11
+ constructor(key: Uint8Array, nonceHeader: Uint8Array);
12
+ private nonceForCounter;
13
+ /** Encrypts one frame and advances this direction's counter. */
14
+ seal(plaintext: Uint8Array, tag?: FrameTag): Uint8Array;
15
+ /**
16
+ * Decrypts one frame and advances this direction's counter. Throws if
17
+ * the frame does not authenticate against the counter this instance
18
+ * currently expects - which is exactly what happens if a frame was
19
+ * replayed, reordered, or the stream was truncated and resumed wrong.
20
+ */
21
+ open(frame: Uint8Array): {
22
+ tag: FrameTag;
23
+ plaintext: Uint8Array;
24
+ };
25
+ /** Rotates this direction's key without a full Noise re-handshake, matching libsodium's REKEY tag. */
26
+ rekey(): void;
27
+ }
28
+ export interface SecretstreamDirectionPair {
29
+ send: SecretstreamState;
30
+ receive: SecretstreamState;
31
+ }
32
+ /**
33
+ * Derives both directions' secretstream state from a completed Noise
34
+ * session's chaining key (HandshakeState.getChainingKey(), valid once
35
+ * split() has run). Both peers call this with the SAME chaining key and
36
+ * opposite `isInitiator` values, so `initiator.send` pairs with
37
+ * `responder.receive` and vice versa.
38
+ */
39
+ export declare function deriveSecretstreamPair(chainingKey: Uint8Array, isInitiator: boolean): SecretstreamDirectionPair;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The event contract skeleton the phone consumes. Phase 1 establishes the
3
+ * three event FAMILIES (transcript, board, activity) and their envelope
4
+ * shape; Phase 2 (data feeds & interactive control) is what actually maps
5
+ * desktop internals - SessionManager's `data`/`activity` events,
6
+ * transcript-service output, repository/DiffService mutations - onto
7
+ * these envelopes. Keeping `payload` as JsonValue here rather than a
8
+ * fully-typed shape per event avoids guessing at Phase 2's real mapping
9
+ * before that integration exists.
10
+ */
11
+ import type { JsonValue } from '../wire/messages';
12
+ export interface TranscriptEvent {
13
+ kind: 'transcript';
14
+ sessionId: string;
15
+ taskId: string;
16
+ payload: JsonValue;
17
+ }
18
+ export interface BoardEvent {
19
+ kind: 'board';
20
+ taskId: string;
21
+ payload: JsonValue;
22
+ }
23
+ export interface ActivityEvent {
24
+ kind: 'activity';
25
+ sessionId: string;
26
+ taskId: string;
27
+ payload: JsonValue;
28
+ }
29
+ export type BridgeEvent = TranscriptEvent | BoardEvent | ActivityEvent;