@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
1,463 lines (1,341 loc) • 102 kB
text/typescript
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