UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

1,463 lines (1,341 loc) 102 kB
import { WalletInterface, Utils, PushDrop, LockingScript, Transaction, WalletProtocol, Base64String, PubKeyHex, SecurityLevels } from '@bsv/sdk' import { validateCreateActionArgs } from './sdk' ////// TODO: ADD SUPPORT FOR ADMIN COUNTERPARTIES BASED ON WALLET STORAGE ////// PROHIBITION OF SPECIAL OPERATIONS IS ALSO CRITICAL. ////// !!!!!!!! SECURITY-CRITICAL ADDITION — DO NOT USE UNTIL IMPLEMENTED. function deepEqual(object1: any, object2: any): boolean { if (object1 === null || object1 === undefined || object2 === null || object2 === undefined) { return object1 === object2 } const keys1 = Object.keys(object1) const keys2 = Object.keys(object2) if (keys1.length !== keys2.length) { return false } for (const key of keys1) { const val1 = object1[key] const val2 = object2[key] const areObjects = isObject(val1) && isObject(val2) if ((areObjects && !deepEqual(val1, val2)) || (!areObjects && val1 !== val2)) { return false } } return true } function isObject(object: any): boolean { return object != null && typeof object === 'object' } /** * Describes a group of permissions that can be requested together. * This structure is based on BRC-73. */ export interface GroupedPermissions { description?: string spendingAuthorization?: { amount: number description: string } protocolPermissions?: Array<{ protocolID: WalletProtocol counterparty?: string description: string }> basketAccess?: Array<{ basket: string description: string }> certificateAccess?: Array<{ type: string fields: string[] verifierPublicKey: string description: string }> } /** * The object passed to the UI when a grouped permission is requested. */ export interface GroupedPermissionRequest { originator: string requestID: string permissions: GroupedPermissions } /** * Signature for functions that handle a grouped permission request event. */ export type GroupedPermissionEventHandler = (request: GroupedPermissionRequest) => void | Promise<void> /** * Describes a single requested permission that the user must either grant or deny. * * Four categories of permission are supported, each with a unique protocol: * 1) protocol - "DPACP" (Domain Protocol Access Control Protocol) * 2) basket - "DBAP" (Domain Basket Access Protocol) * 3) certificate - "DCAP" (Domain Certificate Access Protocol) * 4) spending - "DSAP" (Domain Spending Authorization Protocol) * * This model underpins "requests" made to the user for permission, which the user can * either grant or deny. The manager can then create on-chain tokens (PushDrop outputs) * if permission is granted. Denying requests cause the underlying operation to throw, * and no token is created. An "ephemeral" grant is also possible, denoting a one-time * authorization without an associated persistent on-chain token. */ export interface PermissionRequest { type: 'protocol' | 'basket' | 'certificate' | 'spending' originator: string // The domain or FQDN of the requesting application privileged?: boolean // For "protocol" or "certificate" usage, indicating privileged key usage protocolID?: WalletProtocol // For type='protocol': BRC-43 style (securityLevel, protocolName) counterparty?: string // For type='protocol': e.g. target public key or "self"/"anyone" basket?: string // For type='basket': the basket name being requested certificate?: { // For type='certificate': details about the cert usage verifier: string certType: string fields: string[] } spending?: { // For type='spending': details about the requested spend satoshis: number lineItems?: Array<{ type: 'input' | 'output' | 'fee' description: string satoshis: number }> } reason?: string // Human-readable explanation for requesting permission renewal?: boolean // Whether this request is for renewing an expired token previousToken?: PermissionToken // If renewing an expired permission, reference to the old token } /** * Signature for functions that handle a permission request event, e.g. "Please ask the user to allow basket X". */ export type PermissionEventHandler = (request: PermissionRequest & { requestID: string }) => void | Promise<void> /** * Data structure representing an on-chain permission token. * It is typically stored as a single unspent PushDrop output in a special "internal" admin basket belonging to * the user, held in their underlying wallet. * * It can represent any of the four permission categories by having the relevant fields: * - DPACP: originator, privileged, protocol, securityLevel, counterparty * - DBAP: originator, basketName * - DCAP: originator, privileged, verifier, certType, certFields * - DSAP: originator, authorizedAmount */ export interface PermissionToken { /** The transaction ID where this token resides. */ txid: string /** The current transaction encapsulating the token. */ tx: number[] /** The output index within that transaction. */ outputIndex: number /** The exact script hex for the locking script. */ outputScript: string /** The amount of satoshis assigned to the permission output (often 1). */ satoshis: number /** The originator domain or FQDN that is allowed to use this permission. */ originator: string /** The expiration time for this token in UNIX epoch seconds. (0 or omitted for spending authorizations, which are indefinite) */ expiry: number /** Whether this token grants privileged usage (for protocol or certificate). */ privileged?: boolean /** The protocol name, if this is a DPACP token. */ protocol?: string /** The security level (0,1,2) for DPACP. */ securityLevel?: 0 | 1 | 2 /** The counterparty, for DPACP. */ counterparty?: string /** The name of a basket, if this is a DBAP token. */ basketName?: string /** The certificate type, if this is a DCAP token. */ certType?: string /** The certificate fields that this token covers, if DCAP token. */ certFields?: string[] /** The "verifier" public key string, if DCAP. */ verifier?: string /** For DSAP, the maximum authorized spending for the month. */ authorizedAmount?: number } /** * A map from each permission type to a special "admin basket" name used for storing * the tokens. The tokens themselves are unspent transaction outputs (UTXOs) with a * specialized PushDrop script that references the originator, expiry, etc. */ const BASKET_MAP = { protocol: 'admin protocol-permission', basket: 'admin basket-access', certificate: 'admin certificate-access', spending: 'admin spending-authorization' } /** * The set of callbacks that external code can bind to, e.g. to display UI prompts or logs * when a permission is requested. */ export interface WalletPermissionsManagerCallbacks { onProtocolPermissionRequested?: PermissionEventHandler[] onBasketAccessRequested?: PermissionEventHandler[] onCertificateAccessRequested?: PermissionEventHandler[] onSpendingAuthorizationRequested?: PermissionEventHandler[] onGroupedPermissionRequested?: GroupedPermissionEventHandler[] } /** * Configuration object for the WalletPermissionsManager. If a given option is `false`, * the manager will skip or alter certain permission checks or behaviors. * * By default, all of these are `true` unless specified otherwise. This is the most secure configuration. */ export interface PermissionsManagerConfig { /** * For `createSignature` and `verifySignature`, * require a "protocol usage" permission check? */ seekProtocolPermissionsForSigning?: boolean /** * For methods that perform encryption (encrypt/decrypt), require * a "protocol usage" permission check? */ seekProtocolPermissionsForEncrypting?: boolean /** * For methods that perform HMAC creation or verification (createHmac, verifyHmac), * require a "protocol usage" permission check? */ seekProtocolPermissionsForHMAC?: boolean /** * For revealing counterparty-level or specific key linkage revelation information, * should we require permission? */ seekPermissionsForKeyLinkageRevelation?: boolean /** * For revealing any user public key (getPublicKey) **other** than the identity key, * should we require permission? */ seekPermissionsForPublicKeyRevelation?: boolean /** * If getPublicKey is requested with `identityKey=true`, do we require permission? */ seekPermissionsForIdentityKeyRevelation?: boolean /** * If discoverByIdentityKey / discoverByAttributes are called, do we require permission * for "identity resolution" usage? */ seekPermissionsForIdentityResolution?: boolean /** * When we do internalizeAction with `basket insertion`, or include outputs in baskets * with `createAction, do we ask for basket permission? */ seekBasketInsertionPermissions?: boolean /** * When relinquishOutput is called, do we ask for basket permission? */ seekBasketRemovalPermissions?: boolean /** * When listOutputs is called, do we ask for basket permission? */ seekBasketListingPermissions?: boolean /** * When createAction is called with labels, do we ask for "label usage" permission? */ seekPermissionWhenApplyingActionLabels?: boolean /** * When listActions is called with labels, do we ask for "label usage" permission? */ seekPermissionWhenListingActionsByLabel?: boolean /** * If proving a certificate (proveCertificate) or revealing certificate fields, * do we require a "certificate access" permission? */ seekCertificateDisclosurePermissions?: boolean /** * If acquiring a certificate (acquireCertificate), do we require a permission check? */ seekCertificateAcquisitionPermissions?: boolean /** * If relinquishing a certificate (relinquishCertificate), do we require a permission check? */ seekCertificateRelinquishmentPermissions?: boolean /** * If listing a user's certificates (listCertificates), do we require a permission check? */ seekCertificateListingPermissions?: boolean /** * Should transaction descriptions, input descriptions, and output descriptions be encrypted * when before they are passed to the underlying wallet, and transparently decrypted when retrieved? */ encryptWalletMetadata?: boolean /** * If the originator tries to spend wallet funds (netSpent > 0 in createAction), * do we seek spending authorization? */ seekSpendingPermissions?: boolean /** * If true, triggers a grouped permission request flow based on the originator's `manifest.json`. */ seekGroupedPermission?: boolean /** * If false, permissions are checked without regard for whether we are in * privileged mode. Privileged status is ignored with respect to whether * permissions are granted. Internally, they are always sought and checked * with privileged=false, regardless of the actual value. */ differentiatePrivilegedOperations?: boolean } /** * @class WalletPermissionsManager * * Wraps an underlying BRC-100 `Wallet` implementation with permissions management capabilities. * The manager intercepts calls from external applications (identified by originators), checks if the request is allowed, * and if not, orchestrates user permission flows. It creates or renews on-chain tokens in special * admin baskets to track these authorizations. Finally, it proxies the actual call to the underlying wallet. * * ### Key Responsibilities: * - **Permission Checking**: Before standard wallet operations (e.g. `encrypt`), * the manager checks if a valid permission token exists. If not, it attempts to request permission from the user. * - **On-Chain Tokens**: When permission is granted, the manager stores it as an unspent "PushDrop" output. * This can be spent later to revoke or renew the permission. * - **Callbacks**: The manager triggers user-defined callbacks on permission requests (to show a UI prompt), * on grants/denials, and on internal processes. * * ### Implementation Notes: * - The manager follows the BRC-100 `createAction` + `signAction` pattern for building or spending these tokens. * - Token revocation or renewal uses standard BRC-100 flows: we build a transaction that consumes * the old token UTXO and outputs a new one (or none, if fully revoked). */ export class WalletPermissionsManager implements WalletInterface { /** A reference to the BRC-100 wallet instance. */ private underlying: WalletInterface /** The "admin" domain or FQDN that is implicitly allowed to do everything. */ private adminOriginator: string /** * Event callbacks that external code can subscribe to, e.g. to show a UI prompt * or log events. Each event can have multiple handlers. */ private callbacks: WalletPermissionsManagerCallbacks = { onProtocolPermissionRequested: [], onBasketAccessRequested: [], onCertificateAccessRequested: [], onSpendingAuthorizationRequested: [], onGroupedPermissionRequested: [] } /** * We queue parallel requests for the same resource so that only one * user prompt is created for a single resource. If multiple calls come * in at once for the same "protocol:domain:privileged:counterparty" etc., * they get merged. * * The key is a string derived from the operation; the value is an object with a reference to the * associated request and an array of pending promise resolve/reject pairs, one for each active * operation that's waiting on the particular resource described by the key. */ private activeRequests: Map< string, { request: PermissionRequest | { originator: string; permissions: GroupedPermissions } pending: Array<{ resolve: (val: any) => void reject: (err: any) => void }> } > = new Map() /** Cache recently confirmed permissions to avoid repeated lookups. */ private permissionCache: Map<string, { expiry: number; cachedAt: number }> = new Map() /** How long a cached permission remains valid (5 minutes). */ private static readonly CACHE_TTL_MS = 5 * 60 * 1000 /** * Configuration that determines whether to skip or apply various checks and encryption. */ private config: PermissionsManagerConfig /** * Constructs a new Permissions Manager instance. * * @param underlyingWallet The underlying BRC-100 wallet, where requests are forwarded after permission is granted * @param adminOriginator The domain or FQDN that is automatically allowed everything * @param config A set of boolean flags controlling how strictly permissions are enforced */ constructor(underlyingWallet: WalletInterface, adminOriginator: string, config: PermissionsManagerConfig = {}) { this.underlying = underlyingWallet this.adminOriginator = adminOriginator // Default all config options to true unless specified this.config = { seekProtocolPermissionsForSigning: true, seekProtocolPermissionsForEncrypting: true, seekProtocolPermissionsForHMAC: true, seekPermissionsForKeyLinkageRevelation: true, seekPermissionsForPublicKeyRevelation: true, seekPermissionsForIdentityKeyRevelation: true, seekPermissionsForIdentityResolution: true, seekBasketInsertionPermissions: true, seekBasketRemovalPermissions: true, seekBasketListingPermissions: true, seekPermissionWhenApplyingActionLabels: true, seekPermissionWhenListingActionsByLabel: true, seekCertificateDisclosurePermissions: true, seekCertificateAcquisitionPermissions: true, seekCertificateRelinquishmentPermissions: true, seekCertificateListingPermissions: true, encryptWalletMetadata: true, seekSpendingPermissions: true, seekGroupedPermission: true, differentiatePrivilegedOperations: true, ...config // override with user-specified config } } /* --------------------------------------------------------------------- * 1) PUBLIC API FOR REGISTERING CALLBACKS (UI PROMPTS, LOGGING, ETC.) * --------------------------------------------------------------------- */ /** * Binds a callback function to a named event, such as `onProtocolPermissionRequested`. * * @param eventName The name of the event to listen to * @param handler A function that handles the event * @returns A numeric ID you can use to unbind later */ public bindCallback( eventName: keyof WalletPermissionsManagerCallbacks, handler: PermissionEventHandler | GroupedPermissionEventHandler ): number { const arr = this.callbacks[eventName]! as any[] arr.push(handler) return arr.length - 1 } /** * Unbinds a previously registered callback by either its numeric ID (returned by `bindCallback`) * or by exact function reference. * * @param eventName The event name, e.g. "onProtocolPermissionRequested" * @param reference Either the numeric ID or the function reference * @returns True if successfully unbound, false otherwise */ public unbindCallback(eventName: keyof WalletPermissionsManagerCallbacks, reference: number | Function): boolean { if (!this.callbacks[eventName]) return false const arr = this.callbacks[eventName] as any[] if (typeof reference === 'number') { if (arr[reference]) { arr[reference] = null return true } return false } else { const index = arr.indexOf(reference) if (index !== -1) { arr[index] = null return true } return false } } /** * Internally triggers a named event, calling all subscribed listeners. * Each callback is awaited in turn (though errors are swallowed so that * one failing callback doesn't prevent the others). * * @param eventName The event name * @param param The parameter object passed to all listeners */ private async callEvent(eventName: keyof WalletPermissionsManagerCallbacks, param: any): Promise<void> { const arr = this.callbacks[eventName] || [] for (const cb of arr) { if (typeof cb === 'function') { try { await cb(param) } catch (e) { // Intentionally swallow errors from user-provided callbacks } } } } /* --------------------------------------------------------------------- * 2) PERMISSION (GRANT / DENY) METHODS * --------------------------------------------------------------------- */ /** * Grants a previously requested permission. * This method: * 1) Resolves all pending promise calls waiting on this request * 2) Optionally creates or renews an on-chain PushDrop token (unless `ephemeral===true`) * * @param params requestID to identify which request is granted, plus optional expiry * or `ephemeral` usage, etc. */ public async grantPermission(params: { requestID: string expiry?: number ephemeral?: boolean amount?: number }): Promise<void> { // 1) Identify the matching queued requests in `activeRequests` const matching = this.activeRequests.get(params.requestID) if (!matching) { throw new Error('Request ID not found.') } // 2) Mark all matching requests as resolved, deleting the entry for (const x of matching.pending) { x.resolve(true) } this.activeRequests.delete(params.requestID) // 3) If `ephemeral !== true`, we create or renew an on-chain token if (!params.ephemeral) { const request = matching.request as PermissionRequest if (!request.renewal) { // brand-new permission token await this.createPermissionOnChain( request, params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30, // default 30-day expiry params.amount ) } else { // renewal => spend the old token, produce a new one await this.renewPermissionOnChain( request.previousToken!, request, params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30, params.amount ) } } // Only cache non-ephemeral permissions // Ephemeral permissions should not be cached as they are one-time authorizations if (!params.ephemeral) { const expiry = params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30 const key = this.buildRequestKey(matching.request as PermissionRequest) this.cachePermission(key, expiry) } } /** * Denies a previously requested permission. * This method rejects all pending promise calls waiting on that request * * @param requestID requestID identifying which request to deny */ public async denyPermission(requestID: string): Promise<void> { // 1) Identify the matching requests const matching = this.activeRequests.get(requestID) if (!matching) { throw new Error('Request ID not found.') } // 2) Reject all matching requests, deleting the entry for (const x of matching.pending) { x.reject(new Error('Permission denied.')) } this.activeRequests.delete(requestID) } /** * Grants a previously requested grouped permission. * @param params.requestID The ID of the request being granted. * @param params.granted A subset of the originally requested permissions that the user has granted. * @param params.expiry An optional expiry time (in seconds) for the new permission tokens. */ public async grantGroupedPermission(params: { requestID: string granted: Partial<GroupedPermissions> expiry?: number }): Promise<void> { const matching = this.activeRequests.get(params.requestID) if (!matching) { throw new Error('Request ID not found.') } const originalRequest = matching.request as { originator: string; permissions: GroupedPermissions } const { originator, permissions: requestedPermissions } = originalRequest // --- Validation: Ensure granted permissions are a subset of what was requested --- if ( params.granted.spendingAuthorization && !deepEqual(params.granted.spendingAuthorization, requestedPermissions.spendingAuthorization) ) { throw new Error('Granted spending authorization does not match the original request.') } if ( params.granted.protocolPermissions?.some( g => !requestedPermissions.protocolPermissions?.find(r => deepEqual(r, g)) ) ) { throw new Error('Granted protocol permissions are not a subset of the original request.') } if (params.granted.basketAccess?.some(g => !requestedPermissions.basketAccess?.find(r => deepEqual(r, g)))) { throw new Error('Granted basket access permissions are not a subset of the original request.') } if ( params.granted.certificateAccess?.some(g => !requestedPermissions.certificateAccess?.find(r => deepEqual(r, g))) ) { throw new Error('Granted certificate access permissions are not a subset of the original request.') } // --- End Validation --- const expiry = params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30 // 30-day default if (params.granted.spendingAuthorization) { await this.createPermissionOnChain( { type: 'spending', originator, spending: { satoshis: params.granted.spendingAuthorization.amount }, reason: params.granted.spendingAuthorization.description }, 0, // No expiry for spending tokens params.granted.spendingAuthorization.amount ) } for (const p of params.granted.protocolPermissions || []) { await this.createPermissionOnChain( { type: 'protocol', originator, privileged: false, // No privileged protocols allowed in groups for added security. protocolID: p.protocolID, counterparty: p.counterparty || 'self', reason: p.description }, expiry ) } for (const b of params.granted.basketAccess || []) { await this.createPermissionOnChain( { type: 'basket', originator, basket: b.basket, reason: b.description }, expiry ) } for (const c of params.granted.certificateAccess || []) { await this.createPermissionOnChain( { type: 'certificate', originator, privileged: false, // No certificates on the privileged identity are allowed as part of groups. certificate: { verifier: c.verifierPublicKey, certType: c.type, fields: c.fields }, reason: c.description }, expiry ) } // Resolve all pending promises for this request for (const p of matching.pending) { p.resolve(true) } this.activeRequests.delete(params.requestID) } /** * Denies a previously requested grouped permission. * @param requestID The ID of the request being denied. */ public async denyGroupedPermission(requestID: string): Promise<void> { const matching = this.activeRequests.get(requestID) if (!matching) { throw new Error('Request ID not found.') } const err = new Error('The user has denied the request for permission.') ;(err as any).code = 'ERR_PERMISSION_DENIED' for (const p of matching.pending) { p.reject(err) } this.activeRequests.delete(requestID) } /* --------------------------------------------------------------------- * 3) THE "ENSURE" METHODS: CHECK IF PERMISSION EXISTS, OTHERWISE PROMPT * --------------------------------------------------------------------- */ /** * Ensures the originator has protocol usage permission. * If no valid (unexpired) permission token is found, triggers a permission request flow. */ public async ensureProtocolPermission({ originator, privileged, protocolID, counterparty, reason, seekPermission = true, usageType }: { originator: string privileged: boolean protocolID: WalletProtocol counterparty: string reason?: string seekPermission?: boolean usageType: 'signing' | 'encrypting' | 'hmac' | 'publicKey' | 'identityKey' | 'linkageRevelation' | 'generic' }): Promise<boolean> { // 1) adminOriginator can do anything if (this.isAdminOriginator(originator)) return true // 2) If security level=0, we consider it "open" usage const [level, protoName] = protocolID if (level === 0) return true // 3) If protocol is admin-reserved, block if (this.isAdminProtocol(protocolID)) { throw new Error(`Protocol “${protoName}” is admin-only.`) } // Allow the configured exceptions. if (usageType === 'signing' && !this.config.seekProtocolPermissionsForSigning) { return true } if (usageType === 'encrypting' && !this.config.seekProtocolPermissionsForEncrypting) { return true } if (usageType === 'hmac' && !this.config.seekProtocolPermissionsForHMAC) { return true } if (usageType === 'publicKey' && !this.config.seekPermissionsForPublicKeyRevelation) { return true } if (usageType === 'identityKey' && !this.config.seekPermissionsForIdentityKeyRevelation) { return true } if (usageType === 'linkageRevelation' && !this.config.seekPermissionsForKeyLinkageRevelation) { return true } if (!this.config.differentiatePrivilegedOperations) { privileged = false } const cacheKey = this.buildRequestKey({ type: 'protocol', originator, privileged, protocolID, counterparty }) if (this.isPermissionCached(cacheKey)) { return true } // 4) Attempt to find a valid token in the internal basket const token = await this.findProtocolToken( originator, privileged, protocolID, counterparty, /*includeExpired=*/ true ) if (token) { if (!this.isTokenExpired(token.expiry)) { // valid and unexpired this.cachePermission(cacheKey, token.expiry) return true } else { // has a token but expired => request renewal if allowed if (!seekPermission) { throw new Error(`Protocol permission expired and no further user consent allowed (seekPermission=false).`) } return await this.requestPermissionFlow({ type: 'protocol', originator, privileged, protocolID, counterparty, reason, renewal: true, previousToken: token }) } } else { // No token found => request a new one if allowed if (!seekPermission) { throw new Error(`No protocol permission token found (seekPermission=false).`) } const granted = await this.requestPermissionFlow({ type: 'protocol', originator, privileged, protocolID, counterparty, reason, renewal: false }) return granted } } /** * Ensures the originator has basket usage permission for the specified basket. * If not, triggers a permission request flow. */ public async ensureBasketAccess({ originator, basket, reason, seekPermission = true, usageType }: { originator: string basket: string reason?: string seekPermission?: boolean usageType: 'insertion' | 'removal' | 'listing' }): Promise<boolean> { if (this.isAdminOriginator(originator)) return true if (this.isAdminBasket(basket)) { throw new Error(`Basket “${basket}” is admin-only.`) } if (usageType === 'insertion' && !this.config.seekBasketInsertionPermissions) return true if (usageType === 'removal' && !this.config.seekBasketRemovalPermissions) return true if (usageType === 'listing' && !this.config.seekBasketListingPermissions) return true const cacheKey = this.buildRequestKey({ type: 'basket', originator, basket }) if (this.isPermissionCached(cacheKey)) { return true } const token = await this.findBasketToken(originator, basket, true) if (token) { if (!this.isTokenExpired(token.expiry)) { this.cachePermission(cacheKey, token.expiry) return true } else { if (!seekPermission) { throw new Error(`Basket permission expired (seekPermission=false).`) } return await this.requestPermissionFlow({ type: 'basket', originator, basket, reason, renewal: true, previousToken: token }) } } else { // none if (!seekPermission) { throw new Error(`No basket permission found, and no user consent allowed (seekPermission=false).`) } const granted = await this.requestPermissionFlow({ type: 'basket', originator, basket, reason, renewal: false }) return granted } } /** * Ensures the originator has a valid certificate permission. * This is relevant when revealing certificate fields in DCAP contexts. */ public async ensureCertificateAccess({ originator, privileged, verifier, certType, fields, reason, seekPermission = true, usageType }: { originator: string privileged: boolean verifier: string certType: string fields: string[] reason?: string seekPermission?: boolean usageType: 'disclosure' }): Promise<boolean> { if (this.isAdminOriginator(originator)) return true if (usageType === 'disclosure' && !this.config.seekCertificateDisclosurePermissions) { return true } if (!this.config.differentiatePrivilegedOperations) { privileged = false } const cacheKey = this.buildRequestKey({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields } }) if (this.isPermissionCached(cacheKey)) { return true } const token = await this.findCertificateToken( originator, privileged, verifier, certType, fields, /*includeExpired=*/ true ) if (token) { if (!this.isTokenExpired(token.expiry)) { this.cachePermission(cacheKey, token.expiry) return true } else { if (!seekPermission) { throw new Error(`Certificate permission expired (seekPermission=false).`) } return await this.requestPermissionFlow({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields }, reason, renewal: true, previousToken: token }) } } else { if (!seekPermission) { throw new Error(`No certificate permission found (seekPermission=false).`) } const granted = await this.requestPermissionFlow({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields }, reason, renewal: false }) return granted } } /** * Ensures the originator has spending authorization (DSAP) for a certain satoshi amount. * If the existing token limit is insufficient, attempts to renew. If no token, attempts to create one. */ public async ensureSpendingAuthorization({ originator, satoshis, lineItems, reason, seekPermission = true }: { originator: string satoshis: number lineItems?: Array<{ type: 'input' | 'output' | 'fee' description: string satoshis: number }> reason?: string seekPermission?: boolean }): Promise<boolean> { if (this.isAdminOriginator(originator)) return true if (!this.config.seekSpendingPermissions) { // We skip spending permission entirely return true } const cacheKey = this.buildRequestKey({ type: 'spending', originator, spending: { satoshis } }) if (this.isPermissionCached(cacheKey)) { return true } const token = await this.findSpendingToken(originator) if (token?.authorizedAmount) { // Check how much has been spent so far const spentSoFar = await this.querySpentSince(token) if (spentSoFar + satoshis <= token.authorizedAmount) { this.cachePermission(cacheKey, token.expiry) return true } else { // Renew if possible if (!seekPermission) { throw new Error( `Spending authorization insufficient for ${satoshis}, no user consent (seekPermission=false).` ) } return await this.requestPermissionFlow({ type: 'spending', originator, spending: { satoshis, lineItems }, reason, renewal: true, previousToken: token }) } } else { // no token if (!seekPermission) { throw new Error(`No spending authorization found, (seekPermission=false).`) } return await this.requestPermissionFlow({ type: 'spending', originator, spending: { satoshis, lineItems }, reason, renewal: false }) } } /** * Ensures the originator has label usage permission. * If no valid (unexpired) permission token is found, triggers a permission request flow. */ public async ensureLabelAccess({ originator, label, reason, seekPermission = true, usageType }: { originator: string label: string reason?: string seekPermission?: boolean usageType: 'apply' | 'list' }): Promise<boolean> { // 1) adminOriginator can do anything if (this.isAdminOriginator(originator)) return true // 2) If label is admin-reserved, block if (this.isAdminLabel(label)) { throw new Error(`Label “${label}” is admin-only.`) } if (usageType === 'apply' && !this.config.seekPermissionWhenApplyingActionLabels) { return true } if (usageType === 'list' && !this.config.seekPermissionWhenListingActionsByLabel) { return true } const cacheKey = this.buildRequestKey({ type: 'protocol', originator, privileged: false, protocolID: [1, `action label ${label}`], counterparty: 'self' }) if (this.isPermissionCached(cacheKey)) { return true } // 3) Let ensureProtocolPermission handle the rest. return await this.ensureProtocolPermission({ originator, privileged: false, protocolID: [1, `action label ${label}`], counterparty: 'self', reason, seekPermission, usageType: 'generic' }) } /** * A central method that triggers the permission request flow. * - It checks if there's already an active request for the same key * - If so, we wait on that existing request rather than creating a duplicative one * - Otherwise we create a new request queue, call the relevant "onXXXRequested" event, * and return a promise that resolves once permission is granted or rejects if denied. */ private async requestPermissionFlow(r: PermissionRequest): Promise<boolean> { const key = this.buildRequestKey(r) // If there's already a queue for the same resource, we piggyback on it const existingQueue = this.activeRequests.get(key) if (existingQueue && existingQueue.pending.length > 0) { return new Promise<boolean>((resolve, reject) => { existingQueue.pending.push({ resolve, reject }) }) } // Otherwise, create a new queue with a single entry // Return a promise that resolves or rejects once the user grants/denies return new Promise<boolean>(async (resolve, reject) => { this.activeRequests.set(key, { request: r, pending: [{ resolve, reject }] }) // Fire the relevant onXXXRequested event (which one depends on r.type) switch (r.type) { case 'protocol': await this.callEvent('onProtocolPermissionRequested', { ...r, requestID: key }) break case 'basket': await this.callEvent('onBasketAccessRequested', { ...r, requestID: key }) break case 'certificate': await this.callEvent('onCertificateAccessRequested', { ...r, requestID: key }) break case 'spending': await this.callEvent('onSpendingAuthorizationRequested', { ...r, requestID: key }) break } }) } /* --------------------------------------------------------------------- * 4) SEARCH / DECODE / DECRYPT ON-CHAIN TOKENS (PushDrop Scripts) * --------------------------------------------------------------------- */ /** * We will use a administrative "permission token encryption" protocol to store fields * in each permission's PushDrop script. This ensures that only the user's wallet * can decrypt them. In practice, this data is not super sensitive, but we still * follow the principle of least exposure. */ private static readonly PERM_TOKEN_ENCRYPTION_PROTOCOL: [2, 'admin permission token encryption'] = [ 2, 'admin permission token encryption' ] /** * Similarly, we will use a "metadata encryption" protocol to preserve the confidentiality * of transaction descriptions and input/output descriptions from lower storage layers. */ private static readonly METADATA_ENCRYPTION_PROTOCOL: [2, 'admin metadata encryption'] = [ 2, 'admin metadata encryption' ] /** We always use `keyID="1"` and `counterparty="self"` for these encryption ops. */ private async encryptPermissionTokenField(plaintext: string | number[]): Promise<number[]> { const data = typeof plaintext === 'string' ? Utils.toArray(plaintext, 'utf8') : plaintext const { ciphertext } = await this.underlying.encrypt( { plaintext: data, protocolID: WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL, keyID: '1' }, this.adminOriginator ) return ciphertext } private async decryptPermissionTokenField(ciphertext: number[]): Promise<number[]> { try { const { plaintext } = await this.underlying.decrypt( { ciphertext, protocolID: WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL, keyID: '1' }, this.adminOriginator ) return plaintext } catch (e) { return ciphertext } } /** * Encrypts wallet metadata if configured to do so, otherwise returns the original plaintext for storage. * @param plaintext The metadata to encrypt if configured to do so * @returns The encrypted metadata, or the original value if encryption was disabled. */ private async maybeEncryptMetadata(plaintext: string): Promise<Base64String> { if (!this.config.encryptWalletMetadata) { return plaintext } const { ciphertext } = await this.underlying.encrypt( { plaintext: Utils.toArray(plaintext, 'utf8'), protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL, keyID: '1' }, this.adminOriginator ) return Utils.toBase64(ciphertext) } /** * Attempts to decrypt metadata. if decryption fails, assumes the value is already plaintext and returns it. * @param ciphertext The metadata to attempt decryption for. * @returns The decrypted metadata. If decryption fails, returns the original value instead. */ private async maybeDecryptMetadata(ciphertext: Base64String): Promise<string> { try { const { plaintext } = await this.underlying.decrypt( { ciphertext: Utils.toArray(ciphertext, 'base64'), protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL, keyID: '1' }, this.adminOriginator ) return Utils.toUTF8(plaintext) } catch (e) { return ciphertext } } /** Helper to see if a token's expiry is in the past. */ private isTokenExpired(expiry: number): boolean { const now = Math.floor(Date.now() / 1000) return expiry > 0 && expiry < now } /** Looks for a DPACP permission token matching origin/domain, privileged, protocol, cpty. */ private async findProtocolToken( originator: string, privileged: boolean, protocolID: WalletProtocol, counterparty: string, includeExpired: boolean ): Promise<PermissionToken | undefined> { const [secLevel, protoName] = protocolID const tags = [ `originator ${originator}`, `privileged ${!!privileged}`, `protocolName ${protoName}`, `protocolSecurityLevel ${secLevel}` ] if (secLevel === 2) { tags.push(`counterparty ${counterparty}`) } const result = await this.underlying.listOutputs( { basket: BASKET_MAP.protocol, tags, tagQueryMode: 'all', include: 'entire transactions' }, this.adminOriginator ) for (const out of result.outputs) { const [txid, outputIndexStr] = out.outpoint.split('.') const tx = Transaction.fromBEEF(result.BEEF!, txid) const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript) if (!dec || !dec.fields || dec.fields.length < 6) continue const domainRaw = dec.fields[0] const expiryRaw = dec.fields[1] const privRaw = dec.fields[2] const secLevelRaw = dec.fields[3] const protoNameRaw = dec.fields[4] const counterpartyRaw = dec.fields[5] const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw)) const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10) const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true' const secLevelDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(secLevelRaw)), 10) as | 0 | 1 | 2 const protoNameDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(protoNameRaw)) const cptyDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(counterpartyRaw)) if ( domainDecoded !== originator || privDecoded !== !!privileged || secLevelDecoded !== secLevel || protoNameDecoded !== protoName || (secLevelDecoded === 2 && cptyDecoded !== counterparty) ) { continue } if (!includeExpired && this.isTokenExpired(expiryDecoded)) { continue } return { tx: tx.toBEEF(), txid: out.outpoint.split('.')[0], outputIndex: parseInt(out.outpoint.split('.')[1], 10), outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(), satoshis: out.satoshis, originator, privileged, protocol: protoName, securityLevel: secLevel, expiry: expiryDecoded, counterparty: cptyDecoded } } return undefined } /** Looks for a DBAP token matching (originator, basket). */ private async findBasketToken( originator: string, basket: string, includeExpired: boolean ): Promise<PermissionToken | undefined> { const result = await this.underlying.listOutputs( { basket: BASKET_MAP.basket, tags: [`originator ${originator}`, `basket ${basket}`], tagQueryMode: 'all', include: 'entire transactions' }, this.adminOriginator ) for (const out of result.outputs) { const [txid, outputIndexStr] = out.outpoint.split('.') const tx = Transaction.fromBEEF(result.BEEF!, txid) const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript) if (!dec?.fields || dec.fields.length < 3) continue const domainRaw = dec.fields[0] const expiryRaw = dec.fields[1] const basketRaw = dec.fields[2] const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw)) const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10) const basketDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(basketRaw)) if (domainDecoded !== originator || basketDecoded !== basket) continue if (!includeExpired && this.isTokenExpired(expiryDecoded)) continue return { tx: tx.toBEEF(), txid: out.outpoint.split('.')[0], outputIndex: parseInt(out.outpoint.split('.')[1], 10), outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(), satoshis: out.satoshis, originator, basketName: basketDecoded, expiry: expiryDecoded } } return undefined } /** Looks for a DCAP token matching (origin, privileged, verifier, certType, fields subset). */ private async findCertificateToken( originator: string, privileged: boolean, verifier: string, certType: string, fields: string[], includeExpired: boolean ): Promise<PermissionToken | undefined> { const result = await this.underlying.listOutputs( { basket: BASKET_MAP.certificate, tags: [`originator ${originator}`, `privileged ${!!privileged}`, `type ${certType}`, `verifier ${verifier}`], tagQueryMode: 'all', include: 'entire transactions' }, this.adminOriginator ) for (const out of result.outputs) { const [txid, outputIndexStr] = out.outpoint.split('.') const tx = Transaction.fromBEEF(result.BEEF!, txid) const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript) if (!dec?.fields || dec.fields.length < 6) continue const [domainRaw, expiryRaw, privRaw, typeRaw, fieldsRaw, verifierRaw] = dec.fields const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw)) const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10) const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true' const typeDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(typeRaw)) const verifierDec = Utils.toUTF8(await this.decryptPermissionTokenField(verifierRaw)) const fieldsJson = await this.decryptPermissionTokenField(fieldsRaw) const allFields = JSON.parse(Utils.toUTF8(fieldsJson)) as string[] if ( domainDecoded !== originator || privDecoded !== !!privileged || typeDecoded !== certType || verifierDec !== verifier ) { continue } // Check if 'fields' is a subset of 'allFields' const setAll = new Set(allFields) if (fields.some(f => !setAll.has(f))) { continue } if (!includeExpired && this.isTokenExpired(expiryDecoded)) { continue } return { tx: tx.toBEEF(), txid: out.outpoint.split('.')[0], outputIndex: parseInt(out.outpoint.split('.')[1], 10), outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(), satoshis: out.satoshis, originator, privileged, verifier: verifierDec, certType: typeDecoded, certFields: allFields, expiry: expiryDecoded } } return undefined } /** Looks for a DSAP token matching origin, returning the first one found. */ private async findSpendingToken(originator: string): Promise<PermissionToken | undefined> { const result = await this.underlying.listOutputs( { basket: BASKET_MAP.spending, tags: [`originator ${originator}`], tagQueryMode: 'all', include: 'entire transactions' }, this.adminOriginator ) for (const out of result.ou