fix: enforce secure control ui auth

This commit is contained in:
Peter Steinberger
2026-01-21 23:58:30 +00:00
parent b4776af38c
commit f76e3c1419
18 changed files with 294 additions and 48 deletions

View File

@@ -126,15 +126,27 @@ export class GatewayBrowserClient {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
const deviceIdentity = await loadOrCreateDeviceIdentity();
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
// Over plain HTTP, we skip device identity and fall back to token-only auth.
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator";
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
})?.token;
const authToken = storedToken ?? this.opts.token;
const canFallbackToShared = Boolean(storedToken && this.opts.token);
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token;
if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity();
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
})?.token;
authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
}
const auth =
authToken || this.opts.password
? {
@@ -142,19 +154,39 @@ export class GatewayBrowserClient {
password: this.opts.password,
}
: undefined;
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
let device:
| {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string | undefined;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
device = {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
const params = {
minProtocol: 3,
maxProtocol: 3,
@@ -167,13 +199,7 @@ export class GatewayBrowserClient {
},
role,
scopes,
device: {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
},
device,
caps: [],
auth,
userAgent: navigator.userAgent,
@@ -182,7 +208,7 @@ export class GatewayBrowserClient {
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
if (hello?.auth?.deviceToken) {
if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: hello.auth.role ?? role,
@@ -194,7 +220,7 @@ export class GatewayBrowserClient {
this.opts.onHello?.(hello);
})
.catch(() => {
if (canFallbackToShared) {
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");

View File

@@ -77,6 +77,44 @@ export function renderOverview(props: OverviewProps) {
</div>
`;
})();
const insecureContextHint = (() => {
if (props.connected || !props.lastError) return null;
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
if (isSecureContext !== false) return null;
const lower = props.lastError.toLowerCase();
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
return null;
}
return html`
<div class="muted" style="margin-top: 8px;">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
<div style="margin-top: 6px;">
If you must stay on HTTP, set
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
</div>
<div style="margin-top: 6px;">
<a
class="session-link"
href="https://docs.clawd.bot/gateway/tailscale"
target="_blank"
rel="noreferrer"
title="Tailscale Serve docs (opens in new tab)"
>Docs: Tailscale Serve</a
>
<span class="muted"> · </span>
<a
class="session-link"
href="https://docs.clawd.bot/web/control-ui#insecure-http"
target="_blank"
rel="noreferrer"
title="Insecure HTTP docs (opens in new tab)"
>Docs: Insecure HTTP</a
>
</div>
</div>
`;
})();
return html`
<section class="grid grid-cols-2">
@@ -167,6 +205,7 @@ export function renderOverview(props: OverviewProps) {
? html`<div class="callout danger" style="margin-top: 14px;">
<div>${props.lastError}</div>
${authHint ?? ""}
${insecureContextHint ?? ""}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.