From 83f6195c1f1520ff5c96953375bd9d3d01ab2440 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 22:04:49 +0000 Subject: [PATCH] feat: validate yonexus auth requests --- plugin/core/runtime.ts | 226 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index aa6873c..fd1d501 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -1,16 +1,26 @@ import { + AUTH_ATTEMPT_WINDOW_SECONDS, + AUTH_MAX_ATTEMPTS_PER_WINDOW, + AUTH_RECENT_NONCE_WINDOW_SIZE, type BuiltinEnvelope, type HelloPayload, type PairConfirmPayload, YONEXUS_PROTOCOL_VERSION, + buildAuthFailed, + buildAuthSuccess, buildError, buildHelloAck, buildPairFailed, buildPairRequest, buildPairSuccess, + buildRePairRequired, decodeBuiltin, encodeBuiltin, - isBuiltinMessage + extractAuthRequestSigningInput, + isBuiltinMessage, + isTimestampFresh, + isValidAuthNonce, + type AuthRequestPayload } from "../../../Yonexus.Protocol/src/index.js"; import type { YonexusServerConfig } from "./config.js"; import { @@ -21,6 +31,7 @@ import { type ClientRecord, type ServerRegistry } from "./persistence.js"; +import { verifySignature } from "../../../Yonexus.Client/plugin/crypto/keypair.js"; import type { YonexusServerStore } from "./store.js"; import { type ClientConnection, type ServerTransport } from "./transport.js"; import { createPairingService, type PairingService } from "../services/pairing.js"; @@ -137,6 +148,14 @@ export class YonexusServerRuntime { connection, envelope as BuiltinEnvelope<"pair_confirm", PairConfirmPayload> ); + return; + } + + if (envelope.type === "auth_request") { + await this.handleAuthRequest( + connection, + envelope as BuiltinEnvelope<"auth_request", AuthRequestPayload> + ); } } @@ -313,6 +332,179 @@ export class YonexusServerRuntime { await this.persist(); } + private async handleAuthRequest( + connection: ClientConnection, + envelope: BuiltinEnvelope<"auth_request", AuthRequestPayload> + ): Promise { + const payload = envelope.payload; + if (!payload) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message: "auth_request payload is required" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const identifier = payload.identifier?.trim(); + if (!identifier || !this.options.config.followerIdentifiers.includes(identifier)) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier: identifier || "unknown", + reason: "unknown_identifier" + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const record = this.ensureClientRecord(identifier); + const session = this.registry.sessions.get(identifier); + if (!session || !canAuthenticate(record) || !record.secret || !record.publicKey) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: "not_paired" + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const now = this.now(); + record.recentHandshakeAttempts = record.recentHandshakeAttempts.filter( + (timestamp) => now - timestamp < AUTH_ATTEMPT_WINDOW_SECONDS + ); + record.recentHandshakeAttempts.push(now); + + if (record.recentHandshakeAttempts.length > AUTH_MAX_ATTEMPTS_PER_WINDOW) { + await this.triggerRePairRequired(connection, record, envelope.requestId, "rate_limited"); + return; + } + + if (!isValidAuthNonce(payload.nonce)) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: "invalid_signature" + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + return; + } + + const freshness = isTimestampFresh(payload.proofTimestamp, now); + if (!freshness.ok) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: freshness.reason + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + return; + } + + const hasNonceCollision = record.recentNonces.some((entry) => entry.nonce === payload.nonce); + if (hasNonceCollision) { + await this.triggerRePairRequired(connection, record, envelope.requestId, "nonce_collision"); + return; + } + + const publicKey = payload.publicKey?.trim() || session.publicKey || record.publicKey; + if (!publicKey || publicKey !== record.publicKey) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: "invalid_signature" + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + return; + } + + const isValidSignature = await verifySignature( + publicKey, + extractAuthRequestSigningInput(payload, record.secret), + payload.signature + ); + + if (!isValidSignature) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: "invalid_signature" + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + return; + } + + record.recentNonces = [...record.recentNonces, { nonce: payload.nonce, timestamp: now }].slice( + -AUTH_RECENT_NONCE_WINDOW_SIZE + ); + record.lastAuthenticatedAt = now; + record.lastHeartbeatAt = now; + record.status = "online"; + record.updatedAt = now; + + if (session) { + session.isAuthenticated = true; + session.lastActivityAt = now; + session.publicKey = publicKey; + } + this.options.transport.markAuthenticated(identifier); + this.options.transport.sendToConnection( + { ...connection, identifier }, + encodeBuiltin( + buildAuthSuccess( + { + identifier, + authenticatedAt: now, + status: "online" + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + + await this.persist(); + } + private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" { if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { return "waiting_pair_confirm"; @@ -398,6 +590,38 @@ export class YonexusServerRuntime { } } + private async triggerRePairRequired( + connection: ClientConnection, + record: ClientRecord, + requestId: string | undefined, + reason: "nonce_collision" | "rate_limited" + ): Promise { + record.secret = undefined; + record.pairingStatus = "revoked"; + record.pairingCode = undefined; + record.pairingExpiresAt = undefined; + record.pairingNotifyStatus = undefined; + record.recentNonces = []; + record.recentHandshakeAttempts = []; + record.status = "offline"; + record.updatedAt = this.now(); + + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildRePairRequired( + { + identifier: record.identifier, + reason + }, + { requestId, timestamp: this.now() } + ) + ) + ); + + await this.persist(); + } + private async persist(): Promise { await this.options.store.save(this.registry.clients.values()); }