@bsv/wallet-toolbox-client
Version:
Client only Wallet Storage
1,006 lines • 63.4 kB
JavaScript
"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