@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
1,418 lines (1,274 loc) • 70.6 kB
text/typescript
import {
Hash,
Utils,
Random,
SymmetricKey,
AbortActionArgs,
AbortActionResult,
AcquireCertificateArgs,
AcquireCertificateResult,
AuthenticatedResult,
CreateActionArgs,
CreateActionResult,
CreateHmacArgs,
CreateHmacResult,
CreateSignatureArgs,
CreateSignatureResult,
DiscoverByAttributesArgs,
DiscoverByIdentityKeyArgs,
DiscoverCertificatesResult,
GetHeaderArgs,
GetHeaderResult,
GetHeightResult,
GetNetworkResult,
GetPublicKeyArgs,
GetPublicKeyResult,
GetVersionResult,
InternalizeActionArgs,
InternalizeActionResult,
ListActionsArgs,
ListActionsResult,
ListCertificatesArgs,
ListCertificatesResult,
ListOutputsArgs,
ListOutputsResult,
OriginatorDomainNameStringUnder250Bytes,
ProveCertificateArgs,
ProveCertificateResult,
RelinquishCertificateArgs,
RelinquishCertificateResult,
RelinquishOutputArgs,
RelinquishOutputResult,
RevealCounterpartyKeyLinkageArgs,
RevealCounterpartyKeyLinkageResult,
RevealSpecificKeyLinkageArgs,
RevealSpecificKeyLinkageResult,
SignActionArgs,
SignActionResult,
VerifyHmacArgs,
VerifyHmacResult,
VerifySignatureArgs,
VerifySignatureResult,
WalletDecryptArgs,
WalletDecryptResult,
WalletEncryptArgs,
WalletEncryptResult,
WalletInterface,
OutpointString,
PrivateKey,
LookupResolver,
LookupAnswer,
Transaction,
PushDrop,
CreateActionInput,
SHIPBroadcaster,
BigNumber,
Curve
} from '@bsv/sdk'
import { PrivilegedKeyManager } from './sdk/PrivilegedKeyManager'
/**
* Number of rounds used in PBKDF2 for deriving password keys.
*/
export const 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: number[],
salt: number[],
iterations: number,
keyLen: number,
hash: 'sha256' | 'sha512' = 'sha512'
): Promise<number[]> {
// ----- fast-path: WebCrypto (both browser & recent Node expose globalThis.crypto.subtle)
const subtle = (globalThis as any)?.crypto?.subtle as SubtleCrypto | undefined
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() as AlgorithmIdentifier
},
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 Hash.pbkdf2(passwordBytes, salt, iterations, keyLen, hash)
}
/**
* Unique Identifier for the default profile (16 zero bytes).
*/
export const DEFAULT_PROFILE_ID = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
/**
* Describes the structure of a user profile within the wallet.
*/
export interface Profile {
/**
* User-defined name for the profile.
*/
name: string
/**
* Unique 16-byte identifier for the profile.
*/
id: number[]
/**
* 32-byte random pad XOR'd with the root primary key to derive the profile's primary key.
*/
primaryPad: number[]
/**
* 32-byte random pad XOR'd with the root privileged key to derive the profile's privileged key.
*/
privilegedPad: number[]
/**
* Timestamp (seconds since epoch) when the profile was created.
*/
createdAt: number
}
/**
* Describes the structure of a User Management Protocol (UMP) token.
*/
export interface UMPToken {
/**
* Root Primary key encrypted by the XOR of the password and presentation keys.
*/
passwordPresentationPrimary: number[]
/**
* Root Primary key encrypted by the XOR of the password and recovery keys.
*/
passwordRecoveryPrimary: number[]
/**
* Root Primary key encrypted by the XOR of the presentation and recovery keys.
*/
presentationRecoveryPrimary: number[]
/**
* Root Privileged key encrypted by the XOR of the password and primary keys.
*/
passwordPrimaryPrivileged: number[]
/**
* Root Privileged key encrypted by the XOR of the presentation and recovery keys.
*/
presentationRecoveryPrivileged: number[]
/**
* Hash of the presentation key.
*/
presentationHash: number[]
/**
* PBKDF2 salt used in conjunction with the password to derive the password key.
*/
passwordSalt: number[]
/**
* Hash of the recovery key.
*/
recoveryHash: number[]
/**
* A copy of the presentation key encrypted with the root privileged key.
*/
presentationKeyEncrypted: number[]
/**
* A copy of the recovery key encrypted with the root privileged key.
*/
recoveryKeyEncrypted: number[]
/**
* A copy of the password key encrypted with the root privileged key.
*/
passwordKeyEncrypted: number[]
/**
* Optional field containing the encrypted profile data.
* JSON string -> Encrypted Bytes using root privileged key.
*/
profilesEncrypted?: number[]
/**
* Describes the token's location on-chain, if it's already been published.
*/
currentOutpoint?: OutpointString
}
/**
* Describes a system capable of finding and updating UMP tokens on the blockchain.
*/
export interface UMPTokenInteractor {
/**
* Locates the latest valid copy of a UMP token (including its outpoint)
* based on the presentation key hash.
*
* @param hash The hash of the presentation key.
* @returns The UMP token if found; otherwise, undefined.
*/
findByPresentationKeyHash: (hash: number[]) => Promise<UMPToken | undefined>
/**
* Locates the latest valid copy of a UMP token (including its outpoint)
* based on the recovery key hash.
*
* @param hash The hash of the recovery key.
* @returns The UMP token if found; otherwise, undefined.
*/
findByRecoveryKeyHash: (hash: number[]) => Promise<UMPToken | undefined>
/**
* Creates (and optionally consumes the previous version of) a UMP token on-chain.
*
* @param wallet The wallet that might be used to create a new token (MUST be operating under the DEFAULT profile).
* @param adminOriginator The domain name of the administrative originator.
* @param token The new UMP token to create.
* @param oldTokenToConsume If provided, the old token that must be consumed in the same transaction.
* @returns The newly created outpoint.
*/
buildAndSend: (
wallet: WalletInterface, // This wallet MUST be the one built for the default profile
adminOriginator: OriginatorDomainNameStringUnder250Bytes,
token: UMPToken,
oldTokenToConsume?: UMPToken
) => Promise<OutpointString>
}
/**
* @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.
*/
export class OverlayUMPTokenInteractor implements UMPTokenInteractor {
/**
* A `LookupResolver` instance used to query overlay networks.
*/
private readonly resolver: LookupResolver
/**
* A SHIP broadcaster that can be used to publish updated UMP tokens
* under the `tm_users` topic to overlay service peers.
*/
private readonly broadcaster: SHIPBroadcaster
/**
* 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: LookupResolver = new LookupResolver(),
broadcaster: SHIPBroadcaster = new 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.
*/
public async findByPresentationKeyHash(hash: number[]): Promise<UMPToken | undefined> {
// Query ls_users for the given presentationHash
const question = {
service: 'ls_users',
query: { presentationHash: 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.
*/
public async findByRecoveryKeyHash(hash: number[]): Promise<UMPToken | undefined> {
const question = {
service: 'ls_users',
query: { recoveryHash: 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").
*/
public async buildAndSend(
wallet: WalletInterface, // This wallet MUST be the one built for the default profile
adminOriginator: OriginatorDomainNameStringUnder250Bytes,
token: UMPToken,
oldTokenToConsume?: UMPToken
): Promise<OutpointString> {
// 1) Construct the data fields for the new UMP token.
const fields: number[][] = []
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 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: CreateActionInput[] = []
let inputToken: { beef: number[]; outputIndex: number } | undefined
if (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?.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 ? 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 = 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 = Transaction.fromBEEF(createResult.signableTransaction.tx)
if (oldTokenToConsume?.currentOutpoint) {
// Unlock the old token with a matching PushDrop unlocker
const unlocker = new 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 ? 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 = 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 ? 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 = 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.
*/
private parseLookupAnswer(answer: LookupAnswer): UMPToken | undefined {
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 = Transaction.fromBEEF(beef)
const outpoint = `${tx.id('hex')}.${outputIndex}`
const decoded = 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: ${decoded.fields?.length}`)
return undefined
}
// Build the UMP token from these fields, preserving outpoint
const t: UMPToken = {
// 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
*/
private async findByOutpoint(outpoint: string): Promise<{ beef: number[]; outputIndex: number } | undefined> {
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]
}
}
/**
* 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.
*/
export class CWIStyleWalletManager implements WalletInterface {
/**
* Whether the user is currently authenticated (i.e., root keys are available).
*/
authenticated: boolean
/**
* The domain name of the administrative originator (wallet operator / vendor, or your own).
*/
private adminOriginator: OriginatorDomainNameStringUnder250Bytes
/**
* The system that locates and publishes UMP tokens on-chain.
*/
private UMPTokenInteractor: UMPTokenInteractor
/**
* A function called to persist the newly generated recovery key.
* It should generally trigger a UI prompt where the user is asked to write it down.
*/
private recoveryKeySaver: (key: number[]) => Promise<true>
/**
* Asks the user to enter their password, for a given reason.
* The test function can be used to see if the password is correct before resolving.
* Only resolve with the correct password or reject with an error.
* Resolving with an incorrect password will throw an error.
*/
private passwordRetriever: (reason: string, test: (passwordCandidate: string) => boolean) => Promise<string>
/**
* Optional function to fund a new Wallet after the new-user flow.
*/
private newWalletFunder?: (
presentationKey: number[],
wallet: WalletInterface, // The default profile wallet
adminOriginator: OriginatorDomainNameStringUnder250Bytes
) => Promise<void>
/**
* Builds the underlying wallet for a specific profile.
*/
private walletBuilder: (
profilePrimaryKey: number[],
profilePrivilegedKeyManager: PrivilegedKeyManager,
profileId: number[]
) => Promise<WalletInterface>
/**
* Current mode of authentication.
*/
authenticationMode:
| 'presentation-key-and-password'
| 'presentation-key-and-recovery-key'
| 'recovery-key-and-password' = 'presentation-key-and-password'
/**
* Indicates new user or existing user flow.
*/
authenticationFlow: 'new-user' | 'existing-user' = 'new-user'
/**
* The current UMP token in use.
*/
private currentUMPToken?: UMPToken
/**
* Temporarily retained presentation key.
*/
private presentationKey?: number[]
/**
* Temporarily retained recovery key.
*/
private recoveryKey?: number[]
/**
* The user's *root* primary key, derived from authentication factors.
*/
private rootPrimaryKey?: number[]
/**
* The currently active profile ID (null or DEFAULT_PROFILE_ID means default profile).
*/
private activeProfileId: number[] = DEFAULT_PROFILE_ID
/**
* List of loaded non-default profiles.
*/
private profiles: Profile[] = []
/**
* The underlying wallet instance for the *active* profile.
*/
private underlying?: WalletInterface
/**
* Privileged key manager associated with the *root* keys, aware of the active profile.
*/
private rootPrivilegedKeyManager?: PrivilegedKeyManager
/**
* 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: OriginatorDomainNameStringUnder250Bytes,
walletBuilder: (
profilePrimaryKey: number[],
profilePrivilegedKeyManager: PrivilegedKeyManager,
profileId: number[]
) => Promise<WalletInterface>,
interactor: UMPTokenInteractor = new OverlayUMPTokenInteractor(),
recoveryKeySaver: (key: number[]) => Promise<true>,
passwordRetriever: (reason: string, test: (passwordCandidate: string) => boolean) => Promise<string>,
newWalletFunder?: (
presentationKey: number[],
wallet: WalletInterface, // Default profile wallet
adminOriginator: OriginatorDomainNameStringUnder250Bytes
) => Promise<void>,
stateSnapshot?: number[]
) {
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: number[]): Promise<void> {
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 = 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: string): Promise<void> {
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(
Utils.toArray(password, 'utf8'),
this.currentUMPToken.passwordSalt,
PBKDF2_NUM_ROUNDS,
32,
'sha512'
)
let rootPrimaryKey: number[]
let rootPrivilegedKey: number[] | undefined // 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 SymmetricKey(xorKey).decrypt(this.currentUMPToken.passwordPresentationPrimary) as number[]
} else {
// 'recovery-key-and-password'
if (!this.recoveryKey) throw new Error('No recovery key found!')
const primaryDecryptionKey = this.XOR(this.recoveryKey, derivedPasswordKey)
rootPrimaryKey = new SymmetricKey(primaryDecryptionKey).decrypt(
this.currentUMPToken.passwordRecoveryPrimary
) as number[]
const privilegedDecryptionKey = this.XOR(rootPrimaryKey, derivedPasswordKey)
rootPrivilegedKey = new SymmetricKey(privilegedDecryptionKey).decrypt(
this.currentUMPToken.passwordPrimaryPrivileged
) as number[]
}
// 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 = Random(32)
await this.recoveryKeySaver(recoveryKey)
const passwordSalt = Random(32)
const passwordKey = await pbkdf2NativeOrJs(
Utils.toArray(password, 'utf8'),
passwordSalt,
PBKDF2_NUM_ROUNDS,
32,
'sha512'
)
const rootPrimaryKey = Random(32)
const rootPrivilegedKey = Random(32)
// Build XOR keys
const presentationPassword = new SymmetricKey(this.XOR(this.presentationKey, passwordKey))
const presentationRecovery = new SymmetricKey(this.XOR(this.presentationKey, recoveryKey))
const recoveryPassword = new SymmetricKey(this.XOR(recoveryKey, passwordKey))
const primaryPassword = new SymmetricKey(this.XOR(rootPrimaryKey, passwordKey))
// Temp manager for encryption
const tempPrivilegedKeyManager = new PrivilegedKeyManager(async () => new PrivateKey(rootPrivilegedKey))
// Build new UMP token (no profiles initially)
const newToken: UMPToken = {
passwordSalt,
passwordPresentationPrimary: presentationPassword.encrypt(rootPrimaryKey) as number[],
passwordRecoveryPrimary: recoveryPassword.encrypt(rootPrimaryKey) as number[],
presentationRecoveryPrimary: presentationRecovery.encrypt(rootPrimaryKey) as number[],
passwordPrimaryPrivileged: primaryPassword.encrypt(rootPrivilegedKey) as number[],
presentationRecoveryPrivileged: presentationRecovery.encrypt(rootPrivilegedKey) as number[],
presentationHash: Hash.sha256(this.presentationKey),
recoveryHash: 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(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: number[]): Promise<void> {
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 = 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 SymmetricKey(xorKey).decrypt(
this.currentUMPToken.presentationRecoveryPrimary
) as number[]
const rootPrivilegedKey = new SymmetricKey(xorKey).decrypt(
this.currentUMPToken.presentationRecoveryPrivileged
) as number[]
// 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(): number[] {
if (!this.rootPrimaryKey || !this.currentUMPToken) {
throw new Error('No root primary key or current UMP token set')
}
const snapshotKey = Random(32)
const snapshotPreimageWriter = new 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 SymmetricKey(snapshotKey).encrypt(snapshotPreimage) as number[]
// Build final snapshot (Version 2)
const snapshotWriter = new 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: number[]): Promise<void> {
try {
const reader = new Utils.Reader(snapshot)
const version = reader.readUInt8()
let snapshotKey: number[]
let encryptedPayload: number[]
let activeProfileId = 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 SymmetricKey(snapshotKey).decrypt(encryptedPayload) as number[]
const payloadReader = new 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 as Error).message}`)
}
}
/**
* Destroys the wallet state, clearing keys, tokens, and profiles.
*/
destroy(): void {
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 = 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(): Array<{
id: number[]
name: string
createdAt: number | null
active: boolean
identityKey: string
}> {
if (!this.authenticated) {
throw new Error('Not authenticated.')
}
const profileList = [
// Default profile
{
id: 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),
identityKey: new PrivateKey(this.rootPrimaryKey).toPublicKey().toString()
},
// 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]),
identityKey: new PrivateKey(this.XOR(this.rootPrimaryKey as number[], p.primaryPad)).toPublicKey().toString()
}))
]
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: string): Promise<number[]> {
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: Profile = {
name,
id: Random(16),
primaryPad: Random(32),
privilegedPad: 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: number[]): Promise<void> {
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(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: number[]): Promise<void> {
if (!this.authenticated || !this.rootPrimaryKey || !this.rootPrivilegedKeyManager) {
throw new Error('Cannot switch profile: Wallet not authenticated or root keys missing.')
}
let profilePrimaryKey: number[]
let profilePrivilegedPad: number[] | undefined // 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 = 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(async (reason: string) => {
// Request the ROOT privileged key using the root manager
const rootPrivileged: PrivateKey = await (this.rootPrivilegedKeyManager as any).getPrivilegedKey(reason)
const rootPrivilegedBytes = rootPrivileged.toArray()
// Apply the profile's pad if applicable
const profilePrivilegedBytes = profilePrivilegedPad
? this.XOR(rootPrivilegedBytes, profilePrivilegedPad)
: rootPrivilegedBytes
return new 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: string): Promise<void> {
if (!this.authenticated || !this.currentUMPToken || !this.rootPrimaryKey || !this.rootPrivilegedKeyManager) {
throw new Error('Not authenticated or missing required data.')
}
const passwordSalt = Random(32)
const newPasswordKey = await pbkdf2NativeOrJs(
Utils.toArray(newPassword, 'utf8'),
passwordSalt,
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(): Promise<number[]> {
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(): Promise<void> {
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 = 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: number[]): Promise<void> {
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.
*/
private XOR(n1: number[], n2: number[]): number[] {
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<number>(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.
*/
private async getFactor(
factorName: 'passwordKey' | 'presentationKey' | 'recoveryKey' | 'privilegedKey'
): Promise<number[]> {
if (!this.authenticated || !this.currentUMPToken || !this.rootPrivilegedKeyManager) {
throw new Error(`Cannot get factor "${factorName}": Wallet not ready.`)
}
const protocolID: [0 | 1 | 2, string] = [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 as any).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 as 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.