diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index ccab68a..a3db311 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -1,10 +1,13 @@ import { YONEXUS_PROTOCOL_VERSION, + buildAuthRequest, buildHello, buildPairConfirm, + createAuthRequestSigningInput, decodeBuiltin, encodeBuiltin, isBuiltinMessage, + type AuthFailedPayload, type HelloAckPayload, type PairFailedPayload, type PairRequestPayload, @@ -20,6 +23,7 @@ import { YonexusClientState, YonexusClientStateStore } from "./state.js"; +import { generateNonce, signMessage } from "../crypto/keypair.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js"; export type YonexusClientPhase = @@ -134,9 +138,28 @@ export class YonexusClientRuntime { } if (envelope.type === "auth_success") { + this.options.transport.markAuthenticated(); + this.clientState = { + ...this.clientState, + authenticatedAt: this.now(), + updatedAt: this.now() + }; + await this.options.stateStore.save(this.clientState); this.phase = "authenticated"; return; } + + if (envelope.type === "auth_failed") { + this.handleAuthFailed(envelope as TypedBuiltinEnvelope<"auth_failed">); + return; + } + + if (envelope.type === "re_pair_required") { + this.pendingPairing = undefined; + this.lastPairingFailure = "re_pair_required"; + this.phase = "pair_required"; + return; + } } handleTransportStateChange(state: ClientConnectionState): void { @@ -145,7 +168,7 @@ export class YonexusClientRuntime { } if (state === "disconnected") { - this.phase = "idle"; + this.phase = hasClientSecret(this.clientState) ? "auth_required" : "idle"; } } @@ -202,6 +225,7 @@ export class YonexusClientRuntime { break; case "auth_required": this.phase = "auth_required"; + void this.sendAuthRequest(); break; default: this.phase = "idle"; @@ -240,6 +264,7 @@ export class YonexusClientRuntime { this.pendingPairing = undefined; this.lastPairingFailure = undefined; this.phase = "auth_required"; + await this.sendAuthRequest(); } private handlePairFailed(envelope: TypedBuiltinEnvelope<"pair_failed">): void { @@ -257,6 +282,54 @@ export class YonexusClientRuntime { this.phase = "waiting_pair_confirm"; } + + private handleAuthFailed(envelope: TypedBuiltinEnvelope<"auth_failed">): void { + const payload = envelope.payload as AuthFailedPayload | undefined; + if (!payload) { + return; + } + + this.lastPairingFailure = payload.reason; + this.phase = payload.reason === "re_pair_required" ? "pair_required" : "auth_required"; + } + + private async sendAuthRequest(): Promise { + if (!this.options.transport.isConnected) { + return; + } + + if (!this.clientState.secret || !this.clientState.privateKey) { + this.phase = "pair_required"; + return; + } + + const proofTimestamp = this.now(); + const nonce = generateNonce(); + const signature = await signMessage( + this.clientState.privateKey, + createAuthRequestSigningInput({ + secret: this.clientState.secret, + nonce, + proofTimestamp + }) + ); + + this.options.transport.markAuthenticating(); + this.options.transport.send( + encodeBuiltin( + buildAuthRequest( + { + identifier: this.options.config.identifier, + nonce, + proofTimestamp, + signature, + publicKey: this.clientState.publicKey + }, + { requestId: `auth_${proofTimestamp}_${nonce}`, timestamp: proofTimestamp } + ) + ) + ); + } } export function createYonexusClientRuntime( diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index f227d71..fac8eba 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -18,6 +18,8 @@ export interface ClientTransport { connect(): Promise; disconnect(): void; send(message: string): boolean; + markAuthenticated(): void; + markAuthenticating(): void; } export type ClientMessageHandler = (message: string) => void;