dev/2026-04-08 #1

Merged
hzhang merged 29 commits from dev/2026-04-08 into main 2026-04-13 09:34:22 +00:00
Showing only changes of commit 83f6195c1f - Show all commits

View File

@@ -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<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" {
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<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> {
await this.options.store.save(this.registry.clients.values());
}