UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

364 lines (335 loc) 14.8 kB
import { AuthFetch } from '../auth/clients/index.js' import { DEFAULT_IDENTITY_CLIENT_OPTIONS, defaultIdentity, DisplayableIdentity, KNOWN_IDENTITY_TYPES } from './types/index.js' import { CertificateFieldNameUnder50Bytes, DiscoverByAttributesArgs, DiscoverByIdentityKeyArgs, IdentityCertificate, OriginatorDomainNameStringUnder250Bytes, PubKeyHex, WalletCertificate, WalletClient, WalletInterface } from '../wallet/index.js' import { BroadcastFailure, BroadcastResponse, 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 { TopicBroadcaster } from '../overlay-tools/index.js' import { ContactsManager, Contact } from './ContactsManager.js' /** * IdentityClient lets you discover who others are, and let the world know who you are. */ export class IdentityClient { private readonly authClient: AuthFetch private readonly wallet: WalletInterface private readonly contactsManager: ContactsManager constructor ( wallet?: WalletInterface, private readonly options = DEFAULT_IDENTITY_CLIENT_OPTIONS, private readonly originator?: OriginatorDomainNameStringUnder250Bytes ) { this.wallet = wallet ?? new WalletClient() this.authClient = new AuthFetch(this.wallet) this.contactsManager = new ContactsManager(this.wallet) } /** * 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: WalletCertificate, fieldsToReveal: CertificateFieldNameUnder50Bytes[] ): Promise<BroadcastResponse | BroadcastFailure> { 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() }) // Build the lockingScript with pushdrop.create() and the transaction with createAction() const lockingScript = await new PushDrop(this.wallet).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 } }) 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: DiscoverByIdentityKeyArgs, overrideWithContacts = true ): Promise<DisplayableIdentity[]> { 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: DiscoverByAttributesArgs, overrideWithContacts = true ): Promise<DisplayableIdentity[]> { // 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<PubKeyHex, Contact>( contacts.map(contact => [contact.identityKey, contact] as const) ) // 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) ) } /** * TODO: Implement once revocation overlay is created * 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: Base64String // ): Promise<BroadcastResponse | BroadcastFailure> { // // 1. Find existing UTXO // const lookupResolver = new LookupResolver() // const result = await lookupResolver.query({ // service: 'ls_identity', // query: { // serialNumber // } // }) // let outpoint: string // let lockingScript: LockingScript | undefined // if (result.type === 'output-list') { // const tx = Transaction.fromAtomicBEEF(result.outputs[this.options.outputIndex].beef) // outpoint = `${tx.id('hex')}.${this.options.outputIndex}` // Consider better way // lockingScript = tx.outputs[this.options.outputIndex].lockingScript // } // if (lockingScript === undefined) { // throw new Error('Failed to get locking script for revelation output!') // } // // 2. Parse results // const { signableTransaction } = await this.wallet.createAction({ // description: '', // inputs: [{ // inputDescription: 'Spend certificate revelation token', // outpoint, // unlockingScriptLength: 73 // }], // options: { // randomizeOutputs: false // } // }) // if (signableTransaction === undefined) { // throw new Error('Failed to create signable transaction') // } // const partialTx = Transaction.fromBEEF(signableTransaction.tx) // const unlocker = new PushDrop(this.wallet).unlock( // this.options.protocolID, // this.options.keyID, // 'self', // 'all', // false, // 1, // lockingScript // ) // 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() // } // } // }) // // 4. Return broadcast status // // Submit the transaction to an overlay // const broadcaster = new SHIPBroadcaster(['tm_identity']) // return await broadcaster.broadcast(Transaction.fromAtomicBEEF(signedTx as number[])) // } /** * 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 */ public async getContacts (identityKey?: PubKeyHex, forceRefresh = false, limit = 1000): Promise<Contact[]> { 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) */ public async saveContact (contact: DisplayableIdentity, metadata?: Record<string, any>): Promise<void> { return await this.contactsManager.saveContact(contact, metadata) } /** * Remove a contact from the contacts basket * @param identityKey The identity key of the contact to remove */ public async removeContact (identityKey: PubKeyHex): Promise<void> { 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: IdentityCertificate): DisplayableIdentity { 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: name = defaultIdentity.name avatarURL = decryptedFields.profilePhoto badgeLabel = defaultIdentity.badgeLabel badgeIconURL = defaultIdentity.badgeIconURL badgeClickURL = defaultIdentity.badgeClickURL // TODO: Make this doc page exist break } return { name, avatarURL, abbreviatedKey: identityToParse.subject.length > 0 ? `${identityToParse.subject.substring(0, 10)}...` : '', identityKey: identityToParse.subject, badgeIconURL, badgeLabel, badgeClickURL } } }