@bsv/sdk
Version:
BSV Blockchain Software Development Kit
498 lines (459 loc) • 18.1 kB
text/typescript
import { DEFAULT_IDENTITY_CLIENT_OPTIONS, defaultIdentity, DisplayableIdentity, KNOWN_IDENTITY_TYPES } from './types/index.js'
import {
Base64String,
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 { LookupResolver, SHIPBroadcaster, TopicBroadcaster, withDoubleSpendRetry } 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 wallet: WalletInterface
private readonly contactsManager: ContactsManager
constructor (
wallet?: WalletInterface,
private readonly options = DEFAULT_IDENTITY_CLIENT_OPTIONS,
private readonly originator?: OriginatorDomainNameStringUnder250Bytes
) {
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: 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()
},
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: 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)
)
}
/**
* 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<void> {
// 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
*/
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: {
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
*/
private static hasValue (value: any): value is string {
return value !== undefined && value !== null && value !== ''
}
/**
* Try to parse identity information from unknown certificate types
* by checking common field names
*/
private static tryToParseGenericIdentity (
type: string,
decryptedFields: Record<string, any>,
certifierInfo: any
): {
name: string
avatarURL: string
badgeLabel: string
badgeIconURL: string
badgeClickURL: string
} {
// 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 }
}
}