@bsv/wallet-toolbox-client
Version:
Client only Wallet Storage
1,066 lines (1,065 loc) • 126 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WalletPermissionsManager = void 0;
const sdk_1 = require("@bsv/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, object2) {
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) {
return object != null && typeof object === 'object';
}
/**
* 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'
};
/**
* @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).
*/
class WalletPermissionsManager {
/**
* 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, adminOriginator, config = {}) {
/**
* Event callbacks that external code can subscribe to, e.g. to show a UI prompt
* or log events. Each event can have multiple handlers.
*/
this.callbacks = {
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.
*/
this.activeRequests = new Map();
/** Cache recently confirmed permissions to avoid repeated lookups. */
this.permissionCache = new Map();
this.recentGrants = new Map();
this.underlying = underlyingWallet;
this.adminOriginator = this.normalizeOriginator(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
};
}
/* ---------------------------------------------------------------------
* HELPER METHODS FOR P-MODULE DELEGATION
* --------------------------------------------------------------------- */
/**
* Delegates a wallet method call to a P-module if the basket or protocol name uses a P-scheme.
* Handles the full request/response transformation flow.
*
* @param basketOrProtocolName - The basket or protocol name to check for p-module delegation
* @param method - The wallet method name being called
* @param args - The original args passed to the method
* @param originator - The originator of the request
* @param underlyingCall - Callback that executes the underlying wallet method with transformed args
* @returns The transformed response, or null if not a P-basket/protocol (caller should continue normal flow)
*/
async delegateToPModuleIfNeeded(basketOrProtocolName, method, args, originator, underlyingCall) {
var _a;
// Check if this is a P-protocol/basket
if (!basketOrProtocolName.startsWith('p ')) {
return null; // If not, caller should continue normal flow
}
const schemeID = basketOrProtocolName.split(' ')[1];
const module = (_a = this.config.permissionModules) === null || _a === void 0 ? void 0 : _a[schemeID];
if (!module) {
throw new Error(`Unsupported P-module scheme: p ${schemeID}`);
}
// Transform request with module
const transformedReq = await module.onRequest({
method,
args,
originator
});
// Call underlying method with transformed request
const results = await underlyingCall(transformedReq.args, originator);
// Transform response with module
return await module.onResponse(results, {
method,
originator
});
}
/**
* Decrypts custom instructions in listOutputs results if encryption is configured.
*/
async decryptListOutputsMetadata(results) {
if (results.outputs) {
for (let i = 0; i < results.outputs.length; i++) {
if (results.outputs[i].customInstructions) {
results.outputs[i].customInstructions = await this.maybeDecryptMetadata(results.outputs[i].customInstructions);
}
}
}
return results;
}
/* ---------------------------------------------------------------------
* 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
*/
bindCallback(eventName, handler) {
const arr = this.callbacks[eventName];
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
*/
unbindCallback(eventName, reference) {
if (!this.callbacks[eventName])
return false;
const arr = this.callbacks[eventName];
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
*/
async callEvent(eventName, param) {
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.
*/
async grantPermission(params) {
// 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;
if (!request.renewal) {
// brand-new permission token
await this.createPermissionOnChain(request, params.expiry || 0, // default: never expires
params.amount);
}
else {
// renewal => spend the old token, produce a new one
await this.renewPermissionOnChain(request.previousToken, request, params.expiry || 0, // default: never expires
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 || 0; // default: never expires
const key = this.buildRequestKey(matching.request);
this.cachePermission(key, expiry);
this.markRecentGrant(matching.request);
}
}
/**
* Denies a previously requested permission.
* This method rejects all pending promise calls waiting on that request
*
* @param requestID requestID identifying which request to deny
*/
async denyPermission(requestID) {
// 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.
*/
async grantGroupedPermission(params) {
var _a, _b, _c;
const matching = this.activeRequests.get(params.requestID);
if (!matching) {
throw new Error('Request ID not found.');
}
const originalRequest = matching.request;
const { originator, permissions: requestedPermissions, displayOriginator } = originalRequest;
const originLookupValues = this.buildOriginatorLookupValues(displayOriginator, originator);
// --- 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 ((_a = params.granted.protocolPermissions) === null || _a === void 0 ? void 0 : _a.some(g => { var _a; return !((_a = requestedPermissions.protocolPermissions) === null || _a === void 0 ? void 0 : _a.find(r => deepEqual(r, g))); })) {
throw new Error('Granted protocol permissions are not a subset of the original request.');
}
if ((_b = params.granted.basketAccess) === null || _b === void 0 ? void 0 : _b.some(g => { var _a; return !((_a = requestedPermissions.basketAccess) === null || _a === void 0 ? void 0 : _a.find(r => deepEqual(r, g))); })) {
throw new Error('Granted basket access permissions are not a subset of the original request.');
}
if ((_c = params.granted.certificateAccess) === null || _c === void 0 ? void 0 : _c.some(g => { var _a; return !((_a = requestedPermissions.certificateAccess) === null || _a === void 0 ? void 0 : _a.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 || 0; // default: never expires
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 || []) {
const token = await this.findProtocolToken(originator, false, // No privileged protocols allowed in groups for added security.
p.protocolID, p.counterparty || 'self', true, originLookupValues);
if (token) {
const request = {
type: 'protocol',
originator,
privileged: false, // No privileged protocols allowed in groups for added security.
protocolID: p.protocolID,
counterparty: p.counterparty || 'self',
reason: p.description
};
await this.renewPermissionOnChain(token, request, expiry);
this.markRecentGrant(request);
}
else {
const request = {
type: 'protocol',
originator,
privileged: false, // No privileged protocols allowed in groups for added security.
protocolID: p.protocolID,
counterparty: p.counterparty || 'self',
reason: p.description
};
await this.createPermissionOnChain(request, expiry);
this.markRecentGrant(request);
}
}
for (const b of params.granted.basketAccess || []) {
const request = { type: 'basket', originator, basket: b.basket, reason: b.description };
await this.createPermissionOnChain(request, expiry);
this.markRecentGrant(request);
}
for (const c of params.granted.certificateAccess || []) {
const request = {
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
};
await this.createPermissionOnChain(request, expiry);
this.markRecentGrant(request);
}
// 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.
*/
async denyGroupedPermission(requestID) {
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.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.
*/
async ensureProtocolPermission({ originator, privileged, protocolID, counterparty, reason, seekPermission = true, usageType }) {
const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator);
originator = normalizedOriginator;
// 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;
}
if (this.isRecentlyGranted(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, lookupValues);
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.
*/
async ensureBasketAccess({ originator, basket, reason, seekPermission = true, usageType }) {
const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator);
originator = normalizedOriginator;
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;
}
if (this.isRecentlyGranted(cacheKey)) {
return true;
}
const token = await this.findBasketToken(originator, basket, true, lookupValues);
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.
*/
async ensureCertificateAccess({ originator, privileged, verifier, certType, fields, reason, seekPermission = true, usageType }) {
const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator);
originator = normalizedOriginator;
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;
}
if (this.isRecentlyGranted(cacheKey)) {
return true;
}
const token = await this.findCertificateToken(originator, privileged, verifier, certType, fields,
/*includeExpired=*/ true, lookupValues);
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.
*/
async ensureSpendingAuthorization({ originator, satoshis, lineItems, reason, seekPermission = true }) {
const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator);
originator = normalizedOriginator;
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, lookupValues);
if (token === null || token === void 0 ? void 0 : 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.
*/
async ensureLabelAccess({ originator, label, reason, seekPermission = true, usageType }) {
const { normalized: normalizedOriginator } = this.prepareOriginator(originator);
originator = normalizedOriginator;
// 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.
*/
async requestPermissionFlow(r) {
var _a;
const normalizedOriginator = this.normalizeOriginator(r.originator) || r.originator;
const preparedRequest = {
...r,
originator: normalizedOriginator,
displayOriginator: (_a = r.displayOriginator) !== null && _a !== void 0 ? _a : r.originator
};
const key = this.buildRequestKey(preparedRequest);
// 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((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(async (resolve, reject) => {
this.activeRequests.set(key, {
request: preparedRequest,
pending: [{ resolve, reject }]
});
// Fire the relevant onXXXRequested event (which one depends on r.type)
switch (preparedRequest.type) {
case 'protocol':
await this.callEvent('onProtocolPermissionRequested', {
...preparedRequest,
requestID: key
});
break;
case 'basket':
await this.callEvent('onBasketAccessRequested', {
...preparedRequest,
requestID: key
});
break;
case 'certificate':
await this.callEvent('onCertificateAccessRequested', {
...preparedRequest,
requestID: key
});
break;
case 'spending':
await this.callEvent('onSpendingAuthorizationRequested', {
...preparedRequest,
requestID: key
});
break;
}
});
}
/** We always use `keyID="1"` and `counterparty="self"` for these encryption ops. */
async encryptPermissionTokenField(plaintext) {
const data = typeof plaintext === 'string' ? sdk_1.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;
}
async decryptPermissionTokenField(ciphertext) {
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.
*/
async maybeEncryptMetadata(plaintext) {
if (!this.config.encryptWalletMetadata) {
return plaintext;
}
const { ciphertext } = await this.underlying.encrypt({
plaintext: sdk_1.Utils.toArray(plaintext, 'utf8'),
protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL,
keyID: '1'
}, this.adminOriginator);
return sdk_1.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.
*/
async maybeDecryptMetadata(ciphertext) {
try {
const { plaintext } = await this.underlying.decrypt({
ciphertext: sdk_1.Utils.toArray(ciphertext, 'base64'),
protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL,
keyID: '1'
}, this.adminOriginator);
return sdk_1.Utils.toUTF8(plaintext);
}
catch (e) {
return ciphertext;
}
}
/** Helper to see if a token's expiry is in the past. */
isTokenExpired(expiry) {
const now = Math.floor(Date.now() / 1000);
return expiry > 0 && expiry < now;
}
/** Looks for a DPACP permission token matching origin/domain, privileged, protocol, cpty. */
async findProtocolToken(originator, privileged, protocolID, counterparty, includeExpired, originatorLookupValues) {
const [secLevel, protoName] = protocolID;
const originsToTry = (originatorLookupValues === null || originatorLookupValues === void 0 ? void 0 : originatorLookupValues.length) ? originatorLookupValues : [originator];
for (const originTag of originsToTry) {
const tags = [
`originator ${originTag}`,
`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 = sdk_1.Transaction.fromBEEF(result.BEEF, txid);
const dec = sdk_1.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 = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw));
const normalizedDomain = this.normalizeOriginator(domainDecoded);
if (normalizedDomain !== originator) {
continue;
}
const expiryDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10);
const privDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true';
const secLevelDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(secLevelRaw)), 10);
const protoNameDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(protoNameRaw));
const cptyDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(counterpartyRaw));
if (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,
rawOriginator: domainDecoded,
privileged,
protocol: protoName,
securityLevel: secLevel,
expiry: expiryDecoded,
counterparty: cptyDecoded
};
}
}
return undefined;
}
/** Finds ALL DPACP permission tokens matching origin/domain, privileged, protocol, cpty. Never filters by expiry. */
async findAllProtocolTokens(originator, privileged, protocolID, counterparty, originatorLookupValues) {
const [secLevel, protoName] = protocolID;
const originsToTry = (originatorLookupValues === null || originatorLookupValues === void 0 ? void 0 : originatorLookupValues.length) ? originatorLookupValues : [originator];
const matches = [];
const seen = new Set();
for (const originTag of originsToTry) {
const tags = [
`originator ${originTag}`,
`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) {
if (seen.has(out.outpoint))
continue;
const [txid, outputIndexStr] = out.outpoint.split('.');
const tx = sdk_1.Transaction.fromBEEF(result.BEEF, txid);
const vout = Number(outputIndexStr);
const dec = sdk_1.PushDrop.decode(tx.outputs[vout].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 = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw));
const normalizedDomain = this.normalizeOriginator(domainDecoded);
if (normalizedDomain !== originator) {
continue;
}
const expiryDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10);
const privDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true';
const secLevelDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(secLevelRaw)), 10);
const protoNameDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(protoNameRaw));
const cptyDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(counterpartyRaw));
if (privDecoded !== !!privileged ||
secLevelDecoded !== secLevel ||
protoNameDecoded !== protoName ||
(secLevelDecoded === 2 && cptyDecoded !== counterparty)) {
continue;
}
seen.add(out.outpoint);
matches.push({
tx: tx.toBEEF(),
txid,
outputIndex: vout,
outputScript: tx.outputs[vout].lockingScript.toHex(),
satoshis: out.satoshis,
originator,
rawOriginator: domainDecoded,
privileged,
protocol: protoName,
securityLevel: secLevel,
expiry: expiryDecoded,
counterparty: cptyDecoded
});
}
}
return matches;
}
/** Looks for a DBAP token matching (originator, basket). */
async findBasketToken(originator, basket, includeExpired, originatorLookupValues) {
const originsToTry = (originatorLookupValues === null || originatorLookupValues === void 0 ? void 0 : originatorLookupValues.length) ? originatorLookupValues : [originator];
for (const originTag of originsToTry) {
const result = await this.underlying.listOutputs({
basket: BASKET_MAP.basket,
tags: [`originator ${originTag}`, `basket ${basket}`],
tagQueryMode: 'all',
include: 'entire transactions'
}, this.adminOriginator);
for (const out of result.outputs) {
const [txid, outputIndexStr] = out.outpoint.split('.');
const tx = sdk_1.Transaction.fromBEEF(result.BEEF, txid);
const dec = sdk_1.PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript);
if (!(dec === null || dec === void 0 ? void 0 : dec.fields) || dec.fields.length < 3)
continue;
const domainRaw = dec.fields[0];
const expiryRaw = dec.fields[1];
const basketRaw = dec.fields[2];
const domainDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw));
const normalizedDomain = this.normalizeOriginator(domainDecoded);
if (normalizedDomain !== originator) {
continue;
}
const expiryDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10);
const basketDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(basketRaw));
if (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,
rawOriginator: domainDecoded,
basketName: basketDecoded,
expiry: expiryDecoded
};
}
}
return undefined;
}
/** Looks for a DCAP token matching (origin, privileged, verifier, certType, fields subset). */
async findCertificateToken(originator, privileged, verifier, certType, fields, includeExpired, originatorLookupValues) {
const originsToTry = (originatorLookupValues === null || originatorLookupValues === void 0 ? void 0 : originatorLookupValues.length) ? originatorLookupValues : [originator];
for (const originTag of originsToTry) {
const result = await this.underlying.listOutputs({
basket: BASKET_MAP.certificate,
tags: [`originator ${originTag}`, `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 = sdk_1.Transaction.fromBEEF(result.BEEF, txid);
const dec = sdk_1.PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript);
if (!(dec === null || dec === void 0 ? void 0 : dec.fields) || dec.fields.length < 6)
continue;
const [domainRaw, expiryRaw, privRaw, typeRaw, fieldsRaw, verifierRaw] = dec.fields;
const domainDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw));
const normalizedDomain = this.normalizeOriginator(domainDecoded);
if (normalizedDomain !== originator) {
continue;
}
const expiryDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10);
const privDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true';
const typeDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(typeRaw));
const verifierDec = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(verifierRaw));
const fieldsJson = await this.decryptPermissionTokenField(fieldsRaw);
const allFields = JSON.parse(sdk_1.Utils.toUTF8(fieldsJson));