Wire up NIP-04 in the BitLogin adapter, flip capabilities.nip04 to true

bitlogin's widget now implements NIP-04 (nip04Encrypt/nip04Decrypt),
closing the one gap left relative to a real NIP-07 extension. bitvid's
own nip07Adapter/nsecAdapter/nip46Adapter all already wire this up and
report it via signerCapabilities.nip04 -- BitLogin's adapter was the
one exception, hardcoded to nip04: false because the widget genuinely
didn't support it before. js/nostr/client.js's DM encrypt/decrypt path
only offers NIP-04 to a caller when that capability flag is true, so a
BitLogin-signed-in user previously had no working legacy-DM path at
all. Re-vendored the widget bundle (bitlogin's CHANGELOG.md, 2026-07-21
entry, third one) and updated the one test that hardcoded the old
(now-incorrect) capabilities shape.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
This commit is contained in:
thePR0M3TH3ANandClaude Sonnet 5 committed 2026-07-21 23:32:27 -04:00
1 parent ca7b711b8a
commit f2d390ff18
5 files changed
+1095 -793

No files matched your search

+5 -1
View File
@@ -17,6 +17,10 @@ export function createBitloginAdapter(widgetElement, pubkey) {
widgetElement.nip44Encrypt(peerPubkey, plaintext),
nip44Decrypt: async (peerPubkey, payload) =>
widgetElement.nip44Decrypt(peerPubkey, payload),
nip04Encrypt: async (peerPubkey, plaintext) =>
widgetElement.nip04Encrypt(peerPubkey, plaintext),
nip04Decrypt: async (peerPubkey, payload) =>
widgetElement.nip04Decrypt(peerPubkey, payload),
requestPermissions: async () => ({ ok: true }),
destroy: async () => {
try {
@@ -30,7 +34,7 @@ export function createBitloginAdapter(widgetElement, pubkey) {
capabilities: {
sign: true,
nip44: true,
nip04: false,
nip04: true,
},
};
+16 -2
View File
@@ -102,7 +102,7 @@ describe("BitLogin auth provider", () => {
});
describe("createBitloginAdapter", () => {
it("delegates signing and NIP-44 calls to the live widget element, never window.nostr", async () => {
it("delegates signing and NIP-44/NIP-04 calls to the live widget element, never window.nostr", async () => {
const calls = [];
const fakeWidget = {
signEvent: async (event) => {
@@ -117,6 +117,14 @@ describe("createBitloginAdapter", () => {
calls.push(["nip44Decrypt", peer, payload]);
return "plaintext";
},
nip04Encrypt: async (peer, plaintext) => {
calls.push(["nip04Encrypt", peer, plaintext]);
return "legacy-ciphertext";
},
nip04Decrypt: async (peer, payload) => {
calls.push(["nip04Decrypt", peer, payload]);
return "legacy-plaintext";
},
logout: async () => {
calls.push(["logout"]);
},
@@ -127,19 +135,25 @@ describe("createBitloginAdapter", () => {
assert.equal(adapter.type, "bitlogin");
assert.equal(adapter.pubkey, HEX_PUBKEY);
assert.equal(adapter.canSign(), true);
assert.deepEqual(adapter.capabilities, { sign: true, nip44: true, nip04: false });
// NIP-04 is real, load-bearing capability info: js/nostr/client.js only offers a
// nip04Encrypt/Decrypt code path to callers when signerCapabilities.nip04 is true.
assert.deepEqual(adapter.capabilities, { sign: true, nip44: true, nip04: true });
const signed = await adapter.signEvent({ kind: 1, content: "hi" });
assert.equal(signed.id, "signed");
await adapter.nip44Encrypt("peer-pubkey", "hello");
await adapter.nip44Decrypt("peer-pubkey", "ciphertext");
await adapter.nip04Encrypt("peer-pubkey", "hello");
await adapter.nip04Decrypt("peer-pubkey", "legacy-ciphertext");
await adapter.destroy();
assert.deepEqual(calls, [
["signEvent", { kind: 1, content: "hi" }],
["nip44Encrypt", "peer-pubkey", "hello"],
["nip44Decrypt", "peer-pubkey", "ciphertext"],
["nip04Encrypt", "peer-pubkey", "hello"],
["nip04Decrypt", "peer-pubkey", "legacy-ciphertext"],
["logout"],
]);
});
+4 -4
View File
@@ -7,12 +7,12 @@
"bytes": 288275
},
"bitlogin.js": {
"sha256": "7bbe34092ff5c110d947ac4cec7500cd32824868e51118387566896660a31228",
"bytes": 65009
"sha256": "539253ea709e9554f3dcff872beff40c2ca847b3564f53885b8fd89a7a2747d1",
"bytes": 66045
},
"cryptoWorker.js": {
"sha256": "061d4db248e5a789f61c505dfbc3fe073b6373b3c9a590c331144840e7b654b0",
"bytes": 64890
"sha256": "06a7aaf0be2ff4e80b46f609b868aa4ccfbd402cf0604031500316830ff2f528",
"bytes": 73937
}
}
}
+73 -44
View File
@@ -1,6 +1,6 @@
import { r as y, e as w, E as v, p as Q, R as k, i as h, a as u } from "./bitlogin-shared-QIBe5Omw.js";
const x = 6, p = 64;
function c(a = x) {
function d(a = x) {
const e = w(), t = [];
for (let r = 0; r < a; r++)
t.push(e[y(e.length)]);
@@ -54,8 +54,8 @@ function S(a) {
const e = a.toLowerCase(), t = ["0123456789", "abcdefghijklmnopqrstuvwxyz"];
for (const s of t)
for (let r = 0; r + 4 <= s.length; r++) {
const o = s.slice(r, r + 4), n = [...o].reverse().join("");
if (e.includes(o) || e.includes(n))
const n = s.slice(r, r + 4), o = [...n].reverse().join("");
if (e.includes(n) || e.includes(o))
return !0;
}
return !1;
@@ -75,22 +75,22 @@ class E {
constructor() {
const e = ["cryptoWorker", ".js"].join(""), t = new URL(e, import.meta.url);
this.worker = new Worker(t, { type: "module" }), this.worker.addEventListener("message", (s) => {
const r = s.data, o = this.pending.get(r.id);
if (o)
const r = s.data, n = this.pending.get(r.id);
if (n)
if (this.pending.delete(r.id), r.ok)
o.resolve(r.result);
n.resolve(r.result);
else {
const n = new Error(r.error);
n.name = r.errorName ?? "Error", o.reject(n);
const o = new Error(r.error);
o.name = r.errorName ?? "Error", n.reject(o);
}
});
}
call(e, t) {
const s = `${Date.now().toString(36)}-${(this.counter++).toString(36)}`;
return new Promise((r, o) => {
this.pending.set(s, { resolve: r, reject: o });
const n = { id: s, action: e, payload: t };
this.worker.postMessage(n);
return new Promise((r, n) => {
this.pending.set(s, { resolve: r, reject: n });
const o = { id: s, action: e, payload: t };
this.worker.postMessage(o);
});
}
configure(e) {
@@ -129,6 +129,12 @@ class E {
nip44Decrypt(e) {
return this.call("nip44Decrypt", e);
}
nip04Encrypt(e) {
return this.call("nip04Encrypt", e);
}
nip04Decrypt(e) {
return this.call("nip04Decrypt", e);
}
exportIdentity() {
return this.call("exportIdentity", {});
}
@@ -149,7 +155,7 @@ class E {
}
}
const N = "BitLogin: no identity is unlocked yet. Add <bitlogin-auth> to the page and let the user sign in, or call it programmatically before invoking window.nostr.";
function d(a) {
function c(a) {
return a.catch((e) => {
throw e.message.includes("No identity is unlocked") ? new Error(N) : e;
});
@@ -157,11 +163,11 @@ function d(a) {
function $(a, e) {
return {
async getPublicKey() {
const { publicKey: t } = await d(a.getPublicKey());
const { publicKey: t } = await c(a.getPublicKey());
return t;
},
async signEvent(t) {
return d(a.signEvent(t));
return c(a.signEvent(t));
},
async getRelays() {
const t = {};
@@ -170,11 +176,21 @@ function $(a, e) {
},
nip44: {
async encrypt(t, s) {
const { ciphertext: r } = await d(a.nip44Encrypt({ peerPublicKey: t, plaintext: s }));
const { ciphertext: r } = await c(a.nip44Encrypt({ peerPublicKey: t, plaintext: s }));
return r;
},
async decrypt(t, s) {
const { plaintext: r } = await d(a.nip44Decrypt({ peerPublicKey: t, payload: s }));
const { plaintext: r } = await c(a.nip44Decrypt({ peerPublicKey: t, payload: s }));
return r;
}
},
nip04: {
async encrypt(t, s) {
const { ciphertext: r } = await c(a.nip04Encrypt({ peerPublicKey: t, plaintext: s }));
return r;
},
async decrypt(t, s) {
const { plaintext: r } = await c(a.nip04Decrypt({ peerPublicKey: t, payload: s }));
return r;
}
},
@@ -482,7 +498,9 @@ class F extends HTMLElement {
pendingRollback = null;
rollbackMessage = "";
constructor() {
super(), this.root = this.attachShadow({ mode: "open" }), this.worker = new E();
super(), this.root = this.attachShadow({ mode: "open" });
const e = new CSSStyleSheet();
e.replaceSync(M), this.root.adoptedStyleSheets = [e], this.worker = new E();
}
connectedCallback() {
const e = R(this);
@@ -516,6 +534,17 @@ class F extends HTMLElement {
async nip44Decrypt(e, t) {
return (await this.worker.nip44Decrypt({ peerPublicKey: e, payload: t })).plaintext;
}
/**
* Legacy relative to nip44Encrypt/nip44Decrypt above, but still what a real NIP-07
* extension exposes as window.nostr.nip04 -- implemented for drop-in parity with sites
* (or their older DM code paths) that still expect NIP-04 rather than NIP-44.
*/
async nip04Encrypt(e, t) {
return (await this.worker.nip04Encrypt({ peerPublicKey: e, plaintext: t })).ciphertext;
}
async nip04Decrypt(e, t) {
return (await this.worker.nip04Decrypt({ peerPublicKey: e, payload: t })).plaintext;
}
async logout() {
await this.worker.logout(), this.session = null, this.releaseSigner(), this.dispatchEvent(new CustomEvent("bitlogin-logout")), this.goto("welcome");
}
@@ -673,21 +702,21 @@ class F extends HTMLElement {
this.goto("confirm-phrase");
return;
case "goto-change-password":
this.changePasswordNewCredential = c().secret, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.goto("change-password");
this.changePasswordNewCredential = d().secret, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.goto("change-password");
return;
case "goto-export":
this.goto("export");
return;
case "regenerate-credential":
this.generatedCredential = c().secret, this.savedCheckbox = !1, this.render();
this.generatedCredential = d().secret, this.savedCheckbox = !1, this.render();
return;
case "toggle-manual-password":
this.manualPasswordMode = !this.manualPasswordMode, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.savedCheckbox = !1, this.render();
return;
case "copy-credential": {
const r = this.root.querySelector("#credential-box"), o = t, n = o.textContent ?? "Copy", l = (g) => {
o.textContent = g, setTimeout(() => {
o.isConnected && (o.textContent = n);
const r = this.root.querySelector("#credential-box"), n = t, o = n.textContent ?? "Copy", l = (g) => {
n.textContent = g, setTimeout(() => {
n.isConnected && (n.textContent = o);
}, 2e3);
};
if (!r || !navigator.clipboard?.writeText) {
@@ -757,16 +786,16 @@ class F extends HTMLElement {
if (!(t instanceof HTMLInputElement) || t.name !== "manualPassword" && t.name !== "manualPasswordConfirm") return;
const s = this.root.querySelector('input[name="manualPassword"]')?.value ?? "", r = this.root.querySelector('input[name="manualPasswordConfirm"]')?.value ?? "";
this.manualPasswordFeedback = s ? f(s, this.loginName) : null;
const o = this.root.querySelector("#manual-password-feedback");
if (o)
const n = this.root.querySelector("#manual-password-feedback");
if (n)
if (!this.manualPasswordFeedback)
o.textContent = "", o.className = "notice info";
n.textContent = "", n.className = "notice info";
else {
const l = this.manualPasswordFeedback.entropyBits.toFixed(0);
this.manualPasswordFeedback.ok ? (o.textContent = `Looks good (~${l} bits estimated).`, o.className = "notice info") : (o.textContent = `${this.manualPasswordFeedback.reason} (~${l} bits estimated)`, o.className = "notice warn");
this.manualPasswordFeedback.ok ? (n.textContent = `Looks good (~${l} bits estimated).`, n.className = "notice info") : (n.textContent = `${this.manualPasswordFeedback.reason} (~${l} bits estimated)`, n.className = "notice warn");
}
const n = this.root.querySelector("#manual-password-match-feedback");
n && (r ? r === s ? (n.textContent = "Passwords match.", n.className = "notice info") : (n.textContent = "Passwords do not match.", n.className = "notice warn") : (n.textContent = "", n.className = "notice info"));
const o = this.root.querySelector("#manual-password-match-feedback");
o && (r ? r === s ? (o.textContent = "Passwords match.", o.className = "notice info") : (o.textContent = "Passwords do not match.", o.className = "notice warn") : (o.textContent = "", o.className = "notice info"));
}
/**
* Reads and validates an optional recovery-export file (§19.5) for the recover-phrase
@@ -816,18 +845,18 @@ class F extends HTMLElement {
this.errorMessage = "Login name must be 3-32 characters: a-z, 0-9, '.', '_', '-', and not start/end with punctuation.", this.render();
return;
}
this.loginName = e, this.generatedCredential = c().secret, this.savedCheckbox = !1, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.goto("create-credential");
this.loginName = e, this.generatedCredential = d().secret, this.savedCheckbox = !1, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.goto("create-credential");
}
async handleCreateCredentialSubmit() {
this.savedCheckbox = this.root.querySelector("#saved-check")?.checked ?? !1;
let e = this.generatedCredential;
if (this.manualPasswordMode) {
const { password: o, error: n } = this.readManualPassword(this.loginName);
if (n) {
this.errorMessage = n, this.render();
const { password: n, error: o } = this.readManualPassword(this.loginName);
if (o) {
this.errorMessage = o, this.render();
return;
}
e = o;
e = n;
}
if (!this.savedCheckbox) {
this.errorMessage = this.manualPasswordMode ? "Please confirm you've saved your password (or written it down) before continuing." : "Please confirm you saved your password before continuing.", this.render();
@@ -840,8 +869,8 @@ class F extends HTMLElement {
importKey: this.importKey || void 0
});
this.importKey = "", this.importPreviewNpub = "", this.recoveryPhrase = t.recoveryPhrase;
const s = this.recoveryPhrase.split(" "), r = T(s.length, 3);
this.confirmSlots = r.map((o) => ({ index: o, value: "" })), this.session = { publicKey: t.everydayPublicKey, npub: u(t.everydayPublicKey), accountId: t.accountId }, this.busy = !1, this.goto("confirm-phrase"), this.worker.publishProfileAndRelayLists({
const s = this.recoveryPhrase.split(" "), r = D(s.length, 3);
this.confirmSlots = r.map((n) => ({ index: n, value: "" })), this.session = { publicKey: t.everydayPublicKey, npub: u(t.everydayPublicKey), accountId: t.accountId }, this.busy = !1, this.goto("confirm-phrase"), this.worker.publishProfileAndRelayLists({
name: this.loginName,
generalRelays: this.vaultRelayUrls,
dmRelays: this.vaultRelayUrls
@@ -869,7 +898,7 @@ class F extends HTMLElement {
this.setBusy(!0);
try {
const r = await this.worker.login({ loginName: e, password: t, acknowledgeRollback: s });
this.loginName = e, this.session = { publicKey: r.everydayPublicKey, npub: u(r.everydayPublicKey), accountId: r.accountId }, this.sessionWarnings = [r.rollbackWarning, r.relayDisagreementWarning].filter((o) => !!o), this.busy = !1, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: r.everydayPublicKey } })), this.flashSuccess("dashboard", "Signed in"), this.offerToSaveCredential(e, t);
this.loginName = e, this.session = { publicKey: r.everydayPublicKey, npub: u(r.everydayPublicKey), accountId: r.accountId }, this.sessionWarnings = [r.rollbackWarning, r.relayDisagreementWarning].filter((n) => !!n), this.busy = !1, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: r.everydayPublicKey } })), this.flashSuccess("dashboard", "Signed in"), this.offerToSaveCredential(e, t);
} catch (r) {
if (r instanceof Error && r.name === "RollbackDetectedError") {
this.pendingRollback = { kind: "login", loginName: e, password: t }, this.rollbackMessage = r.message, this.busy = !1, this.goto("rollback-confirm");
@@ -894,7 +923,7 @@ class F extends HTMLElement {
generalRelaysCount: t.generalRelays.length,
dmRelaysCount: t.dmRelays.length,
chainWarning: t.chainWarning
}, this.session = { publicKey: t.everydayPublicKey, npub: u(t.everydayPublicKey), accountId: t.accountId }, this.newCredentialAfterRecovery = c().secret, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.busy = !1, this.goto("recover-new-credentials");
}, this.session = { publicKey: t.everydayPublicKey, npub: u(t.everydayPublicKey), accountId: t.accountId }, this.newCredentialAfterRecovery = d().secret, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.busy = !1, this.goto("recover-new-credentials");
}
async handleRecoverNewCredentialsSubmit() {
const e = this.field("newLoginName").trim().toLowerCase();
@@ -958,7 +987,7 @@ class F extends HTMLElement {
}
render() {
const e = this.pendingSuccessLabel ? this.renderSuccessOverlay(this.pendingSuccessLabel) : "";
this.root.innerHTML = `<style>${M}</style><div class="card">${this.renderScreen()}${e}</div>`;
this.root.innerHTML = `<div class="card">${this.renderScreen()}${e}</div>`;
}
renderSuccessOverlay(e) {
return `
@@ -1224,13 +1253,13 @@ class F extends HTMLElement {
}
}
}
function T(a, e) {
const t = Array.from({ length: a }, (r, o) => o), s = [];
function D(a, e) {
const t = Array.from({ length: a }, (r, n) => n), s = [];
for (let r = 0; r < e && t.length > 0; r++) {
const o = Math.floor(Math.random() * t.length);
s.push(t.splice(o, 1)[0]);
const n = Math.floor(Math.random() * t.length);
s.push(t.splice(n, 1)[0]);
}
return s.sort((r, o) => r - o);
return s.sort((r, n) => r - n);
}
function i(a) {
return a.replace(/[&<>"']/gu, (e) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[e]);
+997 -742
View File
File diff suppressed because it is too large. Load diff