feat: validate yonexus auth requests
This commit is contained in:
@@ -1,16 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
|
AUTH_ATTEMPT_WINDOW_SECONDS,
|
||||||
|
AUTH_MAX_ATTEMPTS_PER_WINDOW,
|
||||||
|
AUTH_RECENT_NONCE_WINDOW_SIZE,
|
||||||
type BuiltinEnvelope,
|
type BuiltinEnvelope,
|
||||||
type HelloPayload,
|
type HelloPayload,
|
||||||
type PairConfirmPayload,
|
type PairConfirmPayload,
|
||||||
YONEXUS_PROTOCOL_VERSION,
|
YONEXUS_PROTOCOL_VERSION,
|
||||||
|
buildAuthFailed,
|
||||||
|
buildAuthSuccess,
|
||||||
buildError,
|
buildError,
|
||||||
buildHelloAck,
|
buildHelloAck,
|
||||||
buildPairFailed,
|
buildPairFailed,
|
||||||
buildPairRequest,
|
buildPairRequest,
|
||||||
buildPairSuccess,
|
buildPairSuccess,
|
||||||
|
buildRePairRequired,
|
||||||
decodeBuiltin,
|
decodeBuiltin,
|
||||||
encodeBuiltin,
|
encodeBuiltin,
|
||||||
isBuiltinMessage
|
extractAuthRequestSigningInput,
|
||||||
|
isBuiltinMessage,
|
||||||
|
isTimestampFresh,
|
||||||
|
isValidAuthNonce,
|
||||||
|
type AuthRequestPayload
|
||||||
} from "../../../Yonexus.Protocol/src/index.js";
|
} from "../../../Yonexus.Protocol/src/index.js";
|
||||||
import type { YonexusServerConfig } from "./config.js";
|
import type { YonexusServerConfig } from "./config.js";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +31,7 @@ import {
|
|||||||
type ClientRecord,
|
type ClientRecord,
|
||||||
type ServerRegistry
|
type ServerRegistry
|
||||||
} from "./persistence.js";
|
} from "./persistence.js";
|
||||||
|
import { verifySignature } from "../../../Yonexus.Client/plugin/crypto/keypair.js";
|
||||||
import type { YonexusServerStore } from "./store.js";
|
import type { YonexusServerStore } from "./store.js";
|
||||||
import { type ClientConnection, type ServerTransport } from "./transport.js";
|
import { type ClientConnection, type ServerTransport } from "./transport.js";
|
||||||
import { createPairingService, type PairingService } from "../services/pairing.js";
|
import { createPairingService, type PairingService } from "../services/pairing.js";
|
||||||
@@ -137,6 +148,14 @@ export class YonexusServerRuntime {
|
|||||||
connection,
|
connection,
|
||||||
envelope as BuiltinEnvelope<"pair_confirm", PairConfirmPayload>
|
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();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleAuthRequest(
|
||||||
|
connection: ClientConnection,
|
||||||
|
envelope: BuiltinEnvelope<"auth_request", AuthRequestPayload>
|
||||||
|
): Promise<void> {
|
||||||
|
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" {
|
private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" {
|
||||||
if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) {
|
if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) {
|
||||||
return "waiting_pair_confirm";
|
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<void> {
|
||||||
|
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<void> {
|
private async persist(): Promise<void> {
|
||||||
await this.options.store.save(this.registry.clients.values());
|
await this.options.store.save(this.registry.clients.values());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user