Merge pull request #763 from PR0M3TH3AN/codex/implement-nip-46-auth-provider

Finalize NIP-46 remote signer integration
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-21 16:28:54 -04:00
commit 1ce38ba0de
7 files changed
+1979 -24

No files matched your search

+72 -5
View File
@@ -58,13 +58,22 @@
type="button"
data-provider-button
>
<span class="flex flex-col items-start gap-2">
<span class="flex w-full flex-col items-start gap-2">
<span data-provider-label class="text-base font-semibold"></span>
<span data-provider-description class="text-sm text-text-muted"></span>
<span
data-provider-status
class="text-sm text-text-muted hidden"
></span>
<div
data-provider-status-row
class="hidden flex w-full items-center justify-between gap-2 text-sm text-text-muted"
>
<span data-provider-status class="truncate"></span>
<button
type="button"
class="btn-link text-sm hidden"
data-provider-disconnect
>
Disconnect
</button>
</div>
<span
data-provider-capabilities
class="flex flex-wrap gap-2"
@@ -175,6 +184,64 @@
</div>
</form>
</template>
<template data-login-nip46-dialog>
<form
data-nip46-form
class="space-y-4 rounded-xl border border-border bg-overlay-panel-soft px-5 py-4"
>
<div class="space-y-2">
<h3 class="text-lg font-semibold text-text">Connect remote signer</h3>
<p class="text-sm text-text-muted">
Paste the <code>nostrconnect://</code> or <code>bunker://</code> link
from your signer to establish a Nostr Connect session.
</p>
</div>
<div
data-nip46-error
class="hidden rounded-lg border border-danger/60 bg-danger/10 px-4 py-2 text-sm text-danger"
role="alert"
></div>
<div class="space-y-2">
<label
class="block text-sm font-medium text-text"
for="nip46ConnectUri"
>
Connection string
</label>
<textarea
id="nip46ConnectUri"
class="input"
rows="3"
spellcheck="false"
autocomplete="off"
placeholder="nostrconnect://… or bunker://…"
data-nip46-connect-uri
required
></textarea>
<p class="text-xs text-text-muted">
We will connect to the relays embedded in the link and request signing
permissions on your behalf.
</p>
</div>
<div class="space-y-2">
<label class="inline-flex items-center gap-2 text-sm text-text">
<input type="checkbox" class="checkbox" data-nip46-remember checked />
<span>Remember this signer for future logins</span>
</label>
<button
type="button"
class="btn-link text-sm hidden"
data-nip46-reuse
>
Reconnect using the saved session
</button>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="btn-ghost" data-nip46-cancel>Cancel</button>
<button type="submit" class="btn" data-nip46-submit>Connect</button>
</div>
</form>
</template>
</div>
</div>
</div>
+91
View File
@@ -0,0 +1,91 @@
# NIP-46 Remote Signer Integration
bitvid can delegate event signing to a remote NIP-46 compatible signer. This
flow lets operators keep long-lived keys on hardened devices while approving
publish/edit/delete actions from a separate browser session.
## Connection strings
bitvid currently expects `bunker://` style URIs. They should embed the remote
signer pubkey in the host segment and may include the following query
parameters:
- `relay=` – one or more WSS relay URLs the signer watches for RPC requests.
Repeat the parameter to advertise multiple relays.
- `secret=` – optional shared secret that must echo back during the `connect`
handshake.
- `perms=` – optional permission string. When present bitvid forwards it during
the initial `connect` RPC.
- `name`, `url`, `image` – optional metadata shown in the login modal so users
can distinguish between saved signers.
URIs without `relay=` parameters fall back to bitvid’s default relay bundle
(`wss://relay.damus.io`, `wss://nos.lol`, `wss://relay.snort.social`,
`wss://relay.primal.net`, `wss://relay.nostr.band`). Make sure your signer also
publishes to at least one of these relays so both the browser and signer see the
same NIP-46 frames.
## Relay requirements
The remote signer must subscribe to `kind 24133` events targeted at the client
public key (included in the connection URI). bitvid publishes RPC requests to
whichever relay list resolves from the URI + local fallback:
1. Explicit `relay=` parameters from the bunker URI (WSS only, duplicates
removed and sanitized).
2. The relays persisted in a previous session, if any.
3. The default bitvid relay set listed above.
For reliable operation:
- Keep at least two relays online so publish attempts succeed within the
8&nbsp;second relay publish timeout.
- Ensure every relay allows inbound events from both the browser’s IP and the
remote signer. Corporate VPNs and Tor bridges frequently block public WSS
relays.
- Avoid private relays that block unsigned events from unknown clients; the
browser session publishes unsigned requests that the signer decrypts with the
shared secret.
## RPC expectations and timeouts
The RPC client issues the following methods:
- `connect` (optionally with `secret` and `perms`)
- `get_public_key`
- `sign_event` for publish/edit/delete flows
Each request is encrypted to the signer and published to the resolved relay set.
bitvid waits up to **8&nbsp;seconds** for a relay to accept the event and up to
**15&nbsp;seconds** for a response. `sign_event` calls allow up to
**20&nbsp;seconds** before timing out. On transient failures the client retries
once (except when the signer returns an authentication URL).
If the signer responds with `error: auth_url`, the UI surfaces the provided URL
so the user can complete any additional verification the signer requires.
## Session persistence and restoration
Successful connections write a session blob to
`localStorage` under the key `bitvid:nip46:session:v1`. The payload includes the
client key pair, remote pubkey, relay list, metadata, permissions, and the most
recent user pubkey returned by the signer.
On page load bitvid reads the stored session, updates the login modal to show a
saved signer, and silently attempts to reconnect in the background. If the
attempt fails due to network timeouts or temporary relay outages the stored
session remains intact so the user can retry manually. Fatal errors (for example
shared secret mismatches) clear the saved session automatically. Users can also
invoke “Disconnect” from the login modal to remove the stored credentials.
## Operational checklist
- Issue bunker URIs that list WSS relays reachable by both the signer and the
browser session.
- Keep the signer online and subscribed to `kind 24133` events for the advertised
relays.
- Respond to `connect`, `get_public_key`, and `sign_event` requests within the
timeouts above.
- Rotate secrets or permissions as needed—bitvid surfaces signer metadata so
operators can confirm which device they are connecting to before approving new
sessions.
+1
View File
@@ -428,6 +428,7 @@ class Application {
providers: authProviders,
services: {
authService: this.authService,
nostrClient,
},
callbacks: {
onProviderSelected: (providerId) => {
+1262 -12
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,4 +1,5 @@
import nip07 from "./nip07.js";
import nip46 from "./nip46.js";
import nsec from "./nsec.js";
function normalizeProviderId(provider, fallbackId) {
@@ -37,7 +38,7 @@ function normalizeProviderMetadata(provider, fallbackId) {
return Object.freeze({ id, label, description, badgeVariant });
}
const providerList = [nip07, nsec].filter(Boolean);
const providerList = [nip07, nip46, nsec].filter(Boolean);
const providerMap = {};
const metadataMap = {};
+117
View File
@@ -0,0 +1,117 @@
const PROVIDER_ID = "nip46";
const PROVIDER_LABEL = "Remote signer";
const PROVIDER_DESCRIPTION = "Connect to a NIP-46 compatible signer via Nostr Connect.";
const PROVIDER_CAPABILITIES = Object.freeze([
Object.freeze({
id: "signing",
label: "Signs events remotely",
variant: "info",
}),
Object.freeze({
id: "multi-device",
label: "Works across browsers",
variant: "neutral",
}),
]);
const PROVIDER_BUTTON = Object.freeze({
variant: "outline",
});
const PROVIDER_MESSAGES = Object.freeze({
loading: "Connecting to remote signer…",
slow: "Waiting for the signer to approve…",
error: "Failed to connect to the remote signer. Please try again.",
});
function normalizePubkey(result) {
if (typeof result === "string") {
return result;
}
if (!result || typeof result !== "object") {
return "";
}
if (typeof result.pubkey === "string") {
return result.pubkey;
}
if (typeof result.publicKey === "string") {
return result.publicKey;
}
return "";
}
function normalizeOptions(options) {
if (!options || typeof options !== "object") {
return {
connectionString: "",
remember: true,
reuseStored: false,
};
}
return {
connectionString:
typeof options.connectionString === "string" ? options.connectionString.trim() : "",
remember: options.remember !== false,
reuseStored: options.reuseStored === true,
};
}
export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
description: PROVIDER_DESCRIPTION,
badgeVariant: "info",
capabilities: PROVIDER_CAPABILITIES,
button: PROVIDER_BUTTON,
messages: PROVIDER_MESSAGES,
errorMessage: PROVIDER_MESSAGES.error,
async login({ nostrClient, options } = {}) {
if (!nostrClient || typeof nostrClient !== "object") {
const error = new Error("Remote signer login is not available.");
error.code = "provider-unavailable";
throw error;
}
if (typeof nostrClient.connectRemoteSigner !== "function") {
const error = new Error("Remote signer support is unavailable in this environment.");
error.code = "provider-unavailable";
throw error;
}
const normalized = normalizeOptions(options);
let result = null;
if (normalized.reuseStored) {
if (typeof nostrClient.useStoredRemoteSigner !== "function") {
const error = new Error("No stored remote signer is available on this device.");
error.code = "no-stored-session";
throw error;
}
result = await nostrClient.useStoredRemoteSigner();
} else {
const uri = normalized.connectionString;
if (!uri) {
const error = new Error("A connection string is required to continue.");
error.code = "connection-required";
throw error;
}
result = await nostrClient.connectRemoteSigner({
connectionString: uri,
remember: normalized.remember,
});
}
const pubkey = normalizePubkey(result);
return {
authType: PROVIDER_ID,
pubkey,
signer: result && typeof result === "object" ? result.signer || null : null,
};
},
};
+434 -6
View File
@@ -202,6 +202,10 @@ export default class LoginModalController {
services.authService && typeof services.authService === "object"
? services.authService
: null,
nostrClient:
services.nostrClient && typeof services.nostrClient === "object"
? services.nostrClient
: null,
};
this.callbacks = {
@@ -233,11 +237,18 @@ export default class LoginModalController {
this.modalElement?.querySelector(".modal-body") || null;
this.nsecTemplate =
this.modalElement?.querySelector("template[data-login-nsec-dialog]") || null;
this.nip46Template =
this.modalElement?.querySelector("template[data-login-nip46-dialog]") || null;
this.template = null;
this.providerEntries = new Map();
this.slowTimers = new Map();
this.boundClickHandler = (event) => this.handleContainerClick(event);
this.activeNsecForm = null;
this.activeNip46Form = null;
this.remoteSignerUnsubscribe = null;
this.lastRemoteSignerStatus = null;
this.initializeRemoteSignerStatus();
this.initialized = false;
this.initialize();
@@ -260,11 +271,20 @@ export default class LoginModalController {
this.renderProviders();
if (typeof this.lastRemoteSignerStatus !== "undefined") {
this.applyRemoteSignerStatus(this.lastRemoteSignerStatus);
}
this.providerContainer.addEventListener("click", this.boundClickHandler);
this.initialized = true;
}
getNostrClient() {
const direct = this.services?.nostrClient;
if (direct && typeof direct === "object") {
return direct;
}
const service = this.services?.authService;
if (!service || typeof service !== "object") {
return null;
@@ -288,6 +308,143 @@ export default class LoginModalController {
return null;
}
getStoredNip46Metadata() {
const nostrClient = this.getNostrClient();
if (!nostrClient || typeof nostrClient.getStoredNip46Metadata !== "function") {
return null;
}
try {
return nostrClient.getStoredNip46Metadata();
} catch (error) {
devLogger.warn(
"[LoginModalController] Failed to inspect stored NIP-46 session:",
error,
);
}
return null;
}
initializeRemoteSignerStatus() {
const nostrClient = this.getNostrClient();
if (!nostrClient) {
this.applyRemoteSignerStatus(null);
return;
}
if (typeof nostrClient.getRemoteSignerStatus === "function") {
try {
const status = nostrClient.getRemoteSignerStatus();
this.applyRemoteSignerStatus(status);
} catch (error) {
devLogger.warn(
"[LoginModalController] Failed to read initial remote signer status:",
error,
);
this.applyRemoteSignerStatus(null);
}
} else {
this.applyRemoteSignerStatus(null);
}
if (typeof nostrClient.onRemoteSignerChange === "function") {
try {
this.remoteSignerUnsubscribe = nostrClient.onRemoteSignerChange((status) => {
this.applyRemoteSignerStatus(status);
});
} catch (error) {
devLogger.warn(
"[LoginModalController] Failed to subscribe to remote signer updates:",
error,
);
}
}
}
formatRemoteSignerLabel(status) {
if (!status || typeof status !== "object") {
return "remote signer";
}
const { label, remoteNpub, remotePubkey } = status;
if (typeof label === "string" && label.trim()) {
return label.trim();
}
if (typeof remoteNpub === "string" && remoteNpub.trim()) {
return remoteNpub.trim();
}
if (typeof remotePubkey === "string" && remotePubkey.trim()) {
const trimmed = remotePubkey.trim();
if (trimmed.length <= 16) {
return trimmed;
}
return `${trimmed.slice(0, 8)}…${trimmed.slice(-6)}`;
}
return "remote signer";
}
applyRemoteSignerStatus(status) {
this.lastRemoteSignerStatus = status || null;
const storedMetadata = this.getStoredNip46Metadata();
const hasStoredSession = storedMetadata?.hasSession === true;
const normalizedState =
typeof status?.state === "string" && status.state.trim()
? status.state.trim()
: hasStoredSession
? "stored"
: "idle";
let message = "";
let showDisconnect = false;
switch (normalizedState) {
case "connected": {
if (typeof status?.userNpub === "string" && status.userNpub.trim()) {
message = `Connected as ${status.userNpub.trim()}`;
} else {
const label = this.formatRemoteSignerLabel(status);
message = `Connected to ${label}`;
}
showDisconnect = true;
break;
}
case "connecting": {
message = "Connecting to remote signer…";
break;
}
case "error": {
message =
typeof status?.message === "string" && status.message.trim()
? status.message.trim()
: "Remote signer unavailable.";
break;
}
case "stored": {
if (hasStoredSession) {
const label = this.formatRemoteSignerLabel(status || storedMetadata);
message = `Saved remote signer (${label}) ready to connect.`;
}
break;
}
default: {
message = "";
break;
}
}
this.setProviderStatus("nip46", {
message,
reset: !message,
showDisconnect,
statusState: normalizedState,
});
}
async promptForNsecOptions() {
if (!this.modalBody || !this.nsecTemplate) {
userLogger.warn("[LoginModalController] Direct key login is unavailable in this build.");
@@ -552,6 +709,160 @@ export default class LoginModalController {
});
}
async promptForNip46Options() {
if (!this.modalBody || !this.nip46Template) {
userLogger.warn(
"[LoginModalController] Remote signer login is unavailable in this build.",
);
return null;
}
if (this.activeNip46Form) {
return null;
}
const fragment = this.nip46Template.content
? this.nip46Template.content.cloneNode(true)
: null;
if (!fragment) {
return null;
}
const form = fragment.querySelector("[data-nip46-form]");
if (!(form instanceof HTMLFormElement)) {
return null;
}
const connectInput = form.querySelector("[data-nip46-connect-uri]");
const rememberCheckbox = form.querySelector("[data-nip46-remember]");
const reuseButton = form.querySelector("[data-nip46-reuse]");
const cancelButton = form.querySelector("[data-nip46-cancel]");
const errorNode = form.querySelector("[data-nip46-error]");
const storedMetadata = this.getStoredNip46Metadata();
const hasStoredSession = storedMetadata?.hasSession === true;
if (reuseButton instanceof HTMLButtonElement) {
reuseButton.classList.toggle("hidden", !hasStoredSession);
}
const elementsToHide = [];
for (const child of Array.from(this.modalBody.children)) {
if (child instanceof HTMLElement && child !== form) {
if (child.tagName === "TEMPLATE") {
continue;
}
const wasHidden = child.classList.contains("hidden");
if (!wasHidden) {
child.classList.add("hidden");
}
elementsToHide.push({ element: child, wasHidden });
}
}
this.modalBody.appendChild(form);
this.activeNip46Form = form;
const setError = (message) => {
if (!(errorNode instanceof HTMLElement)) {
return;
}
const normalized = typeof message === "string" ? message.trim() : "";
if (normalized) {
errorNode.textContent = normalized;
errorNode.classList.remove("hidden");
} else {
errorNode.textContent = "";
errorNode.classList.add("hidden");
}
};
setError("");
if (connectInput instanceof HTMLTextAreaElement) {
connectInput.focus();
}
let submitHandler = null;
let cancelHandler = null;
let reuseHandler = null;
const cleanup = () => {
if (submitHandler) {
form.removeEventListener("submit", submitHandler);
}
if (cancelHandler && cancelButton instanceof HTMLButtonElement) {
cancelButton.removeEventListener("click", cancelHandler);
}
if (reuseHandler && reuseButton instanceof HTMLButtonElement) {
reuseButton.removeEventListener("click", reuseHandler);
}
if (form.parentElement) {
form.parentElement.removeChild(form);
}
for (const entry of elementsToHide) {
if (!(entry?.element instanceof HTMLElement)) {
continue;
}
if (!entry.wasHidden) {
entry.element.classList.remove("hidden");
}
}
this.activeNip46Form = null;
};
return new Promise((resolve) => {
let settled = false;
const finish = (detail) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(detail);
};
submitHandler = (event) => {
event.preventDefault();
setError("");
if (!(connectInput instanceof HTMLTextAreaElement)) {
setError("Enter the remote signer connection link to continue.");
return;
}
const raw = connectInput.value.trim();
if (!raw) {
setError("Paste the nostrconnect:// or bunker:// link from your signer.");
connectInput.focus();
return;
}
const remember =
rememberCheckbox instanceof HTMLInputElement ? rememberCheckbox.checked : true;
finish({ connectionString: raw, remember: remember !== false });
};
form.addEventListener("submit", submitHandler);
if (cancelButton instanceof HTMLButtonElement) {
cancelHandler = (event) => {
event.preventDefault();
finish(null);
};
cancelButton.addEventListener("click", cancelHandler);
}
if (reuseButton instanceof HTMLButtonElement) {
reuseHandler = (event) => {
event.preventDefault();
finish({ reuseStored: true });
};
reuseButton.addEventListener("click", reuseHandler);
}
});
}
resolveTemplate() {
if (!this.modalElement) {
return null;
@@ -660,6 +971,17 @@ export default class LoginModalController {
statusNode.classList.add("hidden");
}
const statusRow = button.querySelector("[data-provider-status-row]");
if (statusRow instanceof HTMLElement) {
statusRow.classList.add("hidden");
}
const disconnectButton = button.querySelector("[data-provider-disconnect]");
if (disconnectButton instanceof HTMLButtonElement) {
disconnectButton.dataset.providerId = provider.id;
disconnectButton.classList.add("hidden");
}
const capabilitiesNode = button.querySelector(
"[data-provider-capabilities]",
);
@@ -702,6 +1024,9 @@ export default class LoginModalController {
button,
descriptionNode: descriptionNode instanceof HTMLElement ? descriptionNode : null,
statusNode: statusNode instanceof HTMLElement ? statusNode : null,
statusRow: statusRow instanceof HTMLElement ? statusRow : null,
disconnectButton:
disconnectButton instanceof HTMLButtonElement ? disconnectButton : null,
defaultDescription: provider.description,
},
};
@@ -713,6 +1038,17 @@ export default class LoginModalController {
return;
}
const disconnectButton = target.closest("[data-provider-disconnect]");
if (disconnectButton instanceof HTMLButtonElement) {
const providerId = disconnectButton.dataset.providerId;
if (providerId) {
this.handleProviderDisconnect(providerId);
}
event.preventDefault();
event.stopPropagation();
return;
}
const button = target.closest("[data-provider-button]");
if (!(button instanceof HTMLButtonElement)) {
return;
@@ -726,10 +1062,60 @@ export default class LoginModalController {
this.handleProviderSelection(providerId);
}
async handleProviderDisconnect(providerId) {
if (providerId !== "nip46") {
return;
}
const nostrClient = this.getNostrClient();
if (!nostrClient || typeof nostrClient.disconnectRemoteSigner !== "function") {
return;
}
try {
await nostrClient.disconnectRemoteSigner({ keepStored: false });
} catch (error) {
devLogger.warn("[LoginModalController] Failed to disconnect remote signer:", error);
}
}
getProviderEntry(providerId) {
return this.providerEntries.get(providerId) || null;
}
setProviderStatus(providerId, options = {}) {
const entry = this.getProviderEntry(providerId);
if (!entry) {
return;
}
const {
message = "",
reset = false,
showDisconnect = false,
disableDisconnect = false,
statusState = null,
} = options || {};
if (statusState) {
entry.statusState = statusState;
} else {
delete entry.statusState;
}
this.updateStatusMessage(entry, message, reset);
const shouldShowRow = !reset && typeof message === "string" && message.trim();
if (entry.statusRow instanceof HTMLElement) {
entry.statusRow.classList.toggle("hidden", !shouldShowRow);
}
if (entry.disconnectButton instanceof HTMLButtonElement) {
entry.disconnectButton.classList.toggle("hidden", !showDisconnect);
entry.disconnectButton.disabled = !!disableDisconnect;
}
}
setLoadingState(providerId, isLoading) {
const entry = this.getProviderEntry(providerId);
if (!entry) {
@@ -741,10 +1127,14 @@ export default class LoginModalController {
entry.button.dataset.state = "loading";
entry.button.setAttribute("aria-busy", "true");
this.updateStatusMessage(
entry,
entry.provider.messages.loading || "Connecting…",
);
const loadingMessage = entry.provider.messages.loading || "Connecting…";
this.setProviderStatus(providerId, {
message: loadingMessage,
reset: false,
showDisconnect: false,
disableDisconnect: true,
statusState: "loading",
});
this.startSlowTimer(providerId, entry);
} else {
const shouldRemainDisabled = entry.provider.disabled === true;
@@ -758,7 +1148,7 @@ export default class LoginModalController {
delete entry.button.dataset.state;
entry.button.removeAttribute("aria-busy");
this.stopSlowTimer(providerId);
this.updateStatusMessage(entry, "", true);
this.setProviderStatus(providerId, { message: "", reset: true });
}
}
@@ -812,7 +1202,13 @@ export default class LoginModalController {
const slowMessage =
entry.provider.messages.slow || "Waiting for the provider…";
this.updateStatusMessage(entry, slowMessage);
this.setProviderStatus(providerId, {
message: slowMessage,
reset: false,
showDisconnect: false,
statusState: "waiting",
disableDisconnect: true,
});
}, timeout);
this.slowTimers.set(providerId, timerId);
@@ -877,6 +1273,26 @@ export default class LoginModalController {
);
return;
}
} else if (entry.provider.id === "nip46") {
try {
const nip46Options = await this.promptForNip46Options();
if (!nip46Options) {
devLogger.log(
"[LoginModalController] Remote signer login cancelled by the user.",
);
if (entry.button instanceof HTMLButtonElement) {
entry.button.focus();
}
return;
}
providerOptions = nip46Options;
} catch (promptError) {
devLogger.error(
"[LoginModalController] Failed to collect remote signer connection:",
promptError,
);
return;
}
}
this.setLoadingState(providerId, true);
@@ -942,6 +1358,18 @@ export default class LoginModalController {
this.stopSlowTimer(providerId);
}
if (typeof this.remoteSignerUnsubscribe === "function") {
try {
this.remoteSignerUnsubscribe();
} catch (error) {
devLogger.warn(
"[LoginModalController] Remote signer unsubscribe handler threw:",
error,
);
}
}
this.remoteSignerUnsubscribe = null;
this.providerEntries.clear();
this.initialized = false;
}