Compare commits

...

8 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
nav
7e69a08443 feat: add shared auth proof helpers 2026-04-08 22:04:37 +00:00
nav
fbe1457ab6 Add protocol codec tests and vitest setup 2026-04-08 21:04:55 +00:00
nav
4d8c787dbc feat(protocol): add shared protocol error helpers 2026-04-08 20:33:25 +00:00
12 changed files with 2365 additions and 17 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

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "yonexus-protocol",
"version": "0.1.0",
"description": "Yonexus shared communication protocol",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^5.0.0",
"vitest": "^1.0.0"
},
"files": [
"dist/",
"src/",
"PROTOCOL.md"
]
}

73
src/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { AuthRequestPayload } from "./types.js";
export const AUTH_NONCE_LENGTH = 24;
export const AUTH_MAX_TIMESTAMP_DRIFT_SECONDS = 10;
export const AUTH_MAX_ATTEMPTS_PER_WINDOW = 10;
export const AUTH_ATTEMPT_WINDOW_SECONDS = 10;
export const AUTH_RECENT_NONCE_WINDOW_SIZE = 10;
export interface AuthProofMaterial {
secret: string;
nonce: string;
timestamp: number;
}
export function createAuthProofMaterial(input: AuthProofMaterial): AuthProofMaterial {
return {
secret: input.secret,
nonce: input.nonce,
timestamp: input.timestamp
};
}
export function serializeAuthProofMaterial(input: AuthProofMaterial): string {
const material = createAuthProofMaterial(input);
return JSON.stringify({
secret: material.secret,
nonce: material.nonce,
timestamp: material.timestamp
});
}
export function isValidAuthNonce(nonce: string): boolean {
return /^[A-Za-z0-9_-]{24}$/.test(nonce);
}
export function isTimestampFresh(
proofTimestamp: number,
now: number,
maxDriftSeconds: number = AUTH_MAX_TIMESTAMP_DRIFT_SECONDS
): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } {
const drift = proofTimestamp - now;
if (Math.abs(drift) < maxDriftSeconds) {
return { ok: true };
}
return {
ok: false,
reason: drift < 0 ? "stale_timestamp" : "future_timestamp"
};
}
export function createAuthRequestSigningInput(input: {
secret: string;
nonce: string;
proofTimestamp: number;
}): string {
return serializeAuthProofMaterial({
secret: input.secret,
nonce: input.nonce,
timestamp: input.proofTimestamp
});
}
export function extractAuthRequestSigningInput(
payload: Pick<AuthRequestPayload, "nonce" | "proofTimestamp">,
secret: string
): string {
return createAuthRequestSigningInput({
secret,
nonce: payload.nonce,
proofTimestamp: payload.proofTimestamp
});
}

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);
}

93
src/errors.ts Normal file
View File

@@ -0,0 +1,93 @@
import type { BuiltinEnvelope, ErrorPayload, ProtocolErrorCode, TypedBuiltinEnvelope } from "./types.js";
export type ProtocolErrorCategory =
| "configuration"
| "protocol"
| "authentication"
| "pairing"
| "runtime";
export interface ProtocolErrorOptions {
message?: string;
details?: Record<string, unknown>;
cause?: unknown;
}
const protocolErrorCategories: Record<ProtocolErrorCode, ProtocolErrorCategory> = {
MALFORMED_MESSAGE: "protocol",
UNSUPPORTED_PROTOCOL_VERSION: "protocol",
IDENTIFIER_NOT_ALLOWED: "protocol",
PAIRING_REQUIRED: "pairing",
PAIRING_EXPIRED: "pairing",
ADMIN_NOTIFICATION_FAILED: "pairing",
AUTH_FAILED: "authentication",
NONCE_COLLISION: "authentication",
RATE_LIMITED: "authentication",
RE_PAIR_REQUIRED: "authentication",
CLIENT_OFFLINE: "runtime",
INTERNAL_ERROR: "runtime"
};
export function getProtocolErrorCategory(code: ProtocolErrorCode): ProtocolErrorCategory {
return protocolErrorCategories[code];
}
export class YonexusProtocolError extends Error {
readonly code: ProtocolErrorCode;
readonly category: ProtocolErrorCategory;
readonly details?: Record<string, unknown>;
override readonly cause?: unknown;
constructor(code: ProtocolErrorCode, options: ProtocolErrorOptions = {}) {
super(options.message ?? code);
this.name = "YonexusProtocolError";
this.code = code;
this.category = getProtocolErrorCategory(code);
this.details = options.details;
this.cause = options.cause;
}
toPayload(): ErrorPayload {
return {
code: this.code,
message: this.message,
details: this.details
};
}
toEnvelope(timestamp: number = Math.floor(Date.now() / 1000)): TypedBuiltinEnvelope<"error"> {
return {
type: "error",
timestamp,
payload: this.toPayload()
};
}
}
export function createProtocolError(
code: ProtocolErrorCode,
options: ProtocolErrorOptions = {}
): YonexusProtocolError {
return new YonexusProtocolError(code, options);
}
export function protocolErrorFromPayload(
payload: ErrorPayload,
cause?: unknown
): YonexusProtocolError {
return new YonexusProtocolError(payload.code, {
message: payload.message,
details: payload.details,
cause
});
}
export function isProtocolError(value: unknown): value is YonexusProtocolError {
return value instanceof YonexusProtocolError;
}
export function isProtocolErrorEnvelope(
envelope: BuiltinEnvelope
): envelope is TypedBuiltinEnvelope<"error"> {
return envelope.type === "error";
}

View File

@@ -1,2 +1,5 @@
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>;

252
tests/codec.test.ts Normal file
View File

@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import {
buildAuthFailed,
buildAuthRequest,
buildAuthSuccess,
buildDisconnectNotice,
buildError,
buildHeartbeat,
buildHeartbeatAck,
buildHello,
buildHelloAck,
buildPairConfirm,
buildPairFailed,
buildPairRequest,
buildPairSuccess,
buildRePairRequired,
buildStatusUpdate,
CodecError,
decodeBuiltin,
encodeBuiltin,
encodeRewrittenRuleMessage,
encodeRuleMessage,
isBuiltinMessage,
parseRewrittenRuleMessage,
parseRuleMessage
} from "../src/index.js";
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",
expiresAt: 1_710_000_000,
ttlSeconds: 300,
adminNotification: "sent" as const,
codeDelivery: "out_of_band" as const
}
},
{
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",
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.type).toBe(envelope.type);
expect(decoded.requestId).toBe("test-req");
expect(decoded.payload).toMatchObject(payload);
}
});
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);
});
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 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"}');
});
it("handles content containing :: delimiters", () => {
const parsed = parseRuleMessage("rule::a::b::c");
expect(parsed.ruleIdentifier).toBe("rule");
expect(parsed.content).toBe("a::b::c");
});
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("throws CodecError for reserved builtin identifier", () => {
expect(() => parseRuleMessage("builtin::something")).toThrow(
/reserved/
);
});
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);
});
});

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node"
}
});