UNPKG

@bsv/wallet-toolbox-client

Version:
1,006 lines 63.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CWIStyleWalletManager = exports.OverlayUMPTokenInteractor = exports.DEFAULT_PROFILE_ID = exports.PBKDF2_NUM_ROUNDS = void 0; const sdk_1 = require("@bsv/sdk"); const PrivilegedKeyManager_1 = require("./sdk/PrivilegedKeyManager"); /** * Number of rounds used in PBKDF2 for deriving password keys. */ exports.PBKDF2_NUM_ROUNDS = 7777; /** * PBKDF-2 that prefers the browser / Node 20+ WebCrypto implementation and * silently falls back to the existing JS code. * * @param passwordBytes Raw password bytes. * @param salt Salt bytes. * @param iterations Number of rounds. * @param keyLen Desired key length in bytes. * @param hash Digest algorithm (default "sha512"). * @returns Derived key bytes. */ async function pbkdf2NativeOrJs(passwordBytes, salt, iterations, keyLen, hash = 'sha512') { var _a; // ----- fast-path: WebCrypto (both browser & recent Node expose globalThis.crypto.subtle) const subtle = (_a = globalThis === null || globalThis === void 0 ? void 0 : globalThis.crypto) === null || _a === void 0 ? void 0 : _a.subtle; if (subtle) { try { const baseKey = await subtle.importKey('raw', new Uint8Array(passwordBytes), { name: 'PBKDF2' }, /*extractable*/ false, ['deriveBits']); const bits = await subtle.deriveBits({ name: 'PBKDF2', salt: new Uint8Array(salt), iterations, hash: hash.toUpperCase() }, baseKey, keyLen * 8); return Array.from(new Uint8Array(bits)); } catch (err) { //console.warn('[pbkdf2] WebCrypto path failed → falling back to JS implementation', err) /* fall through */ } } // ----- slow-path: old JavaScript implementation return sdk_1.Hash.pbkdf2(passwordBytes, salt, iterations, keyLen, hash); } /** * Unique Identifier for the default profile (16 zero bytes). */ exports.DEFAULT_PROFILE_ID = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; /** * @class OverlayUMPTokenInteractor * * A concrete implementation of the UMPTokenInteractor interface that interacts * with Overlay Services and the UMP (User Management Protocol) topic. This class * is responsible for: * * 1) Locating UMP tokens via overlay lookups (ls_users). * 2) Creating and publishing new or updated UMP token outputs on-chain under * the "tm_users" topic. * 3) Consuming (spending) an old token if provided. */ class OverlayUMPTokenInteractor { /** * Construct a new OverlayUMPTokenInteractor. * * @param resolver A LookupResolver instance for performing overlay queries (ls_users). * @param broadcaster A SHIPBroadcaster instance for sharing new or updated tokens across the `tm_users` overlay. */ constructor(resolver = new sdk_1.LookupResolver(), broadcaster = new sdk_1.SHIPBroadcaster(['tm_users'])) { this.resolver = resolver; this.broadcaster = broadcaster; } /** * Finds a UMP token on-chain by the given presentation key hash, if it exists. * Uses the ls_users overlay service to perform the lookup. * * @param hash The 32-byte SHA-256 hash of the presentation key. * @returns A UMPToken object (including currentOutpoint) if found, otherwise undefined. */ async findByPresentationKeyHash(hash) { // Query ls_users for the given presentationHash const question = { service: 'ls_users', query: { presentationHash: sdk_1.Utils.toHex(hash) } }; const answer = await this.resolver.query(question); return this.parseLookupAnswer(answer); } /** * Finds a UMP token on-chain by the given recovery key hash, if it exists. * Uses the ls_users overlay service to perform the lookup. * * @param hash The 32-byte SHA-256 hash of the recovery key. * @returns A UMPToken object (including currentOutpoint) if found, otherwise undefined. */ async findByRecoveryKeyHash(hash) { const question = { service: 'ls_users', query: { recoveryHash: sdk_1.Utils.toHex(hash) } }; const answer = await this.resolver.query(question); return this.parseLookupAnswer(answer); } /** * Creates or updates (replaces) a UMP token on-chain. If `oldTokenToConsume` is provided, * it is spent in the same transaction that creates the new token output. The new token is * then broadcast and published under the `tm_users` topic using a SHIP broadcast, ensuring * overlay participants see the updated token. * * @param wallet The wallet used to build and sign the transaction (MUST be operating under the DEFAULT profile). * @param adminOriginator The domain/FQDN of the administrative originator (wallet operator). * @param token The new UMPToken to create on-chain. * @param oldTokenToConsume Optionally, an existing token to consume/spend in the same transaction. * @returns The outpoint of the newly created UMP token (e.g. "abcd1234...ef.0"). */ async buildAndSend(wallet, // This wallet MUST be the one built for the default profile adminOriginator, token, oldTokenToConsume) { // 1) Construct the data fields for the new UMP token. const fields = []; fields[0] = token.passwordSalt; fields[1] = token.passwordPresentationPrimary; fields[2] = token.passwordRecoveryPrimary; fields[3] = token.presentationRecoveryPrimary; fields[4] = token.passwordPrimaryPrivileged; fields[5] = token.presentationRecoveryPrivileged; fields[6] = token.presentationHash; fields[7] = token.recoveryHash; fields[8] = token.presentationKeyEncrypted; fields[9] = token.passwordKeyEncrypted; fields[10] = token.recoveryKeyEncrypted; // Optional field (11) for encrypted profiles if (token.profilesEncrypted) { fields[11] = token.profilesEncrypted; } // 2) Create a PushDrop script referencing these fields, locked with the admin key. const script = await new sdk_1.PushDrop(wallet, adminOriginator).lock(fields, [2, 'admin user management token'], // protocolID '1', // keyID 'self', // counterparty /*forSelf=*/ true, /*includeSignature=*/ true); // 3) Prepare the createAction call. If oldTokenToConsume is provided, gather the outpoint. const inputs = []; let inputToken; if (oldTokenToConsume === null || oldTokenToConsume === void 0 ? void 0 : oldTokenToConsume.currentOutpoint) { inputToken = await this.findByOutpoint(oldTokenToConsume.currentOutpoint); // If there is no token on the overlay, we can't consume it. Just start over with a new token. if (!inputToken) { oldTokenToConsume = undefined; // Otherwise, add the input } else { inputs.push({ outpoint: oldTokenToConsume.currentOutpoint, unlockingScriptLength: 73, // typical signature length inputDescription: 'Consume old UMP token' }); } } const outputs = [ { lockingScript: script.toHex(), satoshis: 1, outputDescription: 'New UMP token output' } ]; // 4) Build the partial transaction via createAction. let createResult; try { createResult = await wallet.createAction({ description: oldTokenToConsume ? 'Renew UMP token (consume old, create new)' : 'Create new UMP token', inputs, outputs, inputBEEF: inputToken === null || inputToken === void 0 ? void 0 : inputToken.beef, options: { randomizeOutputs: false, acceptDelayedBroadcast: false } }, adminOriginator); } catch (e) { console.error('Error with UMP token update. Attempting a last-ditch effort to get a new one', e); createResult = await wallet.createAction({ description: 'Recover UMP token', outputs, options: { randomizeOutputs: false, acceptDelayedBroadcast: false } }, adminOriginator); } // If the transaction is fully processed by the wallet if (!createResult.signableTransaction) { const finalTxid = createResult.txid || (createResult.tx ? sdk_1.Transaction.fromAtomicBEEF(createResult.tx).id('hex') : undefined); if (!finalTxid) { throw new Error('No signableTransaction and no final TX found.'); } // Now broadcast to `tm_users` using SHIP const broadcastTx = sdk_1.Transaction.fromAtomicBEEF(createResult.tx); const result = await this.broadcaster.broadcast(broadcastTx); console.log('BROADCAST RESULT', result); return `${finalTxid}.0`; } // 5) If oldTokenToConsume is present, we must sign the input referencing it. // (If there's no old token, there's nothing to sign for the input.) let finalTxid = ''; const reference = createResult.signableTransaction.reference; const partialTx = sdk_1.Transaction.fromBEEF(createResult.signableTransaction.tx); if (oldTokenToConsume === null || oldTokenToConsume === void 0 ? void 0 : oldTokenToConsume.currentOutpoint) { // Unlock the old token with a matching PushDrop unlocker const unlocker = new sdk_1.PushDrop(wallet, adminOriginator).unlock([2, 'admin user management token'], '1', 'self'); const unlockingScript = await unlocker.sign(partialTx, 0); // Provide it to the wallet const signResult = await wallet.signAction({ reference, spends: { 0: { unlockingScript: unlockingScript.toHex() } } }, adminOriginator); finalTxid = signResult.txid || (signResult.tx ? sdk_1.Transaction.fromAtomicBEEF(signResult.tx).id('hex') : ''); if (!finalTxid) { throw new Error('Could not finalize transaction for renewed UMP token.'); } // 6) Broadcast to `tm_users` const finalAtomicTx = signResult.tx; if (!finalAtomicTx) { throw new Error('Final transaction data missing after signing renewed UMP token.'); } const broadcastTx = sdk_1.Transaction.fromAtomicBEEF(finalAtomicTx); const result = await this.broadcaster.broadcast(broadcastTx); console.log('BROADCAST RESULT', result); return `${finalTxid}.0`; } else { // Fallback for creating a new token (no input spending) const signResult = await wallet.signAction({ reference, spends: {} }, adminOriginator); finalTxid = signResult.txid || (signResult.tx ? sdk_1.Transaction.fromAtomicBEEF(signResult.tx).id('hex') : ''); if (!finalTxid) { throw new Error('Failed to finalize new UMP token transaction.'); } const finalAtomicTx = signResult.tx; if (!finalAtomicTx) { throw new Error('Final transaction data missing after signing new UMP token.'); } const broadcastTx = sdk_1.Transaction.fromAtomicBEEF(finalAtomicTx); const result = await this.broadcaster.broadcast(broadcastTx); console.log('BROADCAST RESULT', result); return `${finalTxid}.0`; } } /** * Attempts to parse a LookupAnswer from the UMP lookup service. If successful, * extracts the token fields from the resulting transaction and constructs * a UMPToken object. * * @param answer The LookupAnswer returned by a query to ls_users. * @returns The parsed UMPToken or `undefined` if none found/decodable. */ parseLookupAnswer(answer) { var _a; if (answer.type !== 'output-list') { return undefined; } if (!answer.outputs || answer.outputs.length === 0) { return undefined; } const { beef, outputIndex } = answer.outputs[0]; try { const tx = sdk_1.Transaction.fromBEEF(beef); const outpoint = `${tx.id('hex')}.${outputIndex}`; const decoded = sdk_1.PushDrop.decode(tx.outputs[outputIndex].lockingScript); // Expecting 11 or more fields for UMP if (!decoded.fields || decoded.fields.length < 11) { console.warn(`Unexpected number of fields in UMP token: ${(_a = decoded.fields) === null || _a === void 0 ? void 0 : _a.length}`); return undefined; } // Build the UMP token from these fields, preserving outpoint const t = { // Order matches buildAndSend and serialize/deserialize passwordSalt: decoded.fields[0], passwordPresentationPrimary: decoded.fields[1], passwordRecoveryPrimary: decoded.fields[2], presentationRecoveryPrimary: decoded.fields[3], passwordPrimaryPrivileged: decoded.fields[4], presentationRecoveryPrivileged: decoded.fields[5], presentationHash: decoded.fields[6], recoveryHash: decoded.fields[7], presentationKeyEncrypted: decoded.fields[8], passwordKeyEncrypted: decoded.fields[9], recoveryKeyEncrypted: decoded.fields[10], profilesEncrypted: decoded.fields[12] ? decoded.fields[11] : undefined, // If there's a signature in field 12, use field 11 currentOutpoint: outpoint }; return t; } catch (e) { console.error('Failed to parse or decode UMP token:', e); return undefined; } } /** * Finds by outpoint for unlocking / spending previous tokens. * @param outpoint The outpoint we are searching by * @returns The result so that we can use it to unlock the transaction */ async findByOutpoint(outpoint) { const results = await this.resolver.query({ service: 'ls_users', query: { outpoint } }); if (results.type !== 'output-list') { return undefined; } if (!results.outputs || !results.outputs.length) { return undefined; } return results.outputs[0]; } } exports.OverlayUMPTokenInteractor = OverlayUMPTokenInteractor; /** * Manages a "CWI-style" wallet that uses a UMP token and a * multi-key authentication scheme (password, presentation key, and recovery key), * supporting multiple user profiles under a single account. */ class CWIStyleWalletManager { /** * Constructs a new CWIStyleWalletManager. * * @param adminOriginator The domain name of the administrative originator. * @param walletBuilder A function that can build an underlying wallet instance for a profile. * @param interactor An instance of UMPTokenInteractor. * @param recoveryKeySaver A function to persist a new recovery key. * @param passwordRetriever A function to request the user's password. * @param newWalletFunder Optional function to fund a new wallet. * @param stateSnapshot Optional previously saved state snapshot. */ constructor(adminOriginator, walletBuilder, interactor = new OverlayUMPTokenInteractor(), recoveryKeySaver, passwordRetriever, newWalletFunder, stateSnapshot) { /** * Current mode of authentication. */ this.authenticationMode = 'presentation-key-and-password'; /** * Indicates new user or existing user flow. */ this.authenticationFlow = 'new-user'; /** * The currently active profile ID (null or DEFAULT_PROFILE_ID means default profile). */ this.activeProfileId = exports.DEFAULT_PROFILE_ID; /** * List of loaded non-default profiles. */ this.profiles = []; this.adminOriginator = adminOriginator; this.walletBuilder = walletBuilder; this.UMPTokenInteractor = interactor; this.recoveryKeySaver = recoveryKeySaver; this.passwordRetriever = passwordRetriever; this.authenticated = false; this.newWalletFunder = newWalletFunder; // If a saved snapshot is provided, attempt to load it. // Note: loadSnapshot now returns a promise. We don't await it here, // as the constructor must be synchronous. The caller should check // `this.authenticated` after construction if a snapshot was provided. if (stateSnapshot) { this.loadSnapshot(stateSnapshot).catch(err => { console.error('Failed to load snapshot during construction:', err); // Clear potentially partially loaded state this.destroy(); }); } } // --- Authentication Methods --- /** * Provides the presentation key. */ async providePresentationKey(key) { if (this.authenticated) { throw new Error('User is already authenticated'); } if (this.authenticationMode === 'recovery-key-and-password') { throw new Error('Presentation key is not needed in this mode'); } const hash = sdk_1.Hash.sha256(key); const token = await this.UMPTokenInteractor.findByPresentationKeyHash(hash); if (!token) { // No token found -> New user this.authenticationFlow = 'new-user'; this.presentationKey = key; } else { // Found token -> existing user this.authenticationFlow = 'existing-user'; this.presentationKey = key; this.currentUMPToken = token; } } /** * Provides the password. */ async providePassword(password) { if (this.authenticated) { throw new Error('User is already authenticated'); } if (this.authenticationMode === 'presentation-key-and-recovery-key') { throw new Error('Password is not needed in this mode'); } if (this.authenticationFlow === 'existing-user') { // Existing user flow if (!this.currentUMPToken) { throw new Error('Provide presentation or recovery key first.'); } const derivedPasswordKey = await pbkdf2NativeOrJs(sdk_1.Utils.toArray(password, 'utf8'), this.currentUMPToken.passwordSalt, exports.PBKDF2_NUM_ROUNDS, 32, 'sha512'); let rootPrimaryKey; let rootPrivilegedKey; // Only needed for recovery mode if (this.authenticationMode === 'presentation-key-and-password') { if (!this.presentationKey) throw new Error('No presentation key found!'); const xorKey = this.XOR(this.presentationKey, derivedPasswordKey); rootPrimaryKey = new sdk_1.SymmetricKey(xorKey).decrypt(this.currentUMPToken.passwordPresentationPrimary); } else { // 'recovery-key-and-password' if (!this.recoveryKey) throw new Error('No recovery key found!'); const primaryDecryptionKey = this.XOR(this.recoveryKey, derivedPasswordKey); rootPrimaryKey = new sdk_1.SymmetricKey(primaryDecryptionKey).decrypt(this.currentUMPToken.passwordRecoveryPrimary); const privilegedDecryptionKey = this.XOR(rootPrimaryKey, derivedPasswordKey); rootPrivilegedKey = new sdk_1.SymmetricKey(privilegedDecryptionKey).decrypt(this.currentUMPToken.passwordPrimaryPrivileged); } // Build root infrastructure, load profiles, and switch to default profile initially await this.setupRootInfrastructure(rootPrimaryKey, rootPrivilegedKey); await this.switchProfile(this.activeProfileId); } else { // New user flow (only 'presentation-key-and-password') if (this.authenticationMode !== 'presentation-key-and-password') { throw new Error('New-user flow requires presentation key and password mode.'); } if (!this.presentationKey) { throw new Error('No presentation key provided for new-user flow.'); } // Generate new keys/salt const recoveryKey = (0, sdk_1.Random)(32); await this.recoveryKeySaver(recoveryKey); const passwordSalt = (0, sdk_1.Random)(32); const passwordKey = await pbkdf2NativeOrJs(sdk_1.Utils.toArray(password, 'utf8'), passwordSalt, exports.PBKDF2_NUM_ROUNDS, 32, 'sha512'); const rootPrimaryKey = (0, sdk_1.Random)(32); const rootPrivilegedKey = (0, sdk_1.Random)(32); // Build XOR keys const presentationPassword = new sdk_1.SymmetricKey(this.XOR(this.presentationKey, passwordKey)); const presentationRecovery = new sdk_1.SymmetricKey(this.XOR(this.presentationKey, recoveryKey)); const recoveryPassword = new sdk_1.SymmetricKey(this.XOR(recoveryKey, passwordKey)); const primaryPassword = new sdk_1.SymmetricKey(this.XOR(rootPrimaryKey, passwordKey)); // Temp manager for encryption const tempPrivilegedKeyManager = new PrivilegedKeyManager_1.PrivilegedKeyManager(async () => new sdk_1.PrivateKey(rootPrivilegedKey)); // Build new UMP token (no profiles initially) const newToken = { passwordSalt, passwordPresentationPrimary: presentationPassword.encrypt(rootPrimaryKey), passwordRecoveryPrimary: recoveryPassword.encrypt(rootPrimaryKey), presentationRecoveryPrimary: presentationRecovery.encrypt(rootPrimaryKey), passwordPrimaryPrivileged: primaryPassword.encrypt(rootPrivilegedKey), presentationRecoveryPrivileged: presentationRecovery.encrypt(rootPrivilegedKey), presentationHash: sdk_1.Hash.sha256(this.presentationKey), recoveryHash: sdk_1.Hash.sha256(recoveryKey), presentationKeyEncrypted: (await tempPrivilegedKeyManager.encrypt({ plaintext: this.presentationKey, protocolID: [2, 'admin key wrapping'], keyID: '1' })).ciphertext, passwordKeyEncrypted: (await tempPrivilegedKeyManager.encrypt({ plaintext: passwordKey, protocolID: [2, 'admin key wrapping'], keyID: '1' })).ciphertext, recoveryKeyEncrypted: (await tempPrivilegedKeyManager.encrypt({ plaintext: recoveryKey, protocolID: [2, 'admin key wrapping'], keyID: '1' })).ciphertext, profilesEncrypted: undefined // No profiles yet }; this.currentUMPToken = newToken; // Setup root infrastructure and switch to default profile await this.setupRootInfrastructure(rootPrimaryKey); await this.switchProfile(exports.DEFAULT_PROFILE_ID); // Fund the *default* wallet if funder provided if (this.newWalletFunder && this.underlying) { try { await this.newWalletFunder(this.presentationKey, this.underlying, this.adminOriginator); } catch (e) { console.error('Error funding new wallet:', e); // Decide if this should halt the process or just log } } // Publish the new UMP token *after* potentially funding // We need the default profile wallet to sign the UMP creation TX if (!this.underlying) { throw new Error('Default profile wallet not built before attempting to publish UMP token.'); } this.currentUMPToken.currentOutpoint = await this.UMPTokenInteractor.buildAndSend(this.underlying, // Use the default profile wallet this.adminOriginator, newToken); } } /** * Provides the recovery key. */ async provideRecoveryKey(recoveryKey) { if (this.authenticated) { throw new Error('Already authenticated'); } if (this.authenticationFlow === 'new-user') { throw new Error('Do not submit recovery key in new-user flow'); } if (this.authenticationMode === 'presentation-key-and-password') { throw new Error('No recovery key required in this mode'); } else if (this.authenticationMode === 'recovery-key-and-password') { // Wait for password const hash = sdk_1.Hash.sha256(recoveryKey); const token = await this.UMPTokenInteractor.findByRecoveryKeyHash(hash); if (!token) throw new Error('No user found with this recovery key'); this.recoveryKey = recoveryKey; this.currentUMPToken = token; } else { // 'presentation-key-and-recovery-key' if (!this.presentationKey) throw new Error('Provide the presentation key first'); if (!this.currentUMPToken) throw new Error('Current UMP token not found'); const xorKey = this.XOR(this.presentationKey, recoveryKey); const rootPrimaryKey = new sdk_1.SymmetricKey(xorKey).decrypt(this.currentUMPToken.presentationRecoveryPrimary); const rootPrivilegedKey = new sdk_1.SymmetricKey(xorKey).decrypt(this.currentUMPToken.presentationRecoveryPrivileged); // Build root infrastructure, load profiles, switch to default await this.setupRootInfrastructure(rootPrimaryKey, rootPrivilegedKey); await this.switchProfile(this.activeProfileId); } } // --- State Management Methods --- /** * Saves the current wallet state (root key, UMP token, active profile) into an encrypted snapshot. * Version 2 format: [1 byte version=2] + [32 byte snapshot key] + [16 byte activeProfileId] + [encrypted payload] * Encrypted Payload: [32 byte rootPrimaryKey] + [varint token length + serialized UMP token] * * @returns Encrypted snapshot bytes. */ saveSnapshot() { if (!this.rootPrimaryKey || !this.currentUMPToken) { throw new Error('No root primary key or current UMP token set'); } const snapshotKey = (0, sdk_1.Random)(32); const snapshotPreimageWriter = new sdk_1.Utils.Writer(); // Write root primary key snapshotPreimageWriter.write(this.rootPrimaryKey); // Write serialized UMP token (must have outpoint) if (!this.currentUMPToken.currentOutpoint) { throw new Error('UMP token cannot be saved without a current outpoint.'); } const serializedToken = this.serializeUMPToken(this.currentUMPToken); snapshotPreimageWriter.writeVarIntNum(serializedToken.length); snapshotPreimageWriter.write(serializedToken); // Encrypt the payload const snapshotPreimage = snapshotPreimageWriter.toArray(); const snapshotPayload = new sdk_1.SymmetricKey(snapshotKey).encrypt(snapshotPreimage); // Build final snapshot (Version 2) const snapshotWriter = new sdk_1.Utils.Writer(); snapshotWriter.writeUInt8(2); // Version snapshotWriter.write(snapshotKey); snapshotWriter.write(this.activeProfileId); // Active profile ID snapshotWriter.write(snapshotPayload); // Encrypted data return snapshotWriter.toArray(); } /** * Loads a previously saved state snapshot. Restores root key, UMP token, profiles, and active profile. * Handles Version 1 (legacy) and Version 2 formats. * * @param snapshot Encrypted snapshot bytes. */ async loadSnapshot(snapshot) { try { const reader = new sdk_1.Utils.Reader(snapshot); const version = reader.readUInt8(); let snapshotKey; let encryptedPayload; let activeProfileId = exports.DEFAULT_PROFILE_ID; // Default for V1 if (version === 1) { snapshotKey = reader.read(32); encryptedPayload = reader.read(); } else if (version === 2) { snapshotKey = reader.read(32); activeProfileId = reader.read(16); // Read active profile ID encryptedPayload = reader.read(); } else { throw new Error(`Unsupported snapshot version: ${version}`); } // Decrypt payload const decryptedPayload = new sdk_1.SymmetricKey(snapshotKey).decrypt(encryptedPayload); const payloadReader = new sdk_1.Utils.Reader(decryptedPayload); // Read root primary key const rootPrimaryKey = payloadReader.read(32); // Read serialized UMP token const tokenLen = payloadReader.readVarIntNum(); const tokenBytes = payloadReader.read(tokenLen); const token = this.deserializeUMPToken(tokenBytes); // Assign loaded data this.currentUMPToken = token; // Setup root infrastructure, load profiles, and switch to the loaded active profile await this.setupRootInfrastructure(rootPrimaryKey); // Will automatically load profiles await this.switchProfile(activeProfileId); // Switch to the profile saved in the snapshot this.authenticationFlow = 'existing-user'; // Loading implies existing user } catch (error) { this.destroy(); // Clear state on error throw new Error(`Failed to load snapshot: ${error.message}`); } } /** * Destroys the wallet state, clearing keys, tokens, and profiles. */ destroy() { this.underlying = undefined; this.rootPrivilegedKeyManager = undefined; this.authenticated = false; this.rootPrimaryKey = undefined; this.currentUMPToken = undefined; this.presentationKey = undefined; this.recoveryKey = undefined; this.profiles = []; this.activeProfileId = exports.DEFAULT_PROFILE_ID; this.authenticationMode = 'presentation-key-and-password'; this.authenticationFlow = 'new-user'; } // --- Profile Management Methods --- /** * Lists all available profiles, including the default profile. * @returns Array of profile info objects, including an 'active' flag. */ listProfiles() { if (!this.authenticated) { throw new Error('Not authenticated.'); } const profileList = [ // Default profile { id: exports.DEFAULT_PROFILE_ID, name: 'default', createdAt: null, // Default profile doesn't have a creation timestamp in the same way active: this.activeProfileId.every(x => x === 0) }, // Other profiles ...this.profiles.map(p => ({ id: p.id, name: p.name, createdAt: p.createdAt, active: this.activeProfileId.every((x, i) => x === p.id[i]) })) ]; return profileList; } /** * Adds a new profile with the given name. * Generates necessary pads and updates the UMP token. * Does not switch to the new profile automatically. * * @param name The desired name for the new profile. * @returns The ID of the newly created profile. */ async addProfile(name) { if (!this.authenticated || !this.rootPrimaryKey || !this.currentUMPToken || !this.rootPrivilegedKeyManager) { throw new Error('Wallet not fully initialized or authenticated.'); } // Ensure name is unique (including 'default') if (name === 'default' || this.profiles.some(p => p.name.toLowerCase() === name.toLowerCase())) { throw new Error(`Profile name "${name}" is already in use.`); } const newProfile = { name, id: (0, sdk_1.Random)(16), primaryPad: (0, sdk_1.Random)(32), privilegedPad: (0, sdk_1.Random)(32), createdAt: Math.floor(Date.now() / 1000) }; this.profiles.push(newProfile); // Update the UMP token with the new profile list await this.updateAuthFactors(this.currentUMPToken.passwordSalt, // Need to re-derive/decrypt factors needed for re-encryption await this.getFactor('passwordKey'), await this.getFactor('presentationKey'), await this.getFactor('recoveryKey'), this.rootPrimaryKey, await this.getFactor('privilegedKey'), // Get ROOT privileged key this.profiles // Pass the updated profile list ); return newProfile.id; } /** * Deletes a profile by its ID. * Cannot delete the default profile. If the active profile is deleted, * it switches back to the default profile. * * @param profileId The 16-byte ID of the profile to delete. */ async deleteProfile(profileId) { if (!this.authenticated || !this.rootPrimaryKey || !this.currentUMPToken || !this.rootPrivilegedKeyManager) { throw new Error('Wallet not fully initialized or authenticated.'); } if (profileId.every(x => x === 0)) { throw new Error('Cannot delete the default profile.'); } const profileIndex = this.profiles.findIndex(p => p.id.every((x, i) => x === profileId[i])); if (profileIndex === -1) { throw new Error('Profile not found.'); } // Remove the profile this.profiles.splice(profileIndex, 1); // If the deleted profile was active, switch to default if (this.activeProfileId.every((x, i) => x === profileId[i])) { await this.switchProfile(exports.DEFAULT_PROFILE_ID); // This rebuilds the wallet } // Update the UMP token await this.updateAuthFactors(this.currentUMPToken.passwordSalt, await this.getFactor('passwordKey'), await this.getFactor('presentationKey'), await this.getFactor('recoveryKey'), this.rootPrimaryKey, await this.getFactor('privilegedKey'), // Get ROOT privileged key this.profiles // Pass updated list ); } /** * Switches the active profile. This re-derives keys and rebuilds the underlying wallet. * * @param profileId The 16-byte ID of the profile to switch to (use DEFAULT_PROFILE_ID for default). */ async switchProfile(profileId) { if (!this.authenticated || !this.rootPrimaryKey || !this.rootPrivilegedKeyManager) { throw new Error('Cannot switch profile: Wallet not authenticated or root keys missing.'); } let profilePrimaryKey; let profilePrivilegedPad; // Pad for the target profile if (profileId.every(x => x === 0)) { // Switching to default profile profilePrimaryKey = this.rootPrimaryKey; profilePrivilegedPad = undefined; // No pad for default this.activeProfileId = exports.DEFAULT_PROFILE_ID; } else { // Switching to a non-default profile const profile = this.profiles.find(p => p.id.every((x, i) => x === profileId[i])); if (!profile) { throw new Error('Profile not found.'); } profilePrimaryKey = this.XOR(this.rootPrimaryKey, profile.primaryPad); profilePrivilegedPad = profile.privilegedPad; this.activeProfileId = profileId; } // Create a *profile-specific* PrivilegedKeyManager. // It uses the ROOT manager internally but applies the profile's pad. const profilePrivilegedKeyManager = new PrivilegedKeyManager_1.PrivilegedKeyManager(async (reason) => { // Request the ROOT privileged key using the root manager const rootPrivileged = await this.rootPrivilegedKeyManager.getPrivilegedKey(reason); const rootPrivilegedBytes = rootPrivileged.toArray(); // Apply the profile's pad if applicable const profilePrivilegedBytes = profilePrivilegedPad ? this.XOR(rootPrivilegedBytes, profilePrivilegedPad) : rootPrivilegedBytes; return new sdk_1.PrivateKey(profilePrivilegedBytes); }); // Build the underlying wallet for the specific profile this.underlying = await this.walletBuilder(profilePrimaryKey, profilePrivilegedKeyManager, // Pass the profile-specific manager this.activeProfileId // Pass the ID of the profile being activated ); } // --- Key Management Methods --- /** * Changes the user's password. Re-wraps keys and updates the UMP token. */ async changePassword(newPassword) { if (!this.authenticated || !this.currentUMPToken || !this.rootPrimaryKey || !this.rootPrivilegedKeyManager) { throw new Error('Not authenticated or missing required data.'); } const passwordSalt = (0, sdk_1.Random)(32); const newPasswordKey = await pbkdf2NativeOrJs(sdk_1.Utils.toArray(newPassword, 'utf8'), passwordSalt, exports.PBKDF2_NUM_ROUNDS, 32, 'sha512'); // Decrypt existing factors needed for re-encryption, using the *root* privileged key manager const recoveryKey = await this.getFactor('recoveryKey'); const presentationKey = await this.getFactor('presentationKey'); const rootPrivilegedKey = await this.getFactor('privilegedKey'); // Get ROOT privileged key await this.updateAuthFactors(passwordSalt, newPasswordKey, presentationKey, recoveryKey, this.rootPrimaryKey, rootPrivilegedKey, // Pass the explicitly fetched root key this.profiles // Preserve existing profiles ); } /** * Retrieves the current recovery key. Requires privileged access. */ async getRecoveryKey() { if (!this.authenticated || !this.currentUMPToken || !this.rootPrivilegedKeyManager) { throw new Error('Not authenticated or missing required data.'); } return this.getFactor('recoveryKey'); } /** * Changes the user's recovery key. Prompts user to save the new key. */ async changeRecoveryKey() { if (!this.authenticated || !this.currentUMPToken || !this.rootPrimaryKey || !this.rootPrivilegedKeyManager) { throw new Error('Not authenticated or missing required data.'); } // Decrypt existing factors needed const passwordKey = await this.getFactor('passwordKey'); const presentationKey = await this.getFactor('presentationKey'); const rootPrivilegedKey = await this.getFactor('privilegedKey'); // Get ROOT privileged key // Generate and save new recovery key const newRecoveryKey = (0, sdk_1.Random)(32); await this.recoveryKeySaver(newRecoveryKey); await this.updateAuthFactors(this.currentUMPToken.passwordSalt, passwordKey, presentationKey, newRecoveryKey, // Use the new key this.rootPrimaryKey, rootPrivilegedKey, this.profiles // Preserve profiles ); } /** * Changes the user's presentation key. */ async changePresentationKey(newPresentationKey) { if (!this.authenticated || !this.currentUMPToken || !this.rootPrimaryKey || !this.rootPrivilegedKeyManager) { throw new Error('Not authenticated or missing required data.'); } if (newPresentationKey.length !== 32) { throw new Error('Presentation key must be 32 bytes.'); } // Decrypt existing factors const recoveryKey = await this.getFactor('recoveryKey'); const passwordKey = await this.getFactor('passwordKey'); const rootPrivilegedKey = await this.getFactor('privilegedKey'); // Get ROOT privileged key await this.updateAuthFactors(this.currentUMPToken.passwordSalt, passwordKey, newPresentationKey, // Use the new key recoveryKey, this.rootPrimaryKey, rootPrivilegedKey, this.profiles // Preserve profiles ); // Update the temporarily stored key if it was set if (this.presentationKey) { this.presentationKey = newPresentationKey; } } // --- Internal Helper Methods --- /** * Performs XOR operation on two byte arrays. */ XOR(n1, n2) { if (n1.length !== n2.length) { // Provide more context in error throw new Error(`XOR length mismatch: ${n1.length} vs ${n2.length}`); } const r = new Array(n1.length); for (let i = 0; i < n1.length; i++) { r[i] = n1[i] ^ n2[i]; } return r; } /** * Helper to decrypt a specific factor (key) stored encrypted in the UMP token. * Requires the root privileged key manager. * @param factorName Name of the factor to decrypt ('passwordKey', 'presentationKey', 'recoveryKey', 'privilegedKey'). * @param getRoot If true and factorName is 'privilegedKey', returns the root privileged key bytes directly. * @returns The decrypted key bytes. */ async getFactor(factorName) { if (!this.authenticated || !this.currentUMPToken || !this.rootPrivilegedKeyManager) { throw new Error(`Cannot get factor "${factorName}": Wallet not ready.`); } const protocolID = [2, 'admin key wrapping']; // Protocol used for encrypting factors const keyID = '1'; // Key ID used try { switch (factorName) { case 'passwordKey': return (await this.rootPrivilegedKeyManager.decrypt({ ciphertext: this.currentUMPToken.passwordKeyEncrypted, protocolID, keyID })).plaintext; case 'presentationKey': return (await this.rootPrivilegedKeyManager.decrypt({ ciphertext: this.currentUMPToken.presentationKeyEncrypted, protocolID, keyID })).plaintext; case 'recoveryKey': return (await this.rootPrivilegedKeyManager.decrypt({ ciphertext: this.currentUMPToken.recoveryKeyEncrypted, protocolID, keyID })).plaintext; case 'privilegedKey': { // This needs careful handling based on whether the ROOT or PROFILE key is needed. // This helper is mostly used for UMP updates, which need the ROOT key. // We retrieve the PrivateKey object first. const pk = await this.rootPrivilegedKeyManager.getPrivilegedKey('UMP token update', true); // Force retrieval of root key return pk.toArray(); // Return bytes } default: throw new Error(`Unknown factor name: ${factorName}`); } } catch (error) { console.error(`Error decrypting factor ${factorName}:`, error); throw new Error(`Failed to decrypt factor "${factorName}": ${error.message}`); } } /** * Recomputes UMP token fields with updated factors and profiles, then publishes the update. * This operation requires the *root* privileged key and the *default* profile wallet. */ async updateAuthFactors(passwordSalt, passwordKey, presentationKey, recoveryKey, rootPrimaryKey, rootPrivilegedKey, // Explicitly pass the root key bytes profiles // Pass current/new profiles list ) { if (!this.authenticated || !this.rootPrimaryKey || !this.currentUMPToken) { throw new Error('Wallet is not properly authenticated or missing data for update.'); } // Ensure we have the OLD token to consume const oldTokenToConsume = { ...this.currentUMPToken }; if (!oldTokenToConsume.currentOutpoint) { throw new Error('Cannot update UMP token: Old token has no outpoint.'); } // Derive symmetrical encryption keys using XOR for the *root* keys const presentationPassword = new sdk_1.SymmetricKey(this.XOR(presentationKey, passwordKey)); const presentationRecovery = new sdk_1.SymmetricKey(this.XOR(presentationKey, recoveryKey)); const recoveryPassword = new sdk_1.SymmetricKey(this.XOR(recoveryKey, passwordKey)); const primaryPassword = new sdk_1.SymmetricKey(this.XOR(rootPrimaryKey, passwordKey)); // Use rootPrimaryKey // Build a temporary privileged key manager using the explicit ROOT privileged key const tempRootPrivilegedKeyManager = new PrivilegedKeyManager_1.PrivilegedKeyManager(async () => new sdk_1.PrivateKey(rootPrivilegedKey)); // Encrypt profiles if provided let profilesEncrypted; if (profiles && profiles.length > 0) { const profilesJson = JSON.stringify(profiles); const profilesBytes = sdk_1.Utils.toArray(profilesJson, 'utf8'); profilesEncrypted = new sdk_1.SymmetricKey(rootPrimaryKey).encrypt(profilesBytes); } // Construct the new UMP token data const newTokenData = { passwordSalt, passwordPresentationPrimary: presentationPassword.encrypt(rootPrimaryKey), passwordRecoveryPrimary: recoveryPassword.encrypt(rootPrimaryKey), presentationRecoveryPrimary: presentationRecovery.encrypt(rootPrimaryKey), passwordPrimaryPrivileged: primaryPassword.encrypt(rootPrivilegedKey), presentationRecoveryPrivileged: presentationRecovery.encrypt(rootPrivilegedKey), presentationHash: sdk_1.Hash.sha256(presentationKey), recoveryHash: sdk_1.Hash.sha256(recoveryKey), presentationKeyEncrypted: (await tempRootPrivilegedKeyManager.encrypt({ plaintext: presentationKey, protocolID: [2, 'admin key wrapping'], keyID: '1' })).ciphertext, passwordKeyEncrypted: (await tempRootPrivilegedKeyManager.encrypt({ plaintext: passwordKey, protocolID: [2, 'admin key wrapping'], keyID: '1' })).ciphertext, recoveryKeyEncrypted: (await tempRootPrivilegedKeyManager.encrypt({ plaintext: recoveryKey, protocolID: [2, 'admin key wrapping'], keyID: '1' })).ciphertext, profilesEncrypted // Add encrypted profiles // currentOutpoint will be set after publishing }; // We need the wallet built for the DEFAULT profile to publish the UMP token. // If the current active profile is not default, temporarily switch, publish, then switch back. const currentActiveId = this.activeProfileId; let walletToUse = this.underlying; if (!currentActiveId.every(x => x === 0)) { console.log('Temporarily switching to default profile to update UMP token...'); await this.switchProfile(exports.DEFAULT_PROFILE_ID); // This rebuilds this.underlying walletToUse = this.underlying; } if (!walletToUse) { throw new Error('Default profile wallet could not be activated for UMP token update.'); } // Publish the new token on-chain, consuming the old one try { newTokenData.currentOutpoint = await this.UMPTokenInteractor.buildAndSend(walletToUse, this.adminOriginator, newTokenData, oldTokenToConsume // Consume the previous token ); // Update the manager's state this.currentUMPToken = newTokenData; // Profiles are already updated in this.profiles if they were passed in } finally { // Switch back if we temporarily switched if (!currentActiveId.every(x => x === 0)) { console.log('Switching back to original profile...'); await this.switchProfile(currentActiveId); } } } /** * Serializes a UMP token to binary format (Version 2 with optional profiles). * Layout: [1 byte version=2] + [11 * (varint len + bytes) for standard fields] + [1 byte profile_flag] + [IF flag=1 THEN varint len + profile bytes] + [varint len + outpoint bytes] */ serializeUMPToken(token) { if (!token.currentOutpoint) { throw new Error('Token must have outpoint for serialization'); } const writer = new sdk_1.Utils.Writer(); writer.writeUInt8(2); // Version 2 const writeArray = (arr) => { writer.writeVarIntNum(arr.length); writer.write(arr); }; // Write standard fields in specific order writeArray(token.passwordSalt); // 0 writeArray(token.passwordPresent