shogun-core
Version:
SHOGUN SDK - Core library for Shogun SDK
683 lines (682 loc) • 29.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.NostrConnector = void 0;
/**
* The BitcoinWallet class provides functionality for connecting, signing up, and logging in using Bitcoin wallets.
* Supports Alby and Nostr extensions, as well as manual key management.
*/
const logger_1 = require("../../utils/logger");
const errorHandler_1 = require("../../utils/errorHandler");
const eventEmitter_1 = require("../../utils/eventEmitter");
/**
* Class for Bitcoin wallet connections and operations
*/
class NostrConnector extends eventEmitter_1.EventEmitter {
MESSAGE_TO_SIGN = "I Love Shogun!";
DEFAULT_CONFIG = {
cacheDuration: 24 * 60 * 60 * 1000, // 24 hours instead of 30 minutes for better UX
maxRetries: 3,
retryDelay: 1000,
timeout: 60000,
network: "mainnet",
useApi: false,
};
config;
signatureCache = new Map();
// Connection state
connectedAddress = null;
connectedType = null;
manualKeyPair = null;
constructor(config = {}) {
super();
this.config = { ...this.DEFAULT_CONFIG, ...config };
this.setupEventListeners();
}
/**
* Setup event listeners
*/
setupEventListeners() {
// Currently no global events to listen to
// This would be the place to add listeners for wallet connections/disconnections
}
/**
* Cleanup event listeners
*/
cleanup() {
this.removeAllListeners();
this.connectedAddress = null;
this.connectedType = null;
this.manualKeyPair = null;
}
/**
* Get cached signature if valid
*/
getCachedSignature(address) {
// First check in-memory cache
const cached = this.signatureCache.get(address);
if (cached) {
const now = Date.now();
if (now - cached.timestamp <= this.config.cacheDuration) {
return cached.signature;
}
else {
this.signatureCache.delete(address);
}
}
// Then check localStorage for persistence across page reloads
try {
const localStorageKey = `shogun_bitcoin_sig_${address}`;
const localCached = localStorage.getItem(localStorageKey);
if (localCached) {
const parsedCache = JSON.parse(localCached);
const now = Date.now();
if (now - parsedCache.timestamp <= this.config.cacheDuration) {
// Restore to in-memory cache
this.signatureCache.set(address, parsedCache);
return parsedCache.signature;
}
else {
// Remove expired cache
localStorage.removeItem(localStorageKey);
}
}
}
catch (error) {
(0, logger_1.logError)("Error reading signature cache from localStorage:", error);
}
return null;
}
/**
* Cache signature
*/
cacheSignature(address, signature) {
const cacheEntry = {
signature,
timestamp: Date.now(),
address,
};
// Store in memory
this.signatureCache.set(address, cacheEntry);
// Store in localStorage for persistence
try {
const localStorageKey = `shogun_bitcoin_sig_${address}`;
localStorage.setItem(localStorageKey, JSON.stringify(cacheEntry));
(0, logger_1.log)(`Cached signature for address: ${address.substring(0, 10)}...`);
}
catch (error) {
(0, logger_1.logError)("Error saving signature cache to localStorage:", error);
}
}
/**
* Clear signature cache for a specific address or all addresses
*/
clearSignatureCache(address) {
if (address) {
// Clear cache for specific address
this.signatureCache.delete(address);
try {
const localStorageKey = `shogun_bitcoin_sig_${address}`;
localStorage.removeItem(localStorageKey);
(0, logger_1.log)(`Cleared signature cache for address: ${address.substring(0, 10)}...`);
}
catch (error) {
(0, logger_1.logError)("Error clearing signature cache from localStorage:", error);
}
}
else {
// Clear all signature caches
this.signatureCache.clear();
try {
// Find and remove all shogun_bitcoin_sig_ keys
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("shogun_bitcoin_sig_")) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
(0, logger_1.log)(`Cleared all signature caches (${keysToRemove.length} entries)`);
}
catch (error) {
(0, logger_1.logError)("Error clearing all signature caches from localStorage:", error);
}
}
}
/**
* Validates that the address is valid
*/
validateAddress(address) {
if (!address) {
throw new Error("Address not provided");
}
try {
const normalizedAddress = String(address).trim();
// Basic validation for Bitcoin addresses and Nostr pubkeys
if (this.connectedType === "nostr") {
// Nostr pubkeys are hex strings (64 chars) or npub-prefixed keys
// Just check if it's a non-empty string, as we're getting it directly from the extension
if (!normalizedAddress) {
throw new Error("Empty Nostr public key");
}
}
else if (this.connectedType === "manual") {
// For manual keys, just ensure we have something
if (!normalizedAddress) {
throw new Error("Empty manual key");
}
}
else {
// Simple format check for Bitcoin addresses
// More sophisticated validation would require a library
if (!/^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,59}$/.test(normalizedAddress)) {
throw new Error("Invalid Bitcoin address format");
}
}
return normalizedAddress;
}
catch (error) {
errorHandler_1.ErrorHandler.handle(errorHandler_1.ErrorType.VALIDATION, "INVALID_ADDRESS", "Invalid Bitcoin address provided", error);
throw error;
}
}
/**
* Check if Nostr extension is available
*/
isNostrExtensionAvailable() {
return typeof window !== "undefined" && !!window.nostr;
}
/**
* Check if any Bitcoin wallet is available
*/
isAvailable() {
return this.isNostrExtensionAvailable() || this.manualKeyPair !== null;
}
/**
* Connect to a wallet type
*/
async connectWallet(type = "nostr") {
(0, logger_1.log)(`Connecting to Bitcoin wallet via ${type}...`);
try {
let result;
// Attempt to connect to the specified wallet type
switch (type) {
case "alby":
(0, logger_1.log)("Alby is deprecated, redirecting to Nostr");
result = await this.connectNostr();
break;
case "nostr":
result = await this.connectNostr();
break;
case "manual":
result = await this.connectManual();
break;
default:
throw new Error(`Unsupported wallet type: ${type}`);
}
if (result.success && result.address) {
this.connectedAddress = result.address;
this.connectedType = type;
(0, logger_1.log)(`Successfully connected to ${type} wallet: ${result.address}`);
this.emit("wallet_connected", {
address: result.address,
type: this.connectedType,
});
}
return result;
}
catch (error) {
(0, logger_1.logError)(`Error connecting to ${type} wallet:`, error);
return {
success: false,
error: error.message || "Failed to connect to wallet",
};
}
}
/**
* Connect to Nostr extension
*/
async connectNostr() {
if (!this.isNostrExtensionAvailable()) {
return {
success: false,
error: "Nostr extension is not available. Please install a Nostr compatible extension.",
};
}
try {
// Get public key from Nostr extension
const pubKey = await window.nostr.getPublicKey();
if (!pubKey) {
throw new Error("Could not get public key from Nostr extension");
}
this.connectedAddress = pubKey;
this.connectedType = "nostr";
// Emit connected event
this.emit("connected", { address: pubKey, type: "nostr" });
const username = `nostr_${pubKey.substring(0, 10)}`;
return {
success: true,
address: pubKey,
username,
extensionType: "nostr",
};
}
catch (error) {
throw new Error(`Nostr connection error: ${error.message}`);
}
}
/**
* Set up manual key pair for connection
*/
async connectManual() {
// For manual connection, we'd need to have a keypair set
if (!this.manualKeyPair) {
return {
success: false,
error: "No manual key pair configured. Use setKeyPair() first.",
};
}
this.connectedAddress = this.manualKeyPair.address;
this.connectedType = "manual";
// Emit connected event
this.emit("connected", {
address: this.manualKeyPair.address,
type: "manual",
});
const username = `btc_${this.manualKeyPair.address.substring(0, 10)}`;
return {
success: true,
address: this.manualKeyPair.address,
username,
extensionType: "manual",
};
}
/**
* Set a manual key pair for use
*/
setKeyPair(keyPair) {
this.manualKeyPair = keyPair;
if (keyPair.address) {
this.connectedAddress = keyPair.address;
this.connectedType = "manual";
}
}
/**
* Generate credentials using the connected wallet
*/
async generateCredentials(address) {
(0, logger_1.logDebug)(`Generating credentials for address: ${address}`);
try {
// Set the connectedType to nostr if it's not already set
// This ensures validateAddress will use the correct validation logic
if (!this.connectedType && address) {
// If the address looks like a Nostr pubkey (hex string or npub prefix)
if (/^[0-9a-f]{64}$/.test(address) || address.startsWith("npub")) {
this.connectedType = "nostr";
this.connectedAddress = address;
}
}
// Validate the address
const validAddress = this.validateAddress(address);
// Check cache first
const cachedSignature = this.getCachedSignature(validAddress);
if (cachedSignature) {
(0, logger_1.logDebug)("Using cached signature");
return await this.generateCredentialsFromSignature(validAddress, cachedSignature);
}
// For consistent authentication, we need to use a deterministic approach
// that doesn't require a fresh signature each time
const message = this.MESSAGE_TO_SIGN;
let signature;
// NEW STRATEGY: Always try deterministic signature first for consistency
// This ensures that existing users get the same credentials even after localStorage.clear()
try {
// First, try to use a deterministic signature based on the address
// This ensures consistent credentials across sessions
signature = await this.generateDeterministicSignature(validAddress);
(0, logger_1.log)("Using deterministic signature for consistency");
// Cache this deterministic signature for future use
this.cacheSignature(validAddress, signature);
return await this.generateCredentialsFromSignature(validAddress, signature);
}
catch (deterministicError) {
(0, logger_1.logError)("Error generating deterministic signature:", deterministicError);
// Fallback to requesting a real signature only if deterministic fails
try {
signature = await this.requestSignatureWithTimeout(validAddress, message, this.config.timeout);
// Cache the signature for future use
this.cacheSignature(validAddress, signature);
(0, logger_1.log)("Using real Nostr signature as fallback");
}
catch (signError) {
(0, logger_1.logError)("Error requesting signature:", signError);
// Final fallback: use deterministic signature anyway
signature = await this.generateDeterministicSignature(validAddress);
this.cacheSignature(validAddress, signature);
(0, logger_1.log)("Using deterministic signature as final fallback");
}
}
return await this.generateCredentialsFromSignature(validAddress, signature);
}
catch (error) {
(0, logger_1.logError)("Error generating credentials:", error);
throw error;
}
}
/**
* Generate a deterministic signature for consistent authentication
*/
async generateDeterministicSignature(address) {
// Create a deterministic signature based on the address and a fixed message
// This ensures the same credentials are generated each time for the same address
const baseString = `${address}_${this.MESSAGE_TO_SIGN}_shogun_deterministic`;
// Simple hash function to create a deterministic signature
let hash = "";
let runningValue = 0;
for (let i = 0; i < baseString.length; i++) {
const charCode = baseString.charCodeAt(i);
runningValue = (runningValue * 31 + charCode) & 0xffffffff;
if (i % 4 === 3) {
hash += runningValue.toString(16).padStart(8, "0");
}
}
// Ensure we have exactly 128 characters (64 bytes in hex)
while (hash.length < 128) {
runningValue = (runningValue * 31 + hash.length) & 0xffffffff;
hash += runningValue.toString(16).padStart(8, "0");
}
// Ensure the result is exactly 128 characters and contains only valid hex characters
let deterministicSignature = hash.substring(0, 128);
// Double-check that it's a valid hex string
deterministicSignature = deterministicSignature
.toLowerCase()
.replace(/[^0-9a-f]/g, "0");
// Ensure it's exactly 128 characters
if (deterministicSignature.length < 128) {
deterministicSignature = deterministicSignature.padEnd(128, "0");
}
else if (deterministicSignature.length > 128) {
deterministicSignature = deterministicSignature.substring(0, 128);
}
(0, logger_1.log)(`Generated deterministic signature: ${deterministicSignature.substring(0, 16)}... (${deterministicSignature.length} chars)`);
return deterministicSignature;
}
/**
* Generate credentials from an existing signature
*/
async generateCredentialsFromSignature(address, signature) {
(0, logger_1.log)("Generating credentials from signature");
// Create deterministic username based on the address - similar to MetaMask's approach
const username = `${address.toLowerCase()}`;
// Create password from the signature
const password = await this.generatePassword(signature);
return {
username,
password,
message: this.MESSAGE_TO_SIGN,
signature,
};
}
/**
* Generate a password from a signature
*/
async generatePassword(signature) {
if (!signature) {
throw new Error("Invalid signature");
}
try {
// Create a deterministic hash from the signature
// Following a similar approach to the Ethereum connector for consistency
// Normalize the signature to ensure it's clean
const normalizedSig = signature.toLowerCase().replace(/[^a-f0-9]/g, "");
// Create a hash using a simple algorithm
// In a production environment, you would use a proper crypto library
// For example:
// const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalizedSig));
// const hashArray = Array.from(new Uint8Array(hashBuffer));
// const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// For now, implement a simple deterministic hash
let hash = "";
let runningValue = 0;
for (let i = 0; i < normalizedSig.length; i++) {
const charCode = normalizedSig.charCodeAt(i);
runningValue = (runningValue * 31 + charCode) & 0xffffffff;
if (i % 8 === 7) {
hash += runningValue.toString(16).padStart(8, "0");
}
}
// Ensure we have at least 64 characters
while (hash.length < 64) {
runningValue = (runningValue * 31 + hash.length) & 0xffffffff;
hash += runningValue.toString(16).padStart(8, "0");
}
// Trim to 64 characters (matching the Ethereum approach that returns 64 chars)
return hash.substring(0, 64);
}
catch (error) {
(0, logger_1.logError)("Error generating password:", error);
throw new Error("Failed to generate password from signature");
}
}
/**
* Verify a signature
*/
async verifySignature(message, signature, address) {
try {
// Ensure address is a string
const addressStr = typeof address === "object"
? address.address || JSON.stringify(address)
: String(address);
(0, logger_1.log)(`Verifying signature for address: ${addressStr}`);
if (!signature || !message) {
(0, logger_1.logError)("Invalid message or signature for verification");
return false;
}
// Log signature details for debugging
(0, logger_1.log)(`Signature to verify: ${signature.substring(0, 20)}... (length: ${signature.length})`);
(0, logger_1.log)(`Message to verify: ${message}`);
// For Nostr wallet type, we need to use a proper verification approach
if (this.connectedType === "nostr" || this.connectedType === "alby") {
(0, logger_1.log)("Using Nostr-specific verification");
try {
// In a production implementation, we would use proper nostr verification
// using secp256k1 to verify the signature
//
// For example with nostr-tools:
// import * as nostr from 'nostr-tools';
// const event = {
// kind: 1,
// created_at: Math.floor(Date.now() / 1000),
// tags: [],
// content: message,
// pubkey: address,
// id: '', // would be computed
// sig: signature
// };
// const verified = nostr.verifySignature(event);
// return verified;
// Instead for now, we're checking if the address matches what we have connected
// This ensures at least some level of verification
if (!this.connectedAddress) {
(0, logger_1.logError)("No connected address to verify against");
return false;
}
// Convert both addresses to strings for comparison
const connectedAddrStr = String(this.connectedAddress).toLowerCase();
const verifyAddrStr = addressStr.toLowerCase();
if (verifyAddrStr !== connectedAddrStr) {
(0, logger_1.logError)("Address mismatch in signature verification");
(0, logger_1.logError)(`Expected: ${connectedAddrStr}, Got: ${verifyAddrStr}`);
return false;
}
// Basic verification that signature exists and is a hex string
const isValidHexFormat = /^[0-9a-f]+$/i.test(signature);
const hasValidLength = signature.length >= 64;
(0, logger_1.log)(`Signature format check: hex=${isValidHexFormat}, length=${hasValidLength} (${signature.length} chars)`);
if (!isValidHexFormat) {
(0, logger_1.logError)("Invalid signature format - not a valid hex string");
(0, logger_1.logError)(`Signature contains invalid characters: ${signature}`);
return false;
}
if (!hasValidLength) {
(0, logger_1.logError)(`Invalid signature length: ${signature.length} (minimum 64 required)`);
return false;
}
(0, logger_1.log)("Nostr signature appears valid");
return true;
}
catch (verifyError) {
(0, logger_1.logError)("Error in signature verification:", verifyError);
return false;
}
}
else if (this.connectedType === "manual" && this.manualKeyPair) {
(0, logger_1.log)("Using manual verification for keypair");
// For manual keypairs, implement proper signature verification
// For now, we're just checking that the address matches our keypair
try {
const manualAddrStr = String(this.manualKeyPair.address).toLowerCase();
const verifyAddrStr = addressStr.toLowerCase();
const addressMatch = verifyAddrStr === manualAddrStr;
(0, logger_1.log)(`Manual verification - address match: ${addressMatch}`);
return addressMatch;
}
catch (manualVerifyError) {
(0, logger_1.logError)("Error in manual signature verification:", manualVerifyError);
return false;
}
}
// For other wallet types or if API verification is enabled
if (this.config.useApi && this.config.apiUrl) {
(0, logger_1.log)("Using API-based verification");
try {
// In a real implementation, this would make an API call to verify
// return await this.verifySignatureViaApi(message, signature, address);
// For now, return true as this is a placeholder
return true;
}
catch (apiError) {
(0, logger_1.logError)("API verification error:", apiError);
return false;
}
}
(0, logger_1.logWarn)("No specific verification method available, signature cannot be fully verified");
return false;
}
catch (error) {
(0, logger_1.logError)("Error verifying signature:", error);
return false;
}
}
/**
* Get the currently connected address
*/
getConnectedAddress() {
return this.connectedAddress;
}
/**
* Get the currently connected wallet type
*/
getConnectedType() {
return this.connectedType;
}
/**
* Request signature with timeout
*/
requestSignatureWithTimeout(address, message, timeout = 30000) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("Signature request timed out"));
}, timeout);
this.requestSignature(address, message)
.then((signature) => {
clearTimeout(timeoutId);
resolve(signature);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
/**
* Request a signature from the connected wallet
*/
async requestSignature(address, message) {
if (!this.connectedType) {
throw new Error("No wallet connected");
}
try {
switch (this.connectedType) {
case "alby":
// Redirect Alby requests to Nostr
(0, logger_1.logWarn)("Alby is deprecated, redirecting signature request to Nostr");
if (!window.nostr) {
throw new Error("Nostr extension not available");
}
// For Nostr, we need to create an event to sign
const albyRedirectEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: message,
pubkey: address,
};
const albyRedirectResult = await window.nostr.signEvent(albyRedirectEvent);
return albyRedirectResult.sig;
case "nostr":
(0, logger_1.log)("Requesting Nostr signature for message:", message);
// For Nostr, we need to create an event to sign
const nostrEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: message,
pubkey: address,
};
const nostrSignedEvent = await window.nostr.signEvent(nostrEvent);
(0, logger_1.log)("Received Nostr signature:", nostrSignedEvent.sig.substring(0, 20) + "...");
// Normalize the signature to ensure compatibility with GunDB
let signature = nostrSignedEvent.sig;
// Ensure the signature is in the expected format (hex string)
if (signature && typeof signature === "string") {
// Remove any non-hex characters and ensure lowercase
signature = signature.replace(/[^a-f0-9]/gi, "").toLowerCase();
// Ensure it's a valid length hex string (64 bytes = 128 hex chars for secp256k1)
if (signature.length > 128) {
signature = signature.substring(0, 128);
}
else if (signature.length < 128) {
// Pad with zeros if needed (shouldn't happen with valid signatures)
signature = signature.padStart(128, "0");
}
(0, logger_1.log)(`Normalized Nostr signature: ${signature.substring(0, 10)}...`);
}
return signature;
case "manual":
(0, logger_1.log)("Using manual key pair for signature");
if (!this.manualKeyPair) {
throw new Error("No manual key pair available");
}
// In a real implementation, we would use a Bitcoin signing library here
// For now, create a deterministic signature from the private key and message
const manualSignature = `${this.manualKeyPair.privateKey.substring(0, 32)}_${message}_${Date.now()}`;
(0, logger_1.log)("Generated manual signature:", manualSignature.substring(0, 20) + "...");
return manualSignature;
default:
throw new Error(`Unsupported wallet type: ${this.connectedType}`);
}
}
catch (error) {
(0, logger_1.logError)("Error requesting signature:", error);
throw new Error(`Failed to get signature: ${error.message}`);
}
}
}
exports.NostrConnector = NostrConnector;
if (typeof window !== "undefined") {
window.NostrConnector = NostrConnector;
}
else if (typeof global !== "undefined") {
global.NostrConnector = NostrConnector;
}