attempt for nostr-ignition

This commit is contained in:
Believethehype 2024-02-05 21:54:27 +01:00
parent b68c4a4b42
commit f8ad2f19bd
13 changed files with 1228 additions and 18 deletions

View File

@ -17,8 +17,10 @@
"bech32": "^2.0.0",
"bootstrap": "^5.3.2",
"daisyui": "^4.6.0",
"events": "^3.3.0",
"mini-toastr": "^0.8.1",
"nostr-tools": "^1.17.0",
"nostr-ignition": "^0.0.5",
"nostr-tools": "^2.1.5",
"vue": "^3.4.15",
"vue-notifications": "^1.0.2",
"vue3-easy-data-table": "^1.5.47",

View File

@ -31,21 +31,55 @@
<div tabIndex={0} role="button" class="v-Button" >Sign in</div>
<div tabIndex={0} className="dropdown-content -start-44 z-[1] horizontal card card-compact w-64 p-2 shadow bg-primary text-primary-content">
<div className="card-body">
<h3 className="card-title">Nip07 Login</h3>
<p>Use a Browser Nip07 Extension like getalby or nos2x to login or use Amber on Android</p>
<h3 className="card-title">Login</h3>
<p>Use a Browser Nip07 Extension like getalby or nos2x or Nsecbunker to login or use Amber on Android</p>
<button className="btn" @click="sign_in_nip07()">Browser Extension</button>
<!-- <button className="btn" @click="sign_in_nip46()">NsecBunker</button> Not working yet-->
<button className="btn" @click="getWindowNostr()">Nostraddress</button>
<template v-if="supports_android_signer">
<button className="btn" @click="sign_in_amber()">Amber Sign in</button>
</template>
</div>
</div>
</div>
<div v-if="checkConnected">
<h2>🎊 Connected successfully! 🎊</h2>
<img src="https://i.nostr.build/a4OQ.gif" alt="thumbs-up" />
<div id="npubMessage">
The npub of your account is:
<div id="newNostrNpub"></div>
</div>
<div id="sign" style="margin-top: 2rem">
<h2>Want to try signing a note? Don't worry, we won't publish it.</h2>
<pre id="noteCode"></pre>
<button
style="display: block; font-size: 1.25rem; padding: 5px 8px; margin: 0 auto"
onclick="NostrIgnition.signEvent(JSON.parse(document.getElementById('noteCode').innerText))"
>
Sign "Hello World!"
</button>
<h2 style="margin-top: 4rem">Or, open your browser console and try pinging the bunker.</h2>
<button
style="display: block; font-size: 1.25rem; padding: 5px 8px; margin: 0 auto"
onclick="NostrIgnition.ping()"
id="pingButton"
>
Ping Bunker
</button>
</div>
</div>
<Nip89></Nip89>
</template>
</div>
</template>
<script>
import {
loadWasmAsync,
Client,
@ -54,7 +88,17 @@ import {
Filter,
initLogger,
LogLevel,
Timestamp, Keys, NostrDatabase, ClientBuilder, ClientZapper, Alphabet, SingleLetterTag, Options, Duration, PublicKey
Timestamp,
Keys,
NostrDatabase,
ClientBuilder,
ClientZapper,
Alphabet,
SingleLetterTag,
Options,
Duration,
PublicKey,
Nip46Signer
} from "@rust-nostr/nostr-sdk";
import VueNotifications from "vue-notifications";
import store from '../store';
@ -62,14 +106,16 @@ import Nip89 from "@/components/Nip89.vue";
import miniToastr from "mini-toastr";
import deadnip89s from "@/components/data/deadnip89s.json";
import amberSignerService from "./android-signer/AndroidSigner";
import {useDark, useToggle} from "@vueuse/core";
import {ref} from "vue";
const isDark = useDark();
//const toggleDark = useToggle(isDark);
import NostrIgnition from "./nostr-ignition/NostrIgnition"
let nip89dvms = []
let logger = true
let logger = false
export default {
data() {
return {
@ -79,7 +125,40 @@ export default {
supports_android_signer: false,
};
},
async mounted() {
await NostrIgnition.init({
appName: "Noogle"})
const checkConnected = ref(false);
const interval = setInterval(() => {
if (NostrIgnition.connected()) {
checkConnected.value = true
console.log("connected")
console.log("IGNITION " + NostrIgnition.remoteNpub())
//document.getElementById("newNostrNpub").innerText = NostrIgnition.remoteNpub();
//document.getElementById("connectionSuccess").style.display = "flex";
const event = {
content: "Hello World!",
pubkey: NostrIgnition.remotePubkey(),
createdAt: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
};
document.getElementById("noteCode").innerText = JSON.stringify(event, null, 4);
clearInterval(interval);
}
console.log("looping")
}, 1000);
try{
if (amberSignerService.supported) {
this.supports_android_signer = true;
@ -89,14 +168,15 @@ export default {
{
await this.sign_in_nip07()
}
/* else if (localStorage.getItem('nostr-key-method') === 'nip46')
{
await this.sign_in_nip46()
}*/
else if (localStorage.getItem('nostr-key-method') === 'android-signer')
{
let key = ""
if (localStorage.getItem('nostr-key') !== ""){
key = localStorage.getItem('nostr-key')
}
await this.sign_in_amber(key)
await this.sign_in_amber()
}
else {
await this.sign_in_anon()
@ -109,8 +189,15 @@ export default {
}
},
methods: {
getWindowNostr() {
console.log("I try to call window.nostr,but nothing happens")
return window.nostr
},
toggleDark(){
isDark.value = !isDark.value
useToggle(isDark);
@ -122,6 +209,7 @@ export default {
}
},
async sign_in_anon() {
try {
await loadWasmAsync();
@ -165,6 +253,77 @@ export default {
console.log("Client connected")
} catch (error) {
console.log(error);
}
},
async sign_in_nip46() {
try {
await loadWasmAsync();
let connectionstring = ""
if (localStorage.getItem('nostr-key') !== "" && localStorage.getItem('nostr-key').startsWith("nsecbunker://") ){
connectionstring = localStorage.getItem('nostr-key')
}
if (connectionstring === ""){
//ADD DEFAULT TEST STRING FOR NOW, USE USER INPUT LATER
connectionstring = "nsecbunker://npub1ffske30n349f7z3sccn6n90f9dxxqhcy5n4cgpq32355ka2ye6ls7sa6t4#7a53c7292aa6a8f731cd6fcc15b396213c6a7b0448f9e8994b2479f8832c029f?relay=wss://relay.nsecbunker.com"
}
if (connectionstring.startsWith("nsecbunker://")){
connectionstring = connectionstring.replace("nsecbunker://", "")
let split = connectionstring.split("?relay=")
let relay_url = split[1]
let split2 = split[0].split("#")
let publickey = Keys.fromPkStr(split2[0]).publicKey
let app_keys = Keys.fromSkStr(split2[1])
let nip46_signer = new Nip46Signer(relay_url, app_keys, publickey) ;
try{
this.signer = ClientSigner.nip46(nip46_signer);
console.log("SIGNER: " + this.signer)
} catch (error) {
console.log(error);
this.signer = ClientSigner.keys(Keys.generate())
}
//let zapper = ClientZapper.webln()
let opts = new Options().waitForSend(false).connectionTimeout(Duration.fromSecs(5));
let client = new ClientBuilder().signer(this.signer).opts(opts).build()
await client.addRelay(relay_url)
for (const relay of store.state.relays){
await client.addRelay(relay);
}
const pubkey = await nip46_signer.signerPublicKey()
console.log("PUBKEY : " + pubkey.toBech32())
await client.connect();
store.commit('set_client', client)
store.commit('set_pubkey', pubkey)
store.commit('set_hasEventListener', false)
localStorage.setItem('nostr-key-method', "nip46")
localStorage.setItem('nostr-key', connectionstring)
console.log("Client connected")
await this.get_user_info(pubkey)
//miniToastr.showMessage("Login successful!", "Logged in as " + this.current_user, VueNotifications.types.success)
}
else {
miniToastr.showMessage("Invalid Nsecbunker url")
}
} catch (error) {
console.log(error);
}
@ -242,6 +401,11 @@ export default {
}
}
let key = ""
if (localStorage.getItem('nostr-key') !== ""){
key = localStorage.getItem('nostr-key')
}
if (!amberSignerService.supported) {
alert("android signer not supported")
return;
@ -364,7 +528,7 @@ export default {
this.current_user = ""
localStorage.setItem('nostr-key-method', "anon")
localStorage.setItem('nostr-key', "")
await this.state.client.shutdown();
//await this.state.client.shutdown();
await this.sign_in_anon()
}
},

View File

@ -1,10 +1,9 @@
// taken from https://github.com/hzrd149/nostrudel
import { nip19, verifySignature } from "nostr-tools";
import { nip19, verifyEvent } from "nostr-tools";
import createDefer, { Deferred } from "./classes/deffered";
import { getPubkeyFromDecodeResult, isHexKey } from "./helpers/nip19";
import { NostrEvent } from "./types/nostr-event";
export function createGetPublicKeyIntent() {
return `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end`;
}
@ -67,7 +66,7 @@ async function signEvent(draft): Promise<NostrEvent> {
const signedEventJson = await intentRequest(createSignEventIntent(draft));
const signedEvent = JSON.parse(signedEventJson) as NostrEvent;
if (!verifySignature(signedEvent)) throw new Error("Invalid signature");
if (!verifyEvent(signedEvent)) throw new Error("Invalid signature");
return signedEvent;
}

View File

@ -0,0 +1,5 @@
import { expect, test } from "bun:test";
test("2 + 2", () => {
expect(2 + 2).toBe(4);
});

View File

@ -0,0 +1,470 @@
import { NIP05_REGEX, queryProfile } from "nostr-tools/nip05";
import { NPUB_REGEX, Nip46, PUBKEY_REGEX } from "./nip46";
import type { Nip46Response, BunkerProfile, KeyPair } from "./nip46";
import { decode, npubEncode } from "nostr-tools/nip19";
import type { UnsignedEvent } from "nostr-tools";
import { hexToBytes } from "@noble/hashes/utils";
import { checkNip05Availability } from "./utils";
type NostrIgnitionOptions = {
appName: string;
redirectUri: string;
relays?: string[];
};
let options: NostrIgnitionOptions;
let nip46: Nip46;
let availableBunkers: BunkerProfile[] = [];
let localBunker: BunkerProfile | undefined = undefined;
let hasConnected = false;
const init = async (ignitionOptions: NostrIgnitionOptions) => {
// Only do something if the window.nostr object doesn't exist
// e.g. we don't have a NIP-07 extension
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(window as any).nostr) {
console.log("Initializing Nostr Ignition...");
const pubkey = localStorage.getItem("localNostrPubkey");
const privkey = localStorage.getItem("localNostrPrivkey");
if (pubkey && privkey) {
console.log("Using local keypair");
const keys: KeyPair = { privateKey: hexToBytes(privkey), publicKey: pubkey };
nip46 = new Nip46(keys); // instantiate the NIP-46 class with local keys
} else {
console.log("Generating new keypair");
nip46 = new Nip46(); // instantiate the NIP-46 class with no keys
}
options = ignitionOptions; // Set the options
// Check for available bunkers have to do this before modal is created
availableBunkers = await nip46.fetchBunkers();
localBunker = availableBunkers.find((bunker) => bunker.local) || undefined;
// Build the modal
const modal = await createModal(); // Create the modal element
// Get the modal elements
const nostrModalClose = document.getElementById("nostr_ignition__nostrModalClose") as HTMLButtonElement;
const nostrModalCreateContainer = document.getElementById("nostr_ignition__createAccount") as HTMLDivElement;
const nostrModalConnectContainer = document.getElementById("nostr_ignition__connectAccount") as HTMLDivElement;
const nostrModalSwitchToSignIn = document.getElementById("nostr_ignition__switchToSignIn") as HTMLButtonElement;
const nostrModalSwitchToCreateAccount = document.getElementById(
"nostr_ignition__switchToCreateAccount"
) as HTMLButtonElement;
// Create account form
const nostrModalNip05 = document.getElementById("nostr_ignition__nostrModalNip05") as HTMLInputElement;
const nostrModalBunker = document.getElementById("nostr_ignition__nostrModalBunker") as HTMLSelectElement;
const nostrModalEmail = document.getElementById("nostr_ignition__nostrModalEmail") as HTMLInputElement;
const nostrModalCreateSubmit = document.getElementById(
"nostr_ignition__nostrModalCreateSubmit"
) as HTMLButtonElement;
const nostrModalCreateSubmitText = document.getElementById(
"nostr_ignition__nostrModalCreateSubmitText"
) as HTMLSpanElement;
const nostrModalCreateSubmitSpinner = document.getElementById(
"nostr_ignition__nostrModalCreateSubmitSpinner"
) as HTMLSpanElement;
const nostrModalNip05Error = document.getElementById("nostr_ignition__nostrModalNip05Error") as HTMLSpanElement;
const nostrModalBunkerError = document.getElementById(
"nostr_ignition__nostrModalBunkerError"
) as HTMLSpanElement;
// Sign in form
const nostrModalNpubOrNip05 = document.getElementById(
"nostr_ignition__nostrModalNpubOrNip05"
) as HTMLInputElement;
const nostrModalNpubOrNip05Error = document.getElementById(
"nostr_ignition__nostrModalNpubOrNip05Error"
) as HTMLSpanElement;
const nostrModalSignInSubmit = document.getElementById(
"nostr_ignition__nostrModalSignInSubmit"
) as HTMLButtonElement;
const nostrModalSignInSubmitText = document.getElementById(
"nostr_ignition__nostrModalSignInSubmitText"
) as HTMLSpanElement;
const nostrModalSignInSubmitSpinner = document.getElementById(
"nostr_ignition__nostrModalSignInSubmitSpinner"
) as HTMLSpanElement;
const SIGNIN_TIMEOUT = 10000; // 10 seconds
let signInTimeoutFunction: NodeJS.Timeout | null = null;
// If we had local keys, default to the sign in form
if (pubkey && privkey) {
nostrModalCreateContainer.style.display = "none";
nostrModalConnectContainer.style.display = "block";
}
// Update the app name safely (escaping content provided by user)
const appName = document.getElementById("nostr_ignition__appName") as HTMLSpanElement;
appName.innerText = options.appName;
// Add the available bunkers to the select element safely
// (escaping content provided by user generated events)
availableBunkers.forEach((bunker) => {
const option = document.createElement("option");
option.setAttribute("value", bunker.domain);
option.innerText = bunker.domain;
nostrModalBunker.appendChild(option);
});
// Create the window.nostr object and anytime it's called, show the modal
Object.defineProperty(window, "nostr", {
get: function () {
showModal(modal);
},
});
// Function to reset forms
const resetForms = () => {
if (signInTimeoutFunction) clearTimeout(signInTimeoutFunction);
nostrModalNip05.value = "";
nostrModalNip05.classList.remove("invalid");
nostrModalBunkerError.classList.remove("invalid");
nostrModalNip05Error.style.display = "none";
nostrModalBunkerError.style.display = "none";
nostrModalCreateSubmit.disabled = false;
nostrModalCreateSubmitText.style.display = "block";
nostrModalCreateSubmitSpinner.style.display = "none";
nostrModalNpubOrNip05.value = "";
nostrModalNpubOrNip05.classList.remove("invalid");
nostrModalNpubOrNip05Error.style.display = "none";
nostrModalSignInSubmit.disabled = false;
nostrModalSignInSubmitText.style.display = "block";
nostrModalSignInSubmitSpinner.style.display = "none";
nostrModalCreateContainer.style.display = "none";
nostrModalConnectContainer.style.display = "block";
modal.close();
};
// Add event listener to close the modal
nostrModalClose.addEventListener("click", function () {
modal.close();
});
// Add event listeners to switch between sign in and create account
nostrModalSwitchToSignIn.addEventListener("click", function () {
nostrModalCreateContainer.style.display = "none";
nostrModalConnectContainer.style.display = "block";
});
nostrModalSwitchToCreateAccount.addEventListener("click", function () {
nostrModalCreateContainer.style.display = "block";
nostrModalConnectContainer.style.display = "none";
});
/**
*
* Create account form
*
*/
// Add event listener to the username input to check availability
nostrModalNip05.addEventListener("input", function () {
checkNip05Availability(`${nostrModalNip05.value}@${nostrModalBunker.value}`, localBunker).then(
(available) => {
if (available) {
nostrModalNip05.setCustomValidity("");
nostrModalCreateSubmit.disabled = false;
nostrModalNip05.classList.remove("invalid");
nostrModalNip05Error.style.display = "none";
nostrModalBunkerError.style.display = "none";
} else {
nostrModalCreateSubmit.disabled = true;
nostrModalNip05.setCustomValidity("Username is not available");
nostrModalNip05.classList.add("invalid");
nostrModalNip05Error.style.display = "block";
}
}
);
});
// Add an event listener to the form to create the account
nostrModalCreateSubmit.addEventListener("click", async function (event) {
event.preventDefault();
nostrModalCreateSubmit.disabled = true;
nostrModalCreateSubmitText.style.display = "none";
nostrModalCreateSubmitSpinner.style.display = "block";
const bunkerPubkey = availableBunkers.find((bunker) => bunker.domain === nostrModalBunker.value)?.pubkey;
// Add error if we don't have valid details
if (!nostrModalBunker.value || !bunkerPubkey) {
nostrModalCreateSubmit.disabled = true;
nostrModalBunker.setCustomValidity("Error creating account. Please try again later.");
nostrModalBunker.classList.add("invalid");
nostrModalBunkerError.style.display = "block";
// Remove spinner and re-enable submit button
nostrModalCreateSubmit.disabled = false;
nostrModalCreateSubmitText.style.display = "block";
nostrModalCreateSubmitSpinner.style.display = "none";
return;
}
// Trigger the create account flow
createAccount(
bunkerPubkey,
nostrModalNip05.value,
nostrModalBunker.value,
nostrModalEmail.value || undefined
)
.then((response) => {
if (response && response.error) {
openNewWindow(`${response.error}?redirect_uri=${options.redirectUri}`);
}
})
.catch((error) => console.error(error));
});
/**
*
* Sign in form
*
*/
// Add event listener to enable submit button
nostrModalNpubOrNip05.addEventListener("input", function () {
nostrModalNpubOrNip05Error.innerText = "";
nostrModalNpubOrNip05Error.style.display = "none";
nostrModalNpubOrNip05.classList.remove("invalid");
if (nostrModalNpubOrNip05.value.length > 0) {
nostrModalSignInSubmit.disabled = false;
} else {
nostrModalSignInSubmit.disabled = true;
}
});
// Add event listener for sign in form
nostrModalSignInSubmit.addEventListener("click", async function (event) {
event.preventDefault();
nostrModalSignInSubmit.disabled = true;
nostrModalSignInSubmitText.style.display = "none";
nostrModalSignInSubmitSpinner.style.display = "block";
let remotePubkey: string | null = null;
// Order is important here. NIP05_REGEX is pretty loose so it will match an npub
if (NPUB_REGEX.test(nostrModalNpubOrNip05.value)) {
// Decode pubkey from npub
remotePubkey = decode(nostrModalNpubOrNip05.value).data as string;
} else if (PUBKEY_REGEX.test(nostrModalNpubOrNip05.value)) {
// Looks like a pubkey
remotePubkey = nostrModalNpubOrNip05.value;
} else if (NIP05_REGEX.test(nostrModalNpubOrNip05.value)) {
// Look up pubkey for nip05
const profilePointer = await queryProfile(nostrModalNpubOrNip05.value);
if (profilePointer) {
remotePubkey = profilePointer.pubkey;
} else {
nostrModalNpubOrNip05.setCustomValidity("Error fetching Pubkey from NIP-05 value.");
nostrModalNpubOrNip05.classList.add("invalid");
nostrModalNpubOrNip05Error.innerText = "Error fetching Pubkey from NIP-05 value.";
nostrModalNpubOrNip05Error.style.display = "block";
// Remove spinner and re-enable submit button
nostrModalSignInSubmit.disabled = false;
nostrModalSignInSubmitText.style.display = "block";
nostrModalSignInSubmitSpinner.style.display = "none";
return;
}
} else {
// Nothing matches the value - it's an error.
nostrModalNpubOrNip05.setCustomValidity("Invalid Pubkey, npub, or NIP-05");
nostrModalNpubOrNip05.classList.add("invalid");
// Remove spinner and re-enable submit button
nostrModalSignInSubmit.disabled = false;
nostrModalSignInSubmitText.style.display = "block";
nostrModalSignInSubmitSpinner.style.display = "none";
return;
}
connect(remotePubkey)
.then((response) => {
if (response && response.result === "ack") {
hasConnected = true;
resetForms();
}
})
.catch((error) => console.error(error));
signInTimeoutFunction = setTimeout(() => {
nostrModalNpubOrNip05Error.innerText =
"No response from a remote signer. Are you sure there is an available remote signer managing this Pubkey?";
nostrModalNpubOrNip05Error.style.display = "block";
// Remove spinner and re-enable submit button
nostrModalSignInSubmit.disabled = false;
nostrModalSignInSubmitText.style.display = "block";
nostrModalSignInSubmitSpinner.style.display = "none";
}, SIGNIN_TIMEOUT);
});
nip46.on("authChallengeSuccess", async (response: Nip46Response) => {
if (response.result === "ack") {
console.log("Connected to bunker");
hasConnected = true;
resetForms();
} else if (response.result === "pong") {
console.log("Pong!");
} else if (PUBKEY_REGEX.test(response.result)) {
console.log("Account created with pubkey: ", response.result);
nip46.remotePubkey = response.result;
hasConnected = true;
resetForms();
}
});
}
};
// Function to create and show the modal using <dialog>
const createModal = async (): Promise<HTMLDialogElement> => {
// Create the dialog element
const dialog: HTMLDialogElement = document.createElement("dialog");
dialog.id = "nostr_ignition__nostrModal";
// Add content to the dialog
const dialogContent: HTMLDivElement = document.createElement("div");
dialogContent.innerHTML = `
<button id="nostr_ignition__nostrModalClose"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-square"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg></button>
<h2 id="nostr_ignition__nostrModalTitle"><span id="nostr_ignition__appName">This app</span> uses Nostr for accounts</h2>
<div id="nostr_ignition__createAccount">
<p>Would you like to create a new Nostr account? Identities on Nostr are portable so you'll be able to use this account on any other Nostr client.</p>
<form id="nostr_ignition__nostrCreateAccountForm" class="nostr_ignition__nostrModalForm">
<span class="nostr_ignition__inputWrapper">
<input type="text" id="nostr_ignition__nostrModalNip05" name="nostrModalNip05" placeholder="Username" required />
<select id="nostr_ignition__nostrModalBunker" name="nostrModalBunker" required>
</select>
</span>
<span id="nostr_ignition__nostrModalNip05Error" class="nostr_ignition__nostrModalError">Username not available</span>
<span id="nostr_ignition__nostrModalBunkerError" class="nostr_ignition__nostrModalError">Error creating account</span>
<span class="nostr_ignition__inputWrapperFull">
<input type="email" id="nostr_ignition__nostrModalEmail" name="nostrModalEmail" placeholder="Email address. Optional, for account recovery." />
</span>
<button type="submit" id="nostr_ignition__nostrModalCreateSubmit" disabled>
<span id="nostr_ignition__nostrModalCreateSubmitText">Create account</span>
<span id="nostr_ignition__nostrModalCreateSubmitSpinner"></span>
</button>
</form>
<button id="nostr_ignition__switchToSignIn" class="nostr_ignition__linkButton">Already have a Nostr account? Sign in instead.</button>
</div>
<div id="nostr_ignition__connectAccount" style="display:none;">
<p style="text-align: center;">Sign in with your Pubkey, npub, or NIP-05.</p>
<form id="nostr_ignition__nostrSignInForm" class="nostr_ignition__nostrModalForm">
<span class="nostr_ignition__inputWrapper">
<input type="text" id="nostr_ignition__nostrModalNpubOrNip05" name="nostrModalNpubOrNip05" placeholder="Pubkey, npub, or NIP-05" required />
</span>
<span id="nostr_ignition__nostrModalNpubOrNip05Error" class="nostr_ignition__nostrModalError"></span>
<button type="submit" id="nostr_ignition__nostrModalSignInSubmit" disabled>
<span id="nostr_ignition__nostrModalSignInSubmitText">Sign in</span>
<span id="nostr_ignition__nostrModalSignInSubmitSpinner"></span>
</button>
</form>
<button id="nostr_ignition__switchToCreateAccount" class="nostr_ignition__linkButton">No Nostr account? Create a new account.</button>
</div>
<div id="nostr_ignition__nostrModalLearnMore">Not sure what Nostr is? Check out <a href="https://nostr.how" target="_blank">Nostr.how</a> for more info</div>
`;
dialog.appendChild(dialogContent);
// Append the dialog to the document body
document.body.appendChild(dialog);
return dialog;
};
// Function to show the modal
const showModal = (dialog: HTMLDialogElement): void => {
dialog.showModal();
};
/**
* Opens a new window with the specified URL.
* @param url - The URL to open in the new window.
*/
const openNewWindow = (url: string): void => {
const width = 600; // Desired width of the window
const height = 800; // Desired height of the window
const windowFeatures = `width=${width},height=${height},popup=yes`;
window.open(url, "nostrIgnition", windowFeatures);
};
const remoteNpub = (): string | null => {
return nip46.remotePubkey ? npubEncode(nip46.remotePubkey) : null;
};
const remotePubkey = (): string | null => {
return nip46.remotePubkey;
};
const connected = (): boolean => {
return hasConnected;
};
const connect = async (remotePubkey: string): Promise<Nip46Response | void> => {
console.log("Connecting to bunker...");
return nip46
.connect(remotePubkey)
.then((response) => {
if (response.result === "auth_url" && response.error) {
openNewWindow(`${response.error}?redirect_uri=${options.redirectUri}`);
}
return response;
})
.catch((error) => console.error(error));
};
const createAccount = async (
bunkerPubkey: string,
username: string,
domain: string,
email?: string
): Promise<Nip46Response | void> => {
console.log("Creating account...");
return nip46
.createAccount(bunkerPubkey, username, domain, email)
.then((response) => response)
.catch((error) => console.error(error));
};
const ping = async (): Promise<Nip46Response | void> => {
console.log("Pinging bunker...");
nip46
.ping()
.then((response) => {
if (response.result === "pong") {
console.log("Pong!");
} else if (response.result === "auth_url" && response.error) {
openNewWindow(`${response.error}?redirect_uri=${options.redirectUri}`);
}
return response;
})
.catch((error) => console.error(error));
};
const signEvent = async (event: UnsignedEvent): Promise<Nip46Response | void> => {
console.log("Requesting signature...");
return nip46
.sign_event(event)
.then((response) => {
if (response.result === "auth_url" && response.error) {
openNewWindow(`${response.error}?redirect_uri=${options.redirectUri}`);
} else {
console.log("Signed event:", JSON.parse(response.result));
}
return response;
})
.catch((error) => {
console.error(error);
});
};
export default {
init,
createAccount,
ping,
connect,
signEvent,
remoteNpub,
remotePubkey,
connected,
};

View File

@ -0,0 +1,189 @@
@media screen and (max-width: 767px) {
#nostr_ignition__nostrModal {
width: 100%;
}
}
@media screen and (min-width: 768px) {
#nostr_ignition__nostrModal {
width: 50%;
}
}
@media screen and (min-width: 1024px) {
#nostr_ignition__nostrModal {
width: 40%;
}
}
@media screen and (min-width: 1440px) {
#nostr_ignition__nostrModal {
width: 30%;
}
}
#nostr_ignition__nostrModal {
margin: 6rem auto;
border: 1px rgba(0, 0, 0, 0.6) solid;
border-radius: 10px;
outline: none;
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.3);
}
#nostr_ignition__nostrModal::backdrop {
background: rgba(0, 0, 0, 0.5);
}
#nostr_ignition__nostrModal > div {
padding: 0.875rem;
position: relative;
}
#nostr_ignition__nostrModalClose {
position: absolute;
top: 0rem;
right: 0rem;
border: none;
font-weight: bold;
padding: 0;
background: transparent;
}
#nostr_ignition__nostrModalClose svg {
stroke: #444;
}
#nostr_ignition__nostrModalClose:hover svg {
color: #000;
fill: #eee;
stroke: #000;
}
#nostr_ignition__nostrModalTitle {
margin: 1rem 0 0 0;
text-align: center;
}
.nostr_ignition__nostrModalForm {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.nostr_ignition__inputWrapper {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.nostr_ignition__inputWrapperFull {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.nostr_ignition__nostrModalForm input[type="text"],
.nostr_ignition__nostrModalForm input[type="email"] {
padding: 0.5rem;
border: 1px rgba(0, 0, 0, 0.6) solid;
border-radius: 5px;
flex-grow: 1;
outline: none;
}
.nostr_ignition__nostrModalForm input[type="text"].invalid,
.nostr_ignition__nostrModalForm input[type="email"].invalid,
.nostr_ignition__nostrModalForm select.invalid {
border: 1px red solid;
}
.nostr_ignition__nostrModalForm select {
padding: 0.5rem;
border: 1px rgba(0, 0, 0, 0.6) solid;
border-radius: 5px;
flex-grow: 1;
outline: none;
}
.nostr_ignition__nostrModalForm label {
font-size: 0.875rem;
}
.nostr_ignition__nostrModalError {
color: red;
display: none;
align-self: flex-start;
font-size: 0.75rem;
margin-bottom: 0.5rem;
}
.nostr_ignition__nostrModalForm button[type="submit"] {
width: 100%;
padding: 0.5rem;
border: none;
border-radius: 5px;
background: #000;
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: row;
items-align: center;
justify-content: center;
}
.nostr_ignition__nostrModalForm button[type="submit"]:hover {
background: #333;
}
.nostr_ignition__nostrModalForm button[type="submit"]:disabled {
background: #555;
cursor: not-allowed;
color: #ccc;
}
.nostr_ignition__linkButton {
background: transparent;
border: none;
color: #000;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
margin: 1rem auto;
display: block;
}
#nostr_ignition__nostrModalLearnMore {
margin-top: 2rem;
text-align: center;
font-size: 0.75rem;
color: #555;
}
/* Loading spinner */
#nostr_ignition__nostrModalCreateSubmitSpinner,
#nostr_ignition__nostrModalSignInSubmitSpinner {
display: none;
}
#nostr_ignition__nostrModalCreateSubmitSpinner:after,
#nostr_ignition__nostrModalSignInSubmitSpinner:after {
content: " ";
display: block;
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
border: 0.2rem solid #fff;
border-color: #fff transparent #fff transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,3 @@
import NostrIgnition from "./NostrIgnition";
Object.assign(window, { NostrIgnition });

View File

@ -0,0 +1,5 @@
import { expect, test } from "bun:test";
test("2 + 2", () => {
expect(2 + 2).toBe(4);
});

View File

@ -0,0 +1,308 @@
import { SimplePool } from "nostr-tools/pool";
import { generateSecretKey, getPublicKey, finalizeEvent, type Event, type UnsignedEvent } from "nostr-tools/pure";
import type { SubCloser, SubscribeManyParams, VerifiedEvent } from "nostr-tools";
import { encrypt, decrypt } from "nostr-tools/nip04";
import { NostrConnect, Handlerinformation } from "nostr-tools/kinds";
import { bytesToHex } from "@noble/hashes/utils";
import { EventEmitter } from "events";
import { validateBunkerNip05, generateReqId } from "./utils";
const DEFAULT_RELAYS = ["wss://relay.nostr.band", "wss://relay.nsecbunker.com"];
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const NPUB_REGEX = /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/;
export const PUBKEY_REGEX = /^[0-9a-z]{64}$/;
export type KeyPair = { privateKey: Uint8Array; publicKey: string };
export type Nip46Response = {
id: string;
result: string;
error?: string;
event: Event;
};
export type BunkerProfile = {
pubkey: string;
domain: string;
nip05: string;
name: string;
picture: string;
about: string;
website: string;
local: boolean;
};
// If you're running a local nsecbunker for testing you can add it here
// to have it show up in the list of available bunkers.
// The pubkey is the pubkey of the nsecbunker, not the localNostrPubkey
// The domain must be the domain configured in the nsecbunker.json file
// All other fields are optional
// eslint-disable-next-line prefer-const
let localBunker: BunkerProfile | undefined = undefined;
// Uncomment this block to add a local nsecbunker for testing
// localBunker = {
// pubkey: "2ba00ed9b2108bf16de47fb3e2656bed051e314b1afa4dc04c213e67f41f28e1",
// nip05: "",
// domain: "really-trusted-oyster.ngrok-free.app",
// name: "",
// picture: "",
// about: "",
// website: "",
// local: true,
// };
export class Nip46 extends EventEmitter {
private pool: SimplePool;
private subscription: SubCloser | undefined;
private relays: string[];
public keys: KeyPair | undefined;
public remotePubkey: string | null;
/**
* Creates a new instance of the Nip46 class.
* @param relays - An optional array of relay addresses.
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param keys - An optional key pair.
*/
public constructor(keys?: KeyPair, remotePubkey?: string, relays?: string[]) {
super();
this.pool = new SimplePool();
this.relays = relays || DEFAULT_RELAYS;
this.remotePubkey = remotePubkey || null;
this.keys = keys || this.generateAndStoreKey();
if (!this.subscription) this.subscribeToNostrConnectEvents();
}
/**
* Generates a key pair, stores the keys in localStorage.
*
* @private
* @returns {void}
*/
private generateAndStoreKey(): KeyPair {
const privateKey = generateSecretKey();
const publicKey = getPublicKey(privateKey);
// localNostrPubkey is the key that we use to publish events asking nsecbunkers for real signatures
localStorage.setItem("localNostrPubkey", publicKey);
localStorage.setItem("localNostrPrivkey", bytesToHex(privateKey));
return { privateKey, publicKey };
}
/**
* Subscribes to Nostr Connect events (kind 24133 and 24134) for the provided keys and relays.
* It sets up a subscription to receive events and emit events for the received responses.
*
* @private
* @returns {void}
*/
private subscribeToNostrConnectEvents(): void {
// Bail early if we don't have a local keypair
if (!this.keys) return;
// We do this alias because inside the onevent function, `this` is the event object
// eslint-disable-next-line @typescript-eslint/no-this-alias
// const nip46 = this;
const parseResponseEvent = this.parseResponseEvent.bind(this);
const subManyParams: SubscribeManyParams = {
async onevent(event) {
parseResponseEvent(event);
},
oneose() {
console.log("EOSE received");
},
};
this.subscription = this.pool.subscribeMany(
this.relays,
[{ kinds: [NostrConnect, 24134], "#p": [this.keys.publicKey] }],
subManyParams
);
}
/**
* Fetches info on available signers (nsecbunkers) using NIP-89 events.
*
* @returns A promise that resolves to an array of available bunker objects.
*/
async fetchBunkers(): Promise<BunkerProfile[]> {
const events = await this.pool.querySync(this.relays, { kinds: [Handlerinformation] });
// Filter for events that handle the connect event kind
const filteredEvents = events.filter((event) =>
event.tags.some((tag) => tag[0] === "k" && tag[1] === NostrConnect.toString())
);
// Validate bunkers by checking their NIP-05 and pubkey
// Map to a more useful object
const validatedBunkers = await Promise.all(
filteredEvents.map(async (event) => {
const content = JSON.parse(event.content);
const valid = await validateBunkerNip05(content.nip05, event.pubkey);
if (valid) {
return {
pubkey: event.pubkey,
nip05: content.nip05,
domain: content.nip05.split("@")[1],
name: content.name || content.display_name,
picture: content.picture,
about: content.about,
website: content.website,
local: false,
};
}
})
);
// Add local bunker if it exists
if (localBunker) validatedBunkers.unshift(localBunker);
return validatedBunkers.filter((bunker) => bunker !== undefined) as BunkerProfile[];
}
async sendRequest(id: string, method: string, params: string[], remotePubkey?: string): Promise<Nip46Response> {
if (!this.keys) throw new Error("No keys found");
const remotePk: string = (remotePubkey || this.remotePubkey) as string;
if (!remotePk) throw new Error("No remote public key found");
// Encrypt the content for the bunker (NIP-04)
const encryptedContent = await encrypt(this.keys.privateKey, remotePk, JSON.stringify({ id, method, params }));
// Create event to sign
const verifiedEvent: VerifiedEvent = finalizeEvent(
{
kind: method === "create_account" ? 24134 : NostrConnect,
tags: [["p", remotePk]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000),
},
this.keys.privateKey
);
// Build auth_url handler
const authHandler = (response: Nip46Response) => {
if (response.result) {
this.emit("authChallengeSuccess", response);
} else {
this.emit("authChallengeError", response.error);
}
};
// Build the response handler
const responsePromise = new Promise<Nip46Response>((resolve, reject) => {
this.once(`response-${id}`, (response: Nip46Response) => {
// Create account or auth challenge
if (response.result === "auth_url") {
this.once(`response-${id}`, authHandler);
resolve(response);
} else if (response.error) {
reject(response.error);
} else {
resolve(response);
}
});
});
// Publish the event
await Promise.any(this.pool.publish(this.relays, verifiedEvent));
return responsePromise;
}
/**
* Parses a response event and decrypts its content using the recipient's private key.
*
* @param event - The response event to parse.
* @throws {Error} If no keys are found.
* @returns An object containing the parsed response event data.
*/
async parseResponseEvent(responseEvent: Event): Promise<Nip46Response> {
if (!this.keys) throw new Error("No keys found");
const decryptedContent = await decrypt(this.keys.privateKey, responseEvent.pubkey, responseEvent.content);
const parsedContent = JSON.parse(decryptedContent);
const { id, result, error, event } = parsedContent;
this.emit(`response-${id}`, parsedContent as Nip46Response);
return { id, result, error, event };
}
/**
* Sends a ping request to the remote server.
* Requires permission/access rights to bunker.
* @throws {Error} If no keys are found or no remote public key is found.
* @returns "Pong" if successful. The promise will reject if the response is not "pong".
*/
async ping(): Promise<Nip46Response> {
if (!this.keys) throw new Error("No keys found");
if (!this.remotePubkey) throw new Error("No remote public key found");
const reqId = generateReqId();
const params: string[] = [];
return this.sendRequest(reqId, "ping", params, this.remotePubkey);
}
/**
* Connects to a remote server using the provided keys and remote public key.
* Optionally, a secret can be provided for additional authentication.
*
* @param remotePubkey - Optional the remote public key to connect to.
* @param secret - Optional secret for additional authentication.
* @throws {Error} If no keys are found or no remote public key is found.
* @returns "ack" if successful. The promise will reject if the response is not "ack".
*/
async connect(remotePubkey?: string, secret?: string): Promise<Nip46Response> {
if (!this.keys) throw new Error("No keys found");
if (remotePubkey) this.remotePubkey = remotePubkey;
if (!this.remotePubkey) throw new Error("No remote public key found");
const reqId = generateReqId();
const params: string[] = [this.keys.publicKey];
if (secret) params.push(secret);
return this.sendRequest(reqId, "connect", params, remotePubkey);
}
/**
* Signs an event using the remote private key.
* @param event - The event to sign.
* @throws {Error} If no keys are found or no remote public key is found.
* @returns A Promise that resolves to the signed event.
*/
async sign_event(event: UnsignedEvent): Promise<Nip46Response> {
if (!this.keys) throw new Error("No keys found");
if (!this.remotePubkey) throw new Error("No remote public key found");
const reqId = generateReqId();
// Only param is the event to sign
const params: string[] = [JSON.stringify(event)];
return this.sendRequest(reqId, "sign_event", params, this.remotePubkey);
}
/**
* Creates an account with the specified username, domain, and optional email.
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
* @param username - The username for the account.
* @param domain - The domain for the account.
* @param email - The optional email for the account.
* @throws Error if no keys are found, no remote public key is found, or the email is present but invalid.
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
*/
async createAccount(
bunkerPubkey: string,
username: string,
domain: string,
email?: string
): Promise<Nip46Response> {
if (!this.keys) throw new Error("No keys found");
if (email && !EMAIL_REGEX.test(email)) throw new Error("Invalid email");
const reqId = generateReqId();
const params = [username, domain];
if (email) params.push(email);
return this.sendRequest(reqId, "create_account", params, bunkerPubkey);
}
}

View File

@ -0,0 +1,5 @@
import { expect, test } from "bun:test";
test("2 + 2", () => {
expect(2 + 2).toBe(4);
});

View File

@ -0,0 +1,56 @@
import { BunkerProfile } from "./nip46";
/**
* Checks the availability of a NIP05 address on a given domain.
*
* @param nip05 - The NIP05 address to check.
* @throws {Error} If the NIP05 address is invalid. e.g. not in the form `name@domain`.
* @returns A promise that resolves to a boolean indicating the availability of the NIP05 address.
*/
export async function checkNip05Availability(nip05: string, localBunker?: BunkerProfile): Promise<boolean> {
if (nip05.split("@").length !== 2) throw new Error("Invalid nip05");
// Skip availability check if the nip05 is for the local bunker
if (localBunker && nip05.split("@")[1] === localBunker.domain) return true;
const [username, domain] = nip05.split("@");
try {
const response = await fetch(`https://${domain}/.well-known/nostr.json?name=${username}`);
const json = await response.json();
return json.names[username] === undefined ? true : false;
} catch (error) {
console.error(error);
return false;
}
}
/**
* Validates a Bunker's NIP-05.
*
* @param nip05 - The NIP05 to validate.
* @param pubkey - The public key to compare against.
* @returns A promise that resolves to a boolean indicating whether the NIP05 is valid for the bunkers pubkey.
* Will also return false for invalid nip05 format.
*/
export async function validateBunkerNip05(nip05: string, pubkey: string): Promise<boolean> {
if (nip05.split("@").length !== 2) return false;
const domain = nip05.split("@")[1];
try {
const response = await fetch(`https://${domain}/.well-known/nostr.json?name=_`);
const json = await response.json();
return json.names["_"] === pubkey;
} catch (error) {
console.error(error);
return false;
}
}
/**
* Generates a unique request ID.
*
* @returns {string} The generated request ID.
*/
export function generateReqId(): string {
return Math.random().toString(36).substring(7);
}

View File

@ -4,7 +4,6 @@ import { createApp } from 'vue'
import App from './App.vue'
import store from './store';
import "./app.css"
import 'vue3-easy-data-table/dist/style.css';
import router from './router'
import Vue3EasyDataTable from 'vue3-easy-data-table';
@ -30,6 +29,8 @@ function toast ({title, message, type, timeout, cb}) {
return miniToastr[type](message, title, timeout, cb)
}
const options = {
success: toast,
error: toast,
@ -42,7 +43,6 @@ createApp(App)
.use(VueNotifications, options)
.use(store)
.use(router)
.component('EasyDataTable', Vue3EasyDataTable)
.component('VueDatePicker', VueDatePicker)
.mount('#app')

View File

@ -11,6 +11,10 @@
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"paths": {
"@/*": [
"./*",]
},
"module": "ESNext",
"moduleResolution": "Bundler",