UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

364 lines 18.5 kB
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