|
| 1 | +import PublicKeyBundle from './crypto/PublicKeyBundle' |
| 2 | + |
| 3 | +export enum EncryptionAlgorithm { |
| 4 | + AES_256_GCM_HKDF_SHA_256, |
| 5 | +} |
| 6 | + |
| 7 | +/** |
| 8 | + * TopicKeyRecord encapsulates the key, algorithm, and a list of allowed signers |
| 9 | + */ |
| 10 | +export type TopicKeyRecord = { |
| 11 | + keyMaterial: Uint8Array |
| 12 | + encryptionAlgorithm: EncryptionAlgorithm |
| 13 | + // Callers should validate that the signature comes from the list of allowed signers |
| 14 | + // Not strictly necessary, but it prevents against compromised topic keys being |
| 15 | + // used by third parties who would sign the message with a different key |
| 16 | + allowedSigners: PublicKeyBundle[] |
| 17 | +} |
| 18 | + |
| 19 | +/** |
| 20 | + * TopicResult is the public interface for receiving a TopicKey |
| 21 | + */ |
| 22 | +export type TopicResult = { |
| 23 | + topicKey: TopicKeyRecord |
| 24 | + contentTopic: string |
| 25 | +} |
| 26 | + |
| 27 | +// Internal data structure used to store the relationship between a topic and a wallet address |
| 28 | +type WalletTopicRecord = { |
| 29 | + contentTopic: string |
| 30 | + createdAt: Date |
| 31 | +} |
| 32 | + |
| 33 | +type ContentTopic = string |
| 34 | +type WalletAddress = string |
| 35 | + |
| 36 | +/** |
| 37 | + * Custom error type for cases where the caller attempted to add a second key to the same topic |
| 38 | + */ |
| 39 | +export class DuplicateTopicError extends Error { |
| 40 | + constructor(topic: string) { |
| 41 | + super(`Topic ${topic} has already been added`) |
| 42 | + this.name = 'DuplicateTopicError' |
| 43 | + Object.setPrototypeOf(this, DuplicateTopicError.prototype) |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +const findLatestTopic = (records: WalletTopicRecord[]): WalletTopicRecord => { |
| 48 | + let latestRecord: WalletTopicRecord | undefined |
| 49 | + for (const record of records) { |
| 50 | + if (!latestRecord || record.createdAt > latestRecord.createdAt) { |
| 51 | + latestRecord = record |
| 52 | + } |
| 53 | + } |
| 54 | + if (!latestRecord) { |
| 55 | + throw new Error('No record found') |
| 56 | + } |
| 57 | + return latestRecord |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * TopicKeyManager stores the mapping between topics -> keys and wallet addresses -> keys |
| 62 | + */ |
| 63 | +export default class TopicKeyManager { |
| 64 | + // Mapping of content topics to the keys used for decryption on that topic |
| 65 | + private topicKeys: Map<ContentTopic, TopicKeyRecord> |
| 66 | + // Mapping of wallet addresses and topics |
| 67 | + private dmTopics: Map<WalletAddress, WalletTopicRecord[]> |
| 68 | + |
| 69 | + constructor() { |
| 70 | + this.topicKeys = new Map<ContentTopic, TopicKeyRecord>() |
| 71 | + this.dmTopics = new Map<WalletAddress, WalletTopicRecord[]>() |
| 72 | + } |
| 73 | + |
| 74 | + /** |
| 75 | + * Create a TopicKeyRecord for the topic and store it for later access |
| 76 | + * |
| 77 | + * @param contentTopic The topic |
| 78 | + * @param key TopicKeyRecord that contains the topic key and encryption algorithm |
| 79 | + * @param counterparty The other user's PublicKeyBundle |
| 80 | + * @param createdAt Date |
| 81 | + */ |
| 82 | + addDirectMessageTopic( |
| 83 | + contentTopic: string, |
| 84 | + key: TopicKeyRecord, |
| 85 | + counterparty: PublicKeyBundle, |
| 86 | + createdAt: Date |
| 87 | + ): void { |
| 88 | + if (this.topicKeys.has(contentTopic)) { |
| 89 | + throw new DuplicateTopicError(contentTopic) |
| 90 | + } |
| 91 | + this.topicKeys.set(contentTopic, key) |
| 92 | + |
| 93 | + const walletAddress = counterparty.identityKey.walletSignatureAddress() |
| 94 | + const counterpartyTopicList = this.dmTopics.get(walletAddress) || [] |
| 95 | + counterpartyTopicList.push({ contentTopic, createdAt }) |
| 96 | + this.dmTopics.set(walletAddress, counterpartyTopicList) |
| 97 | + } |
| 98 | + |
| 99 | + /** |
| 100 | + * Would be used to get all information required to decrypt/validate a given message |
| 101 | + */ |
| 102 | + getByTopic(contentTopic: string): TopicResult | undefined { |
| 103 | + const topicKey = this.topicKeys.get(contentTopic) |
| 104 | + if (!topicKey) { |
| 105 | + return undefined |
| 106 | + } |
| 107 | + return { |
| 108 | + topicKey, |
| 109 | + contentTopic, |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * Used to know which topic/key to use to send to a given wallet address |
| 115 | + */ |
| 116 | + getLatestByWalletAddress(walletAddress: string): TopicResult | undefined { |
| 117 | + const walletTopics = this.dmTopics.get(walletAddress) |
| 118 | + if (!walletTopics || !walletTopics.length) { |
| 119 | + return undefined |
| 120 | + } |
| 121 | + const newestTopic = findLatestTopic(walletTopics) |
| 122 | + return this.getByTopic(newestTopic.contentTopic) |
| 123 | + } |
| 124 | + |
| 125 | + /** |
| 126 | + * Used to get the topic list to listen for all messages from a given wallet address |
| 127 | + */ |
| 128 | + getAllByWalletAddress(walletAddress: string): TopicResult[] { |
| 129 | + const dmTopics = this.dmTopics |
| 130 | + .get(walletAddress) |
| 131 | + ?.map(({ contentTopic }) => this.getByTopic(contentTopic)) |
| 132 | + .filter((res) => !!res) as TopicResult[] |
| 133 | + |
| 134 | + return dmTopics || [] |
| 135 | + } |
| 136 | +} |
0 commit comments