Compare commits

..

5 Commits

Author SHA1 Message Date
nav
ccdf167daf feat: add crypto module (Ed25519 key generation, sign, verify)
Moves verifySignature, signMessage, generateKeyPair and utility functions
from Yonexus.Client into Protocol so Server no longer depends on Client
at build time or runtime.
2026-04-16 10:36:55 +00:00
h z
d2a16bcb02 Merge pull request 'dev/2026-04-08' (#1) from dev/2026-04-08 into main
Reviewed-on: #1
2026-04-13 09:33:41 +00:00
nav
2611304084 chore: align protocol validation tooling 2026-04-09 05:03:03 +00:00
nav
8744a771a2 Tighten protocol typings for strict consumers 2026-04-09 04:37:59 +00:00
nav
a7e1a9c210 test(protocol): add codec coverage 2026-04-09 00:36:37 +00:00
8 changed files with 2122 additions and 226 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
*.log

1826
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},

View File

@@ -128,7 +128,7 @@ export function decodeBuiltin(raw: string): BuiltinEnvelope {
throw new CodecError("Missing or invalid 'type' field in envelope");
}
return envelope as BuiltinEnvelope;
return envelope as unknown as BuiltinEnvelope;
} catch (cause) {
if (cause instanceof CodecError) {
throw cause;

49
src/crypto.ts Normal file
View File

@@ -0,0 +1,49 @@
import { randomBytes, generateKeyPair as _generateKeyPair, sign, verify } from "node:crypto";
export interface KeyPair {
readonly privateKey: string;
readonly publicKey: string;
readonly algorithm: "Ed25519";
}
export async function generateKeyPair(): Promise<KeyPair> {
const { publicKey, privateKey } = await new Promise<{ publicKey: string; privateKey: string }>((resolve, reject) => {
_generateKeyPair(
"ed25519",
{
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
},
(err, pubKey, privKey) => (err ? reject(err) : resolve({ publicKey: pubKey, privateKey: privKey }))
);
});
return { privateKey, publicKey, algorithm: "Ed25519" };
}
export async function signMessage(privateKeyPem: string, message: Buffer | string): Promise<string> {
return sign(null, typeof message === "string" ? Buffer.from(message) : message, privateKeyPem).toString("base64");
}
export async function verifySignature(publicKeyPem: string, message: Buffer | string, signature: string): Promise<boolean> {
try {
return verify(null, typeof message === "string" ? Buffer.from(message) : message, publicKeyPem, Buffer.from(signature, "base64"));
} catch {
return false;
}
}
export function generatePairingCode(): string {
const bytes = randomBytes(8);
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 12; i++) code += chars[bytes[i % bytes.length] % chars.length];
return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`;
}
export function generateSecret(): string {
return randomBytes(32).toString("base64url");
}
export function generateNonce(): string {
return randomBytes(18).toString("base64url").slice(0, 24);
}

View File

@@ -2,3 +2,4 @@ export * from "./types.js";
export * from "./codec.js";
export * from "./errors.js";
export * from "./auth.js";
export * from "./crypto.js";

View File

@@ -22,7 +22,7 @@ export type BuiltinMessageType = (typeof builtinMessageTypes)[number];
export interface BuiltinEnvelope<
TType extends BuiltinMessageType = BuiltinMessageType,
TPayload extends Record<string, unknown> = Record<string, unknown>
TPayload = Record<string, unknown>
> {
type: TType;
requestId?: string;
@@ -30,7 +30,7 @@ export interface BuiltinEnvelope<
payload?: TPayload;
}
export interface HelloPayload {
export interface HelloPayload extends Record<string, unknown> {
identifier: string;
hasSecret: boolean;
hasKeyPair: boolean;
@@ -44,14 +44,14 @@ export type HelloAckNextAction =
| "rejected"
| "waiting_pair_confirm";
export interface HelloAckPayload {
export interface HelloAckPayload extends Record<string, unknown> {
identifier: string;
nextAction: HelloAckNextAction;
}
export type AdminNotificationStatus = "sent" | "failed";
export interface PairRequestPayload {
export interface PairRequestPayload extends Record<string, unknown> {
identifier: string;
expiresAt: number;
ttlSeconds: number;
@@ -59,12 +59,12 @@ export interface PairRequestPayload {
codeDelivery: "out_of_band";
}
export interface PairConfirmPayload {
export interface PairConfirmPayload extends Record<string, unknown> {
identifier: string;
pairingCode: string;
}
export interface PairSuccessPayload {
export interface PairSuccessPayload extends Record<string, unknown> {
identifier: string;
secret: string;
pairedAt: number;
@@ -77,12 +77,12 @@ export type PairFailedReason =
| "admin_notification_failed"
| "internal_error";
export interface PairFailedPayload {
export interface PairFailedPayload extends Record<string, unknown> {
identifier: string;
reason: PairFailedReason;
}
export interface AuthRequestPayload {
export interface AuthRequestPayload extends Record<string, unknown> {
identifier: string;
nonce: string;
proofTimestamp: number;
@@ -90,7 +90,7 @@ export interface AuthRequestPayload {
publicKey?: string;
}
export interface AuthSuccessPayload {
export interface AuthSuccessPayload extends Record<string, unknown> {
identifier: string;
authenticatedAt: number;
status: "online";
@@ -107,33 +107,33 @@ export type AuthFailedReason =
| "rate_limited"
| "re_pair_required";
export interface AuthFailedPayload {
export interface AuthFailedPayload extends Record<string, unknown> {
identifier: string;
reason: AuthFailedReason;
}
export interface RePairRequiredPayload {
export interface RePairRequiredPayload extends Record<string, unknown> {
identifier: string;
reason: "nonce_collision" | "rate_limited" | "trust_revoked";
}
export interface HeartbeatPayload {
export interface HeartbeatPayload extends Record<string, unknown> {
identifier: string;
status: "alive";
}
export interface HeartbeatAckPayload {
export interface HeartbeatAckPayload extends Record<string, unknown> {
identifier: string;
status: "online" | "unstable" | "offline";
}
export interface StatusUpdatePayload {
export interface StatusUpdatePayload extends Record<string, unknown> {
identifier: string;
status: "online" | "unstable" | "offline";
reason: string;
}
export interface DisconnectNoticePayload {
export interface DisconnectNoticePayload extends Record<string, unknown> {
identifier: string;
reason: string;
}
@@ -152,7 +152,7 @@ export type ProtocolErrorCode =
| "CLIENT_OFFLINE"
| "INTERNAL_ERROR";
export interface ErrorPayload {
export interface ErrorPayload extends Record<string, unknown> {
code: ProtocolErrorCode;
message?: string;
details?: Record<string, unknown>;

View File

@@ -1,237 +1,252 @@
:
import { describe, it, expect, beforeEach } from "vitest";
import { describe, expect, it } from "vitest";
import {
encodeBuiltin,
buildAuthFailed,
buildAuthRequest,
buildAuthSuccess,
buildDisconnectNotice,
buildError,
buildHeartbeat,
buildHeartbeatAck,
buildHello,
buildHelloAck,
buildPairConfirm,
buildPairFailed,
buildPairRequest,
buildPairSuccess,
buildRePairRequired,
buildStatusUpdate,
CodecError,
decodeBuiltin,
parseRuleMessage,
encodeRuleMessage,
parseRewrittenRuleMessage,
encodeBuiltin,
encodeRewrittenRuleMessage,
encodeRuleMessage,
isBuiltinMessage,
CodecError
} from "../src/codec.js";
import type { BuiltinEnvelope, HelloPayload, HelloAckPayload } from "../src/types.js";
parseRewrittenRuleMessage,
parseRuleMessage
} from "../src/index.js";
describe("Protocol Codec", () => {
describe("encodeBuiltin / decodeBuiltin", () => {
it("should encode and decode a hello message", () => {
const envelope: BuiltinEnvelope<"hello", HelloPayload> = {
type: "hello",
requestId: "req_001",
timestamp: 1711886400,
describe("Protocol Codec - encodeBuiltin / decodeBuiltin", () => {
it("encodes and decodes a hello envelope", () => {
const envelope = buildHello(
{
identifier: "client-a",
hasSecret: true,
hasKeyPair: true,
publicKey: "pk-test",
protocolVersion: "1"
},
{ requestId: "req-001" }
);
const encoded = encodeBuiltin(envelope);
expect(encoded.startsWith("builtin::")).toBe(true);
const decoded = decodeBuiltin(encoded);
expect(decoded.type).toBe("hello");
expect(decoded.requestId).toBe("req-001");
expect(decoded.payload).toMatchObject({
identifier: "client-a",
hasSecret: true,
hasKeyPair: true,
publicKey: "pk-test",
protocolVersion: "1"
});
});
it("encodes and decodes all builtin message types", () => {
const testCases = [
{
builder: buildHelloAck,
payload: { identifier: "client-a", nextAction: "pair_required" as const }
},
{
builder: buildPairRequest,
payload: {
identifier: "client-a",
hasSecret: true,
hasKeyPair: true,
publicKey: "pk_test",
protocolVersion: "1"
expiresAt: 1_710_000_000,
ttlSeconds: 300,
adminNotification: "sent" as const,
codeDelivery: "out_of_band" as const
}
};
const encoded = encodeBuiltin(envelope);
expect(encoded).toBe(
'builtin::{"type":"hello","requestId":"req_001","timestamp":1711886400,"payload":{"identifier":"client-a","hasSecret":true,"hasKeyPair":true,"publicKey":"pk_test","protocolVersion":"1"}}'
);
const decoded = decodeBuiltin(encoded);
expect(decoded).toEqual(envelope);
});
it("should encode and decode a hello_ack message", () => {
const envelope: BuiltinEnvelope<"hello_ack", HelloAckPayload> = {
type: "hello_ack",
requestId: "req_001",
timestamp: 1711886401,
},
{
builder: buildPairConfirm,
payload: { identifier: "client-a", pairingCode: "ABCD-1234-XYZ" }
},
{
builder: buildPairSuccess,
payload: { identifier: "client-a", secret: "secret-xyz", pairedAt: 1_710_000_000 }
},
{
builder: buildPairFailed,
payload: { identifier: "client-a", reason: "expired" as const }
},
{
builder: buildAuthRequest,
payload: {
identifier: "client-a",
nextAction: "pair_required"
nonce: "RANDOM24CHARACTERSTRINGX",
proofTimestamp: 1_710_000_000,
signature: "sig-base64",
publicKey: "pk-test"
}
};
},
{
builder: buildAuthSuccess,
payload: { identifier: "client-a", authenticatedAt: 1_710_000_000, status: "online" as const }
},
{
builder: buildAuthFailed,
payload: { identifier: "client-a", reason: "invalid_signature" as const }
},
{
builder: buildRePairRequired,
payload: { identifier: "client-a", reason: "nonce_collision" as const }
},
{
builder: buildHeartbeat,
payload: { identifier: "client-a", status: "alive" as const }
},
{
builder: buildHeartbeatAck,
payload: { identifier: "client-a", status: "online" as const }
},
{
builder: buildStatusUpdate,
payload: { identifier: "client-a", status: "unstable" as const, reason: "heartbeat_timeout_7m" }
},
{
builder: buildDisconnectNotice,
payload: { identifier: "client-a", reason: "heartbeat_timeout_11m" }
},
{
builder: buildError,
payload: { code: "MALFORMED_MESSAGE" as const, message: "Invalid JSON" }
}
];
for (const { builder, payload } of testCases) {
const envelope = builder(payload as never, { requestId: "test-req" });
const encoded = encodeBuiltin(envelope);
const decoded = decodeBuiltin(encoded);
expect(decoded).toEqual(envelope);
});
it("should handle envelope without optional fields", () => {
const envelope: BuiltinEnvelope = {
type: "heartbeat"
};
const encoded = encodeBuiltin(envelope);
expect(encoded).toBe('builtin::{"type":"heartbeat"}');
const decoded = decodeBuiltin(encoded);
expect(decoded).toEqual(envelope);
});
it("should throw CodecError for malformed builtin message", () => {
expect(() => decodeBuiltin("invalid")).toThrow(CodecError);
expect(() => decodeBuiltin("builtin::")).toThrow(CodecError);
expect(() => decodeBuiltin("builtin::invalid json")).toThrow(CodecError);
});
it("should throw CodecError for non-builtin message prefix", () => {
expect(() => decodeBuiltin("hello::{\"type\":\"test\"}")).toThrow(CodecError);
});
expect(decoded.type).toBe(envelope.type);
expect(decoded.requestId).toBe("test-req");
expect(decoded.payload).toMatchObject(payload);
}
});
describe("isBuiltinMessage", () => {
it("should return true for builtin messages", () => {
expect(isBuiltinMessage('builtin::{"type":"hello"}')).toBe(true);
expect(isBuiltinMessage("builtin::{}")).toBe(true);
});
it("should return false for non-builtin messages", () => {
expect(isBuiltinMessage("hello::world")).toBe(false);
expect(isBuiltinMessage("rule::content")).toBe(false);
expect(isBuiltinMessage("")).toBe(false);
});
it("throws CodecError for malformed messages", () => {
expect(() => decodeBuiltin("not-builtin::{")).toThrow(CodecError);
expect(() => decodeBuiltin("builtin::not-json")).toThrow(CodecError);
expect(() => decodeBuiltin("builtin::{}")).toThrow(CodecError); // missing type
expect(() => decodeBuiltin("no-delimiter")).toThrow(CodecError);
expect(() => decodeBuiltin("")).toThrow(CodecError);
});
describe("parseRuleMessage", () => {
it("should parse simple rule message", () => {
const result = parseRuleMessage("chat_sync::{\"body\":\"hello\"}");
expect(result).toEqual({
rule: "chat_sync",
content: '{"body":"hello"}'
});
});
it("should parse rule message with content containing ::", () => {
const result = parseRuleMessage("chat_sync::sender::extra::{\"body\":\"hello::world\"}");
expect(result).toEqual({
rule: "chat_sync",
content: 'sender::extra::{"body":"hello::world"}'
});
});
it("should throw CodecError for malformed rule message", () => {
expect(() => parseRuleMessage("invalid")).toThrow(CodecError);
expect(() => parseRuleMessage("")).toThrow(CodecError);
});
it("should reject builtin as rule identifier", () => {
expect(() => parseRuleMessage('builtin::{"type":"hello"}')).toThrow(CodecError);
});
});
describe("encodeRuleMessage", () => {
it("should encode rule message", () => {
const encoded = encodeRuleMessage("chat_sync", '{"body":"hello"}');
expect(encoded).toBe('chat_sync::{"body":"hello"}');
});
it("should reject builtin as rule", () => {
expect(() => encodeRuleMessage("builtin", "content")).toThrow(CodecError);
});
});
describe("parseRewrittenRuleMessage", () => {
it("should parse rewritten server-side message", () => {
const result = parseRewrittenRuleMessage("chat_sync::client-a::{\"body\":\"hello\"}");
expect(result).toEqual({
rule: "chat_sync",
sender: "client-a",
content: '{"body":"hello"}'
});
});
it("should handle content with :: delimiters", () => {
const result = parseRewrittenRuleMessage("chat_sync::client-a::extra::{\"body\":\"hello::world\"}");
expect(result).toEqual({
rule: "chat_sync",
sender: "client-a",
content: 'extra::{"body":"hello::world"}'
});
});
it("should throw CodecError for malformed rewritten message", () => {
expect(() => parseRewrittenRuleMessage("rule::content")).toThrow(CodecError);
expect(() => parseRewrittenRuleMessage("invalid")).toThrow(CodecError);
});
});
describe("encodeRewrittenRuleMessage", () => {
it("should encode rewritten message", () => {
const encoded = encodeRewrittenRuleMessage("chat_sync", "client-a", '{"body":"hello"}');
expect(encoded).toBe('chat_sync::client-a::{"body":"hello"}');
});
it("should reject builtin as rule", () => {
expect(() => encodeRewrittenRuleMessage("builtin", "sender", "content")).toThrow(CodecError);
});
it("throws CodecError for invalid envelope input", () => {
expect(() => encodeBuiltin(null as never)).toThrow(CodecError);
expect(() => encodeBuiltin({} as never)).toThrow(CodecError);
expect(() => encodeBuiltin({ type: 123 } as never)).toThrow(CodecError);
});
});
describe("Protocol Message Examples", () => {
describe("Hello Flow", () => {
it("should demonstrate complete hello flow", () => {
// Client sends hello
const hello: BuiltinEnvelope<"hello", HelloPayload> = {
type: "hello",
requestId: "req_001",
timestamp: Math.floor(Date.now() / 1000),
payload: {
identifier: "client-a",
hasSecret: false,
hasKeyPair: true,
publicKey: "ed25519_pk_test123",
protocolVersion: "1"
}
};
const helloWire = encodeBuiltin(hello);
expect(isBuiltinMessage(helloWire)).toBe(true);
// Server parses hello
const parsedHello = decodeBuiltin(helloWire);
expect(parsedHello.type).toBe("hello");
expect((parsedHello.payload as HelloPayload).identifier).toBe("client-a");
// Server responds with hello_ack
const helloAck: BuiltinEnvelope<"hello_ack", HelloAckPayload> = {
type: "hello_ack",
requestId: hello.requestId,
timestamp: Math.floor(Date.now() / 1000),
payload: {
identifier: "client-a",
nextAction: "pair_required"
}
};
const ackWire = encodeBuiltin(helloAck);
const parsedAck = decodeBuiltin(ackWire);
expect(parsedAck.type).toBe("hello_ack");
expect((parsedAck.payload as HelloAckPayload).nextAction).toBe("pair_required");
});
describe("Protocol Codec - Rule Message Parsing", () => {
it("parses rule messages correctly", () => {
const parsed = parseRuleMessage("chat_sync::{\"body\":\"hello\"}");
expect(parsed.ruleIdentifier).toBe("chat_sync");
expect(parsed.content).toBe('{"body":"hello"}');
});
describe("Rule Message Flow", () => {
it("should demonstrate client to server message", () => {
const message = encodeRuleMessage("chat_sync", '{"conversationId":"abc","body":"hello"}');
expect(message).toBe('chat_sync::{"conversationId":"abc","body":"hello"}');
it("handles content containing :: delimiters", () => {
const parsed = parseRuleMessage("rule::a::b::c");
expect(parsed.ruleIdentifier).toBe("rule");
expect(parsed.content).toBe("a::b::c");
});
// Server parses
const parsed = parseRuleMessage(message);
expect(parsed.rule).toBe("chat_sync");
expect(parsed.content).toBe('{"conversationId":"abc","body":"hello"}');
});
it("throws CodecError for invalid rule messages", () => {
expect(() => parseRuleMessage("no-delimiter")).toThrow(CodecError);
expect(() => parseRuleMessage(":")).toThrow(CodecError);
expect(() => parseRuleMessage("")).toThrow(CodecError);
expect(() => parseRuleMessage("::content")).toThrow(CodecError); // empty identifier
});
it("should demonstrate server-side rewriting", () => {
// Server rewrites incoming message
const rewritten = encodeRewrittenRuleMessage(
"chat_sync",
"client-a",
'{"conversationId":"abc","body":"hello"}'
);
expect(rewritten).toBe('chat_sync::client-a::{"conversationId":"abc","body":"hello"}');
it("throws CodecError for reserved builtin identifier", () => {
expect(() => parseRuleMessage("builtin::something")).toThrow(
/reserved/
);
});
// Server-side handler parses
const parsed = parseRewrittenRuleMessage(rewritten);
expect(parsed.rule).toBe("chat_sync");
expect(parsed.sender).toBe("client-a");
expect(parsed.content).toBe('{"conversationId":"abc","body":"hello"}');
});
it("throws CodecError for invalid rule identifier characters", () => {
expect(() => parseRuleMessage("rule with spaces::content")).toThrow(CodecError);
// Note: special chars in content are allowed - only the rule identifier is validated
expect(() => parseRuleMessage("rule::with::special!::chars")).not.toThrow(); // content can contain special chars
});
it("isBuiltinMessage identifies builtin messages", () => {
expect(isBuiltinMessage("builtin::{\"type\":\"hello\"}")).toBe(true);
expect(isBuiltinMessage("rule::content")).toBe(false);
expect(isBuiltinMessage("not-builtin::content")).toBe(false);
expect(isBuiltinMessage("")).toBe(false);
});
});
describe("Protocol Codec - Server Rewritten Messages", () => {
it("parses rewritten messages with sender identifier", () => {
const parsed = parseRewrittenRuleMessage("chat_sync::client-a::{\"body\":\"hello\"}");
expect(parsed.ruleIdentifier).toBe("chat_sync");
expect(parsed.senderIdentifier).toBe("client-a");
expect(parsed.messageContent).toBe('{"body":"hello"}');
expect(parsed.content).toBe("client-a::{\"body\":\"hello\"}");
});
it("handles complex content with multiple delimiters", () => {
const parsed = parseRewrittenRuleMessage("rule::sender::a::b::c");
expect(parsed.ruleIdentifier).toBe("rule");
expect(parsed.senderIdentifier).toBe("sender");
expect(parsed.messageContent).toBe("a::b::c");
});
it("throws CodecError for malformed rewritten messages", () => {
expect(() => parseRewrittenRuleMessage("no-delimiters")).toThrow(CodecError);
expect(() => parseRewrittenRuleMessage("rule::only-one")).toThrow(/sender/);
expect(() => parseRewrittenRuleMessage("::sender::content")).toThrow(/Empty/);
// Note: "rule:::content" has empty sender which is caught by "missing sender identifier delimiter" check
expect(() => parseRewrittenRuleMessage("rule:::content")).toThrow(/sender/);
});
});
describe("Protocol Codec - encodeRuleMessage", () => {
it("encodes rule messages correctly", () => {
const encoded = encodeRuleMessage("chat_sync", '{"body":"hello"}');
expect(encoded).toBe('chat_sync::{"body":"hello"}');
});
it("throws CodecError for invalid rule identifiers", () => {
expect(() => encodeRuleMessage("", "content")).toThrow(CodecError);
expect(() => encodeRuleMessage("builtin", "content")).toThrow(/reserved/);
expect(() => encodeRuleMessage("rule with spaces", "content")).toThrow(CodecError);
expect(() => encodeRuleMessage("rule!", "content")).toThrow(CodecError);
});
it("throws CodecError for non-string content", () => {
expect(() => encodeRuleMessage("rule", null as never)).toThrow(CodecError);
});
});
describe("Protocol Codec - encodeRewrittenRuleMessage", () => {
it("encodes rewritten messages correctly", () => {
const encoded = encodeRewrittenRuleMessage("chat_sync", "client-a", '{"body":"hello"}');
expect(encoded).toBe('chat_sync::client-a::{"body":"hello"}');
});
it("throws CodecError for reserved rule identifier", () => {
expect(() => encodeRewrittenRuleMessage("builtin", "sender", "content")).toThrow(/reserved/);
});
it("throws CodecError for empty identifiers", () => {
expect(() => encodeRewrittenRuleMessage("", "sender", "content")).toThrow(CodecError);
expect(() => encodeRewrittenRuleMessage("rule", "", "content")).toThrow(CodecError);
});
});