Skip to content

Commit

Permalink
Merge pull request #177 from xmtp/negotiated-topic-invitation-class
Browse files Browse the repository at this point in the history
Negotiated topic invitation classes
  • Loading branch information
neekolas authored Sep 29, 2022
2 parents 3659a35 + 2cf0032 commit 9a266d7
Show file tree
Hide file tree
Showing 9 changed files with 436 additions and 11 deletions.
5 changes: 2 additions & 3 deletions src/ApiClient.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { messageApi } from '@xmtp/proto'
import { NotifyStreamEntityArrival } from '@xmtp/proto/ts/dist/types/fetch.pb'
import { retry, sleep } from './utils'
import Long from 'long'
import { dateToNs, retry, sleep } from './utils'
import AuthCache from './authn/AuthCache'
import { Authenticator } from './authn'
import { version } from '../package.json'
Expand Down Expand Up @@ -48,7 +47,7 @@ export type SubscribeCallback = NotifyStreamEntityArrival<messageApi.Envelope>
export type UnsubscribeFn = () => Promise<void>

const toNanoString = (d: Date | undefined): undefined | string => {
return d && Long.fromNumber(d.valueOf()).multiply(1_000_000).toString()
return d && dateToNs(d).toString()
}

const isAbortError = (err?: Error): boolean => {
Expand Down
202 changes: 202 additions & 0 deletions src/Invitation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import Long from 'long'
import { SignedPublicKeyBundle } from './crypto/PublicKeyBundle'
import { invitation } from '@xmtp/proto'
import Ciphertext from './crypto/Ciphertext'
import { decrypt, encrypt } from './crypto'
import { PrivateKeyBundleV2 } from './crypto/PrivateKeyBundle'
import { dateToNs } from './utils'

/**
* InvitationV1 is a protobuf message to be encrypted and used as the ciphertext in a SealedInvitationV1 message
*/
export class InvitationV1 implements invitation.InvitationV1 {
topic: string
aes256GcmHkdfSha256: invitation.InvitationV1_Aes256gcmHkdfsha256 // eslint-disable-line camelcase

constructor({ topic, aes256GcmHkdfSha256 }: invitation.InvitationV1) {
if (!topic || !topic.length) {
throw new Error('Missing topic')
}
if (
!aes256GcmHkdfSha256 ||
!aes256GcmHkdfSha256.keyMaterial ||
!aes256GcmHkdfSha256.keyMaterial.length
) {
throw new Error('Missing key material')
}
this.topic = topic
this.aes256GcmHkdfSha256 = aes256GcmHkdfSha256
}

toBytes(): Uint8Array {
return invitation.InvitationV1.encode(this).finish()
}

static fromBytes(bytes: Uint8Array): InvitationV1 {
return new InvitationV1(invitation.InvitationV1.decode(bytes))
}
}

/**
* SealedInvitationHeaderV1 is a protobuf message to be used as the headerBytes in a SealedInvitationV1
*/
export class SealedInvitationHeaderV1
implements invitation.SealedInvitationHeaderV1
{
sender: SignedPublicKeyBundle
recipient: SignedPublicKeyBundle
createdNs: Long

constructor({
sender,
recipient,
createdNs,
}: invitation.SealedInvitationHeaderV1) {
if (!sender) {
throw new Error('Missing sender')
}
if (!recipient) {
throw new Error('Missing recipient')
}
this.sender = new SignedPublicKeyBundle(sender)
this.recipient = new SignedPublicKeyBundle(recipient)
this.createdNs = createdNs
}

toBytes(): Uint8Array {
return invitation.SealedInvitationHeaderV1.encode(this).finish()
}

static fromBytes(bytes: Uint8Array): SealedInvitationHeaderV1 {
return new SealedInvitationHeaderV1(
invitation.SealedInvitationHeaderV1.decode(bytes)
)
}
}

export class SealedInvitationV1 implements invitation.SealedInvitationV1 {
headerBytes: Uint8Array
ciphertext: Ciphertext
private _header?: SealedInvitationHeaderV1
private _invitation?: InvitationV1

constructor({ headerBytes, ciphertext }: invitation.SealedInvitationV1) {
if (!headerBytes || !headerBytes.length) {
throw new Error('Missing header bytes')
}
if (!ciphertext) {
throw new Error('Missing ciphertext')
}
this.headerBytes = headerBytes
this.ciphertext = new Ciphertext(ciphertext)
}

/**
* Accessor method for the full header object
*/
get header(): SealedInvitationHeaderV1 {
// Use cached value if already exists
if (this._header) {
return this._header
}
this._header = SealedInvitationHeaderV1.fromBytes(this.headerBytes)
return this._header
}

/**
* getInvitation decrypts and returns the InvitationV1 stored in the ciphertext of the Sealed Invitation
*/
async getInvitation(viewer: PrivateKeyBundleV2): Promise<InvitationV1> {
// Use cached value if already exists
if (this._invitation) {
return this._invitation
}
// The constructors for child classes will validate that this is complete
const header = this.header
let secret: Uint8Array
if (viewer.identityKey.matches(this.header.sender.identityKey)) {
secret = await viewer.sharedSecret(
header.recipient,
header.sender.preKey,
false
)
} else {
secret = await viewer.sharedSecret(
header.sender,
header.recipient.preKey,
true
)
}

const decryptedBytes = await decrypt(
this.ciphertext,
secret,
this.headerBytes
)
this._invitation = InvitationV1.fromBytes(decryptedBytes)
return this._invitation
}

toBytes(): Uint8Array {
return invitation.SealedInvitationV1.encode(this).finish()
}

static fromBytes(bytes: Uint8Array): SealedInvitationV1 {
return new SealedInvitationV1(invitation.SealedInvitationV1.decode(bytes))
}
}

/**
* Wrapper class for SealedInvitationV1 and any future iterations of SealedInvitation
*/
export class SealedInvitation implements invitation.SealedInvitation {
v1: SealedInvitationV1

constructor({ v1 }: invitation.SealedInvitation) {
if (!v1) {
throw new Error('Missing v1 invitation')
}
this.v1 = new SealedInvitationV1(v1)
}

toBytes(): Uint8Array {
return invitation.SealedInvitation.encode(this).finish()
}

static fromBytes(bytes: Uint8Array): SealedInvitation {
return new SealedInvitation(invitation.SealedInvitation.decode(bytes))
}

/**
* Create a SealedInvitation with a SealedInvitationV1 payload
* Will encrypt all contents and validate inputs
*/
static async createV1({
sender,
recipient,
created,
invitation,
}: {
sender: PrivateKeyBundleV2
recipient: SignedPublicKeyBundle
created: Date
invitation: InvitationV1
}): Promise<SealedInvitation> {
const headerBytes = new SealedInvitationHeaderV1({
sender: sender.getPublicKeyBundle(),
recipient,
createdNs: dateToNs(created),
}).toBytes()

const secret = await sender.sharedSecret(
recipient,
sender.getCurrentPreKey().publicKey,
false
)

const invitationBytes = invitation.toBytes()
const ciphertext = await encrypt(invitationBytes, secret, headerBytes)

return new SealedInvitation({ v1: { headerBytes, ciphertext } })
}
}
2 changes: 1 addition & 1 deletion src/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export default class Message implements proto.MessageV1 {
const headerBytes = proto.MessageHeaderV1.encode(header).finish()
const ciphertext = await encrypt(message, secret, headerBytes)
const protoMsg = {
v1: { headerBytes: headerBytes, ciphertext },
v1: { headerBytes, ciphertext },
v2: undefined,
}
const bytes = proto.Message.encode(protoMsg).finish()
Expand Down
5 changes: 3 additions & 2 deletions src/authn/AuthData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { authn as authnProto } from '@xmtp/proto'
import Long from 'long'
import { dateToNs } from '../utils'

export default class AuthData implements authnProto.AuthData {
walletAddr: string
Expand All @@ -13,8 +14,8 @@ export default class AuthData implements authnProto.AuthData {
static create(walletAddr: string, timestamp?: Date): AuthData {
timestamp = timestamp || new Date()
return new AuthData({
walletAddr: walletAddr,
createdNs: Long.fromNumber(timestamp.getTime()).multiply(1_000_000),
walletAddr,
createdNs: dateToNs(timestamp),
})
}

Expand Down
2 changes: 1 addition & 1 deletion src/crypto/PrivateKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export class PrivateKey implements privateKey.PrivateKey {

// derive shared secret from peer's PublicKey;
// the peer can derive the same secret using their PrivateKey and our PublicKey
sharedSecret(peer: PublicKey): Uint8Array {
sharedSecret(peer: PublicKey | SignedPublicKey): Uint8Array {
return secp.getSharedSecret(
this.secp256k1.bytes,
peer.secp256k1Uncompressed.bytes,
Expand Down
2 changes: 1 addition & 1 deletion src/crypto/PrivateKeyBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export class PrivateKeyBundleV1 implements proto.PrivateKeyBundleV1 {
// @myPreKey indicates which of my preKeys should be used to derive the secret
// @recipient indicates if this is the sending or receiving side.
async sharedSecret(
peer: PublicKeyBundle,
peer: PublicKeyBundle | SignedPublicKeyBundle,
myPreKey: PublicKey,
isRecipient: boolean
): Promise<Uint8Array> {
Expand Down
6 changes: 6 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Long from 'long'

export type IsRetryable = (err?: Error) => boolean

export const buildContentTopic = (name: string): string =>
Expand Down Expand Up @@ -70,3 +72,7 @@ export async function retry<T extends (...arg0: any[]) => any>(
return retry(fn, args, maxRetries, sleepTime, isRetryableFn, currRetry + 1)
}
}

export function dateToNs(date: Date): Long {
return Long.fromNumber(date.valueOf()).multiply(1_000_000)
}
5 changes: 2 additions & 3 deletions test/ApiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { sleep } from './helpers'
import { Authenticator } from '../src/authn'
import { PrivateKey } from '../src'
import { version } from '../package.json'
import { dateToNs } from '../src/utils'
const { MessageApi } = messageApi

const PATH_PREFIX = 'http://fake:5050'
Expand Down Expand Up @@ -165,9 +166,7 @@ describe('Publish', () => {
{
message: msg.message,
contentTopic: msg.contentTopic,
timestampNs: Long.fromNumber(now.valueOf())
.multiply(1_000_000)
.toString(),
timestampNs: dateToNs(now).toString(),
},
],
}
Expand Down
Loading

0 comments on commit 9a266d7

Please sign in to comment.