@bsv/sdk
Version:
BSV Blockchain Software Development Kit
364 lines • 18.5 kB
JavaScript
import { DEFAULT_IDENTITY_CLIENT_OPTIONS, defaultIdentity, KNOWN_IDENTITY_TYPES } from './types/index.js';
import { WalletClient } from '../wallet/index.js';
import { Transaction } from '../transaction/index.js';
import Certificate from '../auth/certificates/Certificate.js';
import { PushDrop } from '../script/index.js';
import { PrivateKey, Utils } from '../primitives/index.js';
import { LookupResolver, SHIPBroadcaster, TopicBroadcaster, withDoubleSpendRetry } from '../overlay-tools/index.js';
import { ContactsManager } from './ContactsManager.js';
/**
* IdentityClient lets you discover who others are, and let the world know who you are.
*/
export class IdentityClient {
options;
originator;
wallet;
contactsManager;
constructor(wallet, options = DEFAULT_IDENTITY_CLIENT_OPTIONS, originator) {
this.options = options;
this.originator = originator;
this.originator = originator;
this.wallet = wallet ?? new WalletClient();
this.contactsManager = new ContactsManager(this.wallet, this.originator);
}
/**
* Publicly reveals selected fields from a given certificate by creating a publicly verifiable certificate.
* The publicly revealed certificate is included in a blockchain transaction and broadcast to a federated overlay node.
*
* @param {Certificate} certificate - The master certificate to selectively reveal.
* @param {CertificateFieldNameUnder50Bytes[]} fieldsToReveal - An array of certificate field names to reveal. Only these fields will be included in the public certificate.
*
* @returns {Promise<object>} A promise that resolves with the broadcast result from the overlay network.
* @throws {Error} Throws an error if the certificate is invalid, the fields cannot be revealed, or if the broadcast fails.
*/
async publiclyRevealAttributes(certificate, fieldsToReveal) {
if (Object.keys(certificate.fields).length === 0) {
throw new Error('Public reveal failed: Certificate has no fields to reveal!');
}
if (fieldsToReveal.length === 0) {
throw new Error('Public reveal failed: You must reveal at least one field!');
}
try {
const masterCert = new Certificate(certificate.type, certificate.serialNumber, certificate.subject, certificate.certifier, certificate.revocationOutpoint, certificate.fields, certificate.signature);
await masterCert.verify();
}
catch (error) {
throw new Error('Public reveal failed: Certificate verification failed!');
}
// Given we already have a master certificate from a certifier,
// create an anyone verifiable certificate with selectively revealed fields
const { keyringForVerifier } = await this.wallet.proveCertificate({
certificate,
fieldsToReveal,
verifier: new PrivateKey(1).toPublicKey().toString()
}, this.originator);
// Build the lockingScript with pushdrop.create() and the transaction with createAction()
const lockingScript = await new PushDrop(this.wallet, this.originator).lock([
Utils.toArray(JSON.stringify({ ...certificate, keyring: keyringForVerifier }))
], this.options.protocolID, this.options.keyID, 'anyone', true, true);
// TODO: Consider verification and if this is necessary
// counterpartyCanVerifyMyOwnership: true
const { tx } = await this.wallet.createAction({
description: 'Create a new Identity Token',
outputs: [
{
satoshis: this.options.tokenAmount,
lockingScript: lockingScript.toHex(),
outputDescription: 'Identity Token'
}
],
options: {
randomizeOutputs: false
}
}, this.originator);
if (tx !== undefined) {
// Submit the transaction to an overlay
const broadcaster = new TopicBroadcaster(['tm_identity'], {
networkPreset: (await this.wallet.getNetwork({})).network
});
return await broadcaster.broadcast(Transaction.fromAtomicBEEF(tx));
}
throw new Error('Public reveal failed: failed to create action!');
}
/**
* Resolves displayable identity certificates, issued to a given identity key by a trusted certifier.
*
* @param {DiscoverByIdentityKeyArgs} args - Arguments for requesting the discovery based on the identity key.
* @param {boolean} [overrideWithContacts=true] - Whether to override the results with personal contacts if available.
* @returns {Promise<DisplayableIdentity[]>} The promise resolves to displayable identities.
*/
async resolveByIdentityKey(args, overrideWithContacts = true) {
if (overrideWithContacts) {
// Override results with personal contacts if available
const contacts = await this.contactsManager.getContacts(args.identityKey);
if (contacts.length > 0) {
return contacts;
}
}
const { certificates } = await this.wallet.discoverByIdentityKey(args, this.originator);
return certificates.map((cert) => {
return IdentityClient.parseIdentity(cert);
});
}
/**
* Resolves displayable identity certificates by specific identity attributes, issued by a trusted entity.
*
* @param {DiscoverByAttributesArgs} args - Attributes and optional parameters used to discover certificates.
* @param {boolean} [overrideWithContacts=true] - Whether to override the results with personal contacts if available.
* @returns {Promise<DisplayableIdentity[]>} The promise resolves to displayable identities.
*/
async resolveByAttributes(args, overrideWithContacts = true) {
// Run both queries in parallel for better performance
const [contacts, certificatesResult] = await Promise.all([
overrideWithContacts
? this.contactsManager.getContacts()
: Promise.resolve([]),
this.wallet.discoverByAttributes(args, this.originator)
]);
// Fast lookup by identityKey
const contactByKey = new Map(contacts.map((contact) => [contact.identityKey, contact]));
// Guard if certificates might be absent
const certs = certificatesResult?.certificates ?? [];
// Parse certificates and substitute with contacts where available
return certs.map((cert) => contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert));
}
/**
* Remove public certificate revelation from overlay services by spending the identity token
* @param serialNumber - Unique serial number of the certificate to revoke revelation
*/
async revokeCertificateRevelation(serialNumber) {
// 1. Find existing UTXO
const lookupResolver = new LookupResolver({
networkPreset: (await this.wallet.getNetwork({})).network
});
const result = await lookupResolver.query({
service: 'ls_identity',
query: {
serialNumber
}
});
if (result.type !== 'output-list') {
throw new Error('Failed to get lookup result');
}
const topicBroadcaster = new SHIPBroadcaster(['tm_identity'], {
networkPreset: (await this.wallet.getNetwork({})).network,
requireAcknowledgmentFromAllHostsForTopics: [],
requireAcknowledgmentFromAnyHostForTopics: [],
requireAcknowledgmentFromSpecificHostsForTopics: { tm_identity: [] }
});
await withDoubleSpendRetry(async () => {
const tx = Transaction.fromBEEF(result.outputs[0].beef);
const outpoint = `${tx.id('hex')}.${this.options.outputIndex}`;
const lockingScript = tx.outputs[this.options.outputIndex].lockingScript;
if (lockingScript === undefined || outpoint === undefined) {
throw new Error('Failed to get locking script for revelation output!');
}
// 2. Parse results
const { signableTransaction } = await this.wallet.createAction({
description: 'Spend certificate revelation token',
inputBEEF: result.outputs[0].beef,
inputs: [
{
inputDescription: 'Revelation token',
outpoint,
unlockingScriptLength: 74
}
],
options: {
randomizeOutputs: false,
acceptDelayedBroadcast: false,
noSend: true
}
}, this.originator);
if (signableTransaction === undefined) {
throw new Error('Failed to create signable transaction');
}
const partialTx = Transaction.fromBEEF(signableTransaction.tx);
const unlocker = new PushDrop(this.wallet, this.originator).unlock(this.options.protocolID, this.options.keyID, 'anyone');
const unlockingScript = await unlocker.sign(partialTx, this.options.outputIndex);
const { tx: signedTx } = await this.wallet.signAction({
reference: signableTransaction.reference,
spends: {
[this.options.outputIndex]: {
unlockingScript: unlockingScript.toHex()
}
},
options: {
acceptDelayedBroadcast: false,
noSend: true
}
}, this.originator);
if (signedTx === undefined) {
throw new Error('Failed to sign transaction');
}
await topicBroadcaster.broadcast(Transaction.fromAtomicBEEF(signedTx));
}, topicBroadcaster);
}
/**
* Load all records from the contacts basket
* @param identityKey Optional specific identity key to fetch
* @param forceRefresh Whether to force a check for new contact data
* @param limit Optional limit on number of contacts to fetch
* @returns A promise that resolves with an array of contacts
*/
async getContacts(identityKey, forceRefresh = false, limit = 1000) {
return await this.contactsManager.getContacts(identityKey, forceRefresh, limit);
}
/**
* Save or update a Metanet contact
* @param contact The displayable identity information for the contact
* @param metadata Optional metadata to store with the contact (ex. notes, aliases, etc)
*/
async saveContact(contact, metadata) {
return await this.contactsManager.saveContact(contact, metadata);
}
/**
* Remove a contact from the contacts basket
* @param identityKey The identity key of the contact to remove
*/
async removeContact(identityKey) {
return await this.contactsManager.removeContact(identityKey);
}
/**
* Parse out identity and certifier attributes to display from an IdentityCertificate
* @param identityToParse - The Identity Certificate to parse
* @returns - IdentityToDisplay
*/
static parseIdentity(identityToParse) {
const { type, decryptedFields, certifierInfo } = identityToParse;
let name, avatarURL, badgeLabel, badgeIconURL, badgeClickURL;
// Parse out the name to display based on the specific certificate type which has clearly defined fields.
switch (type) {
case KNOWN_IDENTITY_TYPES.xCert:
name = decryptedFields.userName;
avatarURL = decryptedFields.profilePhoto;
badgeLabel = `X account certified by ${certifierInfo.name}`;
badgeIconURL = certifierInfo.iconUrl;
badgeClickURL = 'https://socialcert.net'; // TODO Make a specific page for this.
break;
case KNOWN_IDENTITY_TYPES.discordCert:
name = decryptedFields.userName;
avatarURL = decryptedFields.profilePhoto;
badgeLabel = `Discord account certified by ${certifierInfo.name}`;
badgeIconURL = certifierInfo.iconUrl;
badgeClickURL = 'https://socialcert.net'; // TODO Make a specific page for this.
break;
case KNOWN_IDENTITY_TYPES.emailCert:
name = decryptedFields.email;
avatarURL = 'XUTZxep7BBghAJbSBwTjNfmcsDdRFs5EaGEgkESGSgjJVYgMEizu';
badgeLabel = `Email certified by ${certifierInfo.name}`;
badgeIconURL = certifierInfo.iconUrl;
badgeClickURL = 'https://socialcert.net'; // TODO Make a specific page for this.
break;
case KNOWN_IDENTITY_TYPES.phoneCert:
name = decryptedFields.phoneNumber;
avatarURL = 'XUTLxtX3ELNUwRhLwL7kWNGbdnFM8WG2eSLv84J7654oH8HaJWrU';
badgeLabel = `Phone certified by ${certifierInfo.name}`;
badgeIconURL = certifierInfo.iconUrl;
badgeClickURL = 'https://socialcert.net'; // TODO Make a specific page for this.
break;
case KNOWN_IDENTITY_TYPES.identiCert:
name = `${decryptedFields.firstName} ${decryptedFields.lastName}`;
avatarURL = decryptedFields.profilePhoto;
badgeLabel = `Government ID certified by ${certifierInfo.name}`;
badgeIconURL = certifierInfo.iconUrl;
badgeClickURL = 'https://identicert.me'; // TODO Make a specific page for this.
break;
case KNOWN_IDENTITY_TYPES.registrant:
name = decryptedFields.name;
avatarURL = decryptedFields.icon;
badgeLabel = `Entity certified by ${certifierInfo.name}`;
badgeIconURL = certifierInfo.iconUrl;
badgeClickURL = 'https://projectbabbage.com/docs/registrant'; // TODO: Make this doc page exist
break;
case KNOWN_IDENTITY_TYPES.coolCert:
name = decryptedFields.cool === 'true' ? 'Cool Person!' : 'Not cool!';
break;
case KNOWN_IDENTITY_TYPES.anyone:
name = 'Anyone';
avatarURL = 'XUT4bpQ6cpBaXi1oMzZsXfpkWGbtp2JTUYAoN7PzhStFJ6wLfoeR';
badgeLabel =
'Represents the ability for anyone to access this information.';
badgeIconURL = 'XUUV39HVPkpmMzYNTx7rpKzJvXfeiVyQWg2vfSpjBAuhunTCA9uG';
badgeClickURL = 'https://projectbabbage.com/docs/anyone-identity'; // TODO: Make this doc page exist
break;
case KNOWN_IDENTITY_TYPES.self:
name = 'You';
avatarURL = 'XUT9jHGk2qace148jeCX5rDsMftkSGYKmigLwU2PLLBc7Hm63VYR';
badgeLabel = 'Represents your ability to access this information.';
badgeIconURL = 'XUUV39HVPkpmMzYNTx7rpKzJvXfeiVyQWg2vfSpjBAuhunTCA9uG';
badgeClickURL = 'https://projectbabbage.com/docs/self-identity'; // TODO: Make this doc page exist
break;
default: {
const parsed = IdentityClient.tryToParseGenericIdentity(type, decryptedFields, certifierInfo);
name = parsed.name;
avatarURL = parsed.avatarURL;
badgeLabel = parsed.badgeLabel;
badgeIconURL = parsed.badgeIconURL;
badgeClickURL = parsed.badgeClickURL;
break;
}
}
return {
name,
avatarURL,
abbreviatedKey: identityToParse.subject.length > 0
? `${identityToParse.subject.substring(0, 10)}...`
: '',
identityKey: identityToParse.subject,
badgeIconURL,
badgeLabel,
badgeClickURL
};
}
/**
* Helper to check if a value is a non-empty string
*/
static hasValue(value) {
return value !== undefined && value !== null && value !== '';
}
/**
* Try to parse identity information from unknown certificate types
* by checking common field names
*/
static tryToParseGenericIdentity(type, decryptedFields, certifierInfo) {
// Try to construct a name from common field patterns
const firstName = decryptedFields.firstName;
const lastName = decryptedFields.lastName;
const fullName = IdentityClient.hasValue(firstName) && IdentityClient.hasValue(lastName)
? `${firstName} ${lastName}`
: IdentityClient.hasValue(firstName)
? firstName
: IdentityClient.hasValue(lastName)
? lastName
: undefined;
const name = IdentityClient.hasValue(decryptedFields.name)
? decryptedFields.name
: IdentityClient.hasValue(decryptedFields.userName)
? decryptedFields.userName
: (fullName ??
(IdentityClient.hasValue(decryptedFields.email)
? decryptedFields.email
: defaultIdentity.name));
// Try to find an avatar/photo from common field names
const avatarURL = IdentityClient.hasValue(decryptedFields.profilePhoto)
? decryptedFields.profilePhoto
: IdentityClient.hasValue(decryptedFields.avatar)
? decryptedFields.avatar
: IdentityClient.hasValue(decryptedFields.icon)
? decryptedFields.icon
: IdentityClient.hasValue(decryptedFields.photo)
? decryptedFields.photo
: defaultIdentity.avatarURL;
// Generate badge information
const badgeLabel = IdentityClient.hasValue(certifierInfo?.name)
? `${type} certified by ${String(certifierInfo.name)}`
: defaultIdentity.badgeLabel;
const badgeIconURL = IdentityClient.hasValue(certifierInfo?.iconUrl)
? certifierInfo.iconUrl
: defaultIdentity.badgeIconURL;
const badgeClickURL = defaultIdentity.badgeClickURL;
return { name, avatarURL, badgeLabel, badgeIconURL, badgeClickURL };
}
}
//# sourceMappingURL=IdentityClient.js.map