Skip to content

Commit 9a266d7

Browse files
authored
Merge pull request #177 from xmtp/negotiated-topic-invitation-class
Negotiated topic invitation classes
2 parents 3659a35 + 2cf0032 commit 9a266d7

9 files changed

+436
-11
lines changed

src/ApiClient.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { messageApi } from '@xmtp/proto'
22
import { NotifyStreamEntityArrival } from '@xmtp/proto/ts/dist/types/fetch.pb'
3-
import { retry, sleep } from './utils'
4-
import Long from 'long'
3+
import { dateToNs, retry, sleep } from './utils'
54
import AuthCache from './authn/AuthCache'
65
import { Authenticator } from './authn'
76
import { version } from '../package.json'
@@ -48,7 +47,7 @@ export type SubscribeCallback = NotifyStreamEntityArrival<messageApi.Envelope>
4847
export type UnsubscribeFn = () => Promise<void>
4948

5049
const toNanoString = (d: Date | undefined): undefined | string => {
51-
return d && Long.fromNumber(d.valueOf()).multiply(1_000_000).toString()
50+
return d && dateToNs(d).toString()
5251
}
5352

5453
const isAbortError = (err?: Error): boolean => {

src/Invitation.ts

+202
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import Long from 'long'
2+
import { SignedPublicKeyBundle } from './crypto/PublicKeyBundle'
3+
import { invitation } from '@xmtp/proto'
4+
import Ciphertext from './crypto/Ciphertext'
5+
import { decrypt, encrypt } from './crypto'
6+
import { PrivateKeyBundleV2 } from './crypto/PrivateKeyBundle'
7+
import { dateToNs } from './utils'
8+
9+
/**
10+
* InvitationV1 is a protobuf message to be encrypted and used as the ciphertext in a SealedInvitationV1 message
11+
*/
12+
export class InvitationV1 implements invitation.InvitationV1 {
13+
topic: string
14+
aes256GcmHkdfSha256: invitation.InvitationV1_Aes256gcmHkdfsha256 // eslint-disable-line camelcase
15+
16+
constructor({ topic, aes256GcmHkdfSha256 }: invitation.InvitationV1) {
17+
if (!topic || !topic.length) {
18+
throw new Error('Missing topic')
19+
}
20+
if (
21+
!aes256GcmHkdfSha256 ||
22+
!aes256GcmHkdfSha256.keyMaterial ||
23+
!aes256GcmHkdfSha256.keyMaterial.length
24+
) {
25+
throw new Error('Missing key material')
26+
}
27+
this.topic = topic
28+
this.aes256GcmHkdfSha256 = aes256GcmHkdfSha256
29+
}
30+
31+
toBytes(): Uint8Array {
32+
return invitation.InvitationV1.encode(this).finish()
33+
}
34+
35+
static fromBytes(bytes: Uint8Array): InvitationV1 {
36+
return new InvitationV1(invitation.InvitationV1.decode(bytes))
37+
}
38+
}
39+
40+
/**
41+
* SealedInvitationHeaderV1 is a protobuf message to be used as the headerBytes in a SealedInvitationV1
42+
*/
43+
export class SealedInvitationHeaderV1
44+
implements invitation.SealedInvitationHeaderV1
45+
{
46+
sender: SignedPublicKeyBundle
47+
recipient: SignedPublicKeyBundle
48+
createdNs: Long
49+
50+
constructor({
51+
sender,
52+
recipient,
53+
createdNs,
54+
}: invitation.SealedInvitationHeaderV1) {
55+
if (!sender) {
56+
throw new Error('Missing sender')
57+
}
58+
if (!recipient) {
59+
throw new Error('Missing recipient')
60+
}
61+
this.sender = new SignedPublicKeyBundle(sender)
62+
this.recipient = new SignedPublicKeyBundle(recipient)
63+
this.createdNs = createdNs
64+
}
65+
66+
toBytes(): Uint8Array {
67+
return invitation.SealedInvitationHeaderV1.encode(this).finish()
68+
}
69+
70+
static fromBytes(bytes: Uint8Array): SealedInvitationHeaderV1 {
71+
return new SealedInvitationHeaderV1(
72+
invitation.SealedInvitationHeaderV1.decode(bytes)
73+
)
74+
}
75+
}
76+
77+
export class SealedInvitationV1 implements invitation.SealedInvitationV1 {
78+
headerBytes: Uint8Array
79+
ciphertext: Ciphertext
80+
private _header?: SealedInvitationHeaderV1
81+
private _invitation?: InvitationV1
82+
83+
constructor({ headerBytes, ciphertext }: invitation.SealedInvitationV1) {
84+
if (!headerBytes || !headerBytes.length) {
85+
throw new Error('Missing header bytes')
86+
}
87+
if (!ciphertext) {
88+
throw new Error('Missing ciphertext')
89+
}
90+
this.headerBytes = headerBytes
91+
this.ciphertext = new Ciphertext(ciphertext)
92+
}
93+
94+
/**
95+
* Accessor method for the full header object
96+
*/
97+
get header(): SealedInvitationHeaderV1 {
98+
// Use cached value if already exists
99+
if (this._header) {
100+
return this._header
101+
}
102+
this._header = SealedInvitationHeaderV1.fromBytes(this.headerBytes)
103+
return this._header
104+
}
105+
106+
/**
107+
* getInvitation decrypts and returns the InvitationV1 stored in the ciphertext of the Sealed Invitation
108+
*/
109+
async getInvitation(viewer: PrivateKeyBundleV2): Promise<InvitationV1> {
110+
// Use cached value if already exists
111+
if (this._invitation) {
112+
return this._invitation
113+
}
114+
// The constructors for child classes will validate that this is complete
115+
const header = this.header
116+
let secret: Uint8Array
117+
if (viewer.identityKey.matches(this.header.sender.identityKey)) {
118+
secret = await viewer.sharedSecret(
119+
header.recipient,
120+
header.sender.preKey,
121+
false
122+
)
123+
} else {
124+
secret = await viewer.sharedSecret(
125+
header.sender,
126+
header.recipient.preKey,
127+
true
128+
)
129+
}
130+
131+
const decryptedBytes = await decrypt(
132+
this.ciphertext,
133+
secret,
134+
this.headerBytes
135+
)
136+
this._invitation = InvitationV1.fromBytes(decryptedBytes)
137+
return this._invitation
138+
}
139+
140+
toBytes(): Uint8Array {
141+
return invitation.SealedInvitationV1.encode(this).finish()
142+
}
143+
144+
static fromBytes(bytes: Uint8Array): SealedInvitationV1 {
145+
return new SealedInvitationV1(invitation.SealedInvitationV1.decode(bytes))
146+
}
147+
}
148+
149+
/**
150+
* Wrapper class for SealedInvitationV1 and any future iterations of SealedInvitation
151+
*/
152+
export class SealedInvitation implements invitation.SealedInvitation {
153+
v1: SealedInvitationV1
154+
155+
constructor({ v1 }: invitation.SealedInvitation) {
156+
if (!v1) {
157+
throw new Error('Missing v1 invitation')
158+
}
159+
this.v1 = new SealedInvitationV1(v1)
160+
}
161+
162+
toBytes(): Uint8Array {
163+
return invitation.SealedInvitation.encode(this).finish()
164+
}
165+
166+
static fromBytes(bytes: Uint8Array): SealedInvitation {
167+
return new SealedInvitation(invitation.SealedInvitation.decode(bytes))
168+
}
169+
170+
/**
171+
* Create a SealedInvitation with a SealedInvitationV1 payload
172+
* Will encrypt all contents and validate inputs
173+
*/
174+
static async createV1({
175+
sender,
176+
recipient,
177+
created,
178+
invitation,
179+
}: {
180+
sender: PrivateKeyBundleV2
181+
recipient: SignedPublicKeyBundle
182+
created: Date
183+
invitation: InvitationV1
184+
}): Promise<SealedInvitation> {
185+
const headerBytes = new SealedInvitationHeaderV1({
186+
sender: sender.getPublicKeyBundle(),
187+
recipient,
188+
createdNs: dateToNs(created),
189+
}).toBytes()
190+
191+
const secret = await sender.sharedSecret(
192+
recipient,
193+
sender.getCurrentPreKey().publicKey,
194+
false
195+
)
196+
197+
const invitationBytes = invitation.toBytes()
198+
const ciphertext = await encrypt(invitationBytes, secret, headerBytes)
199+
200+
return new SealedInvitation({ v1: { headerBytes, ciphertext } })
201+
}
202+
}

src/Message.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export default class Message implements proto.MessageV1 {
124124
const headerBytes = proto.MessageHeaderV1.encode(header).finish()
125125
const ciphertext = await encrypt(message, secret, headerBytes)
126126
const protoMsg = {
127-
v1: { headerBytes: headerBytes, ciphertext },
127+
v1: { headerBytes, ciphertext },
128128
v2: undefined,
129129
}
130130
const bytes = proto.Message.encode(protoMsg).finish()

src/authn/AuthData.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { authn as authnProto } from '@xmtp/proto'
22
import Long from 'long'
3+
import { dateToNs } from '../utils'
34

45
export default class AuthData implements authnProto.AuthData {
56
walletAddr: string
@@ -13,8 +14,8 @@ export default class AuthData implements authnProto.AuthData {
1314
static create(walletAddr: string, timestamp?: Date): AuthData {
1415
timestamp = timestamp || new Date()
1516
return new AuthData({
16-
walletAddr: walletAddr,
17-
createdNs: Long.fromNumber(timestamp.getTime()).multiply(1_000_000),
17+
walletAddr,
18+
createdNs: dateToNs(timestamp),
1819
})
1920
}
2021

src/crypto/PrivateKey.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ export class PrivateKey implements privateKey.PrivateKey {
237237

238238
// derive shared secret from peer's PublicKey;
239239
// the peer can derive the same secret using their PrivateKey and our PublicKey
240-
sharedSecret(peer: PublicKey): Uint8Array {
240+
sharedSecret(peer: PublicKey | SignedPublicKey): Uint8Array {
241241
return secp.getSharedSecret(
242242
this.secp256k1.bytes,
243243
peer.secp256k1Uncompressed.bytes,

src/crypto/PrivateKeyBundle.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export class PrivateKeyBundleV1 implements proto.PrivateKeyBundleV1 {
193193
// @myPreKey indicates which of my preKeys should be used to derive the secret
194194
// @recipient indicates if this is the sending or receiving side.
195195
async sharedSecret(
196-
peer: PublicKeyBundle,
196+
peer: PublicKeyBundle | SignedPublicKeyBundle,
197197
myPreKey: PublicKey,
198198
isRecipient: boolean
199199
): Promise<Uint8Array> {

src/utils.ts

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Long from 'long'
2+
13
export type IsRetryable = (err?: Error) => boolean
24

35
export const buildContentTopic = (name: string): string =>
@@ -70,3 +72,7 @@ export async function retry<T extends (...arg0: any[]) => any>(
7072
return retry(fn, args, maxRetries, sleepTime, isRetryableFn, currRetry + 1)
7173
}
7274
}
75+
76+
export function dateToNs(date: Date): Long {
77+
return Long.fromNumber(date.valueOf()).multiply(1_000_000)
78+
}

test/ApiClient.test.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { sleep } from './helpers'
66
import { Authenticator } from '../src/authn'
77
import { PrivateKey } from '../src'
88
import { version } from '../package.json'
9+
import { dateToNs } from '../src/utils'
910
const { MessageApi } = messageApi
1011

1112
const PATH_PREFIX = 'http://fake:5050'
@@ -165,9 +166,7 @@ describe('Publish', () => {
165166
{
166167
message: msg.message,
167168
contentTopic: msg.contentTopic,
168-
timestampNs: Long.fromNumber(now.valueOf())
169-
.multiply(1_000_000)
170-
.toString(),
169+
timestampNs: dateToNs(now).toString(),
171170
},
172171
],
173172
}

0 commit comments

Comments
 (0)