From 67b12fceb024e511d76bfcf85938c964a9e63b72 Mon Sep 17 00:00:00 2001 From: Felicio Mununga Date: Tue, 21 Mar 2023 11:05:59 +0100 Subject: [PATCH] Add url data encoding (#345) * add @scure/base * add link-preview.proto * add prototype of encode function * add tests * add test cases * update proto * more * more * add missing community chat description to proto * more * more * add browser brotli and lz-string * move encoding comparison * add sinlge encoding * split encoding * add decoding * update .prettierignore * exclude comparison * remove comparison tests * Update packages/status-js/src/utils/encode-url-data.test.ts * Update packages/status-js/src/utils/encode-url-data.test.ts * remove checksum * ensure channel is serializable * Update .prettierignore * update protos * add creaet-url.ts * set links * comment * update protos * add nominal type for EncodedUrlData * add sign/verify fns * export fns from index * set zlib as external module * add tag indices * encode channel uuid * use `.toJson()` with type assertion * use uppercase url * split url creating fns * fix typo * describe test suite * use getters * fix nominal type * remove `node:` prefix from `zlib` import * remove todos?: * rename URLProps to URLParams * fix package.json after rebase --- .prettierignore | 1 + packages/status-js/package.json | 1 + packages/status-js/src/client/account.test.ts | 20 +- packages/status-js/src/client/account.ts | 29 +- packages/status-js/src/client/chat.ts | 8 + .../src/client/community/community.ts | 5 + packages/status-js/src/client/member.ts | 5 + packages/status-js/src/index.ts | 6 + .../status-js/src/protos/communities.proto | 3 + .../status-js/src/protos/communities_pb.ts | 13 + .../src/protos/push-notifications.proto | 2 + .../src/protos/push-notifications_pb.ts | 7 + packages/status-js/src/protos/url-data.proto | 35 +++ packages/status-js/src/protos/url-data_pb.ts | 269 ++++++++++++++++++ .../status-js/src/utils/create-url.test.ts | 85 ++++++ packages/status-js/src/utils/create-url.ts | 45 +++ .../src/utils/encode-url-data.test.ts | 75 +++++ .../status-js/src/utils/encode-url-data.ts | 65 +++++ packages/status-js/src/utils/sign-data.ts | 50 ++++ .../status-js/src/utils/sign-url-data.test.ts | 24 ++ packages/status-js/src/utils/sign-url-data.ts | 23 ++ packages/status-js/vite.config.ts | 2 +- yarn.lock | 5 + 23 files changed, 745 insertions(+), 33 deletions(-) create mode 100644 packages/status-js/src/protos/url-data.proto create mode 100644 packages/status-js/src/protos/url-data_pb.ts create mode 100644 packages/status-js/src/utils/create-url.test.ts create mode 100644 packages/status-js/src/utils/create-url.ts create mode 100644 packages/status-js/src/utils/encode-url-data.test.ts create mode 100644 packages/status-js/src/utils/encode-url-data.ts create mode 100644 packages/status-js/src/utils/sign-data.ts create mode 100644 packages/status-js/src/utils/sign-url-data.test.ts create mode 100644 packages/status-js/src/utils/sign-url-data.ts diff --git a/.prettierignore b/.prettierignore index efaa0492..19c76f1d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ **/protos **/coverage .next +**/.data diff --git a/packages/status-js/package.json b/packages/status-js/package.json index c4fad170..439b64cc 100644 --- a/packages/status-js/package.json +++ b/packages/status-js/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@bufbuild/protobuf": "^1.0.0", + "@scure/base": "^1.1.1", "ethereum-cryptography": "^1.0.3", "js-waku": "^0.30.0", "multiformats": "^11.0.1" diff --git a/packages/status-js/src/client/account.test.ts b/packages/status-js/src/client/account.test.ts index a5a0869f..fd64d8cb 100644 --- a/packages/status-js/src/client/account.test.ts +++ b/packages/status-js/src/client/account.test.ts @@ -1,6 +1,3 @@ -import { keccak256 } from 'ethereum-cryptography/keccak' -import * as secp from 'ethereum-cryptography/secp256k1' -import { utf8ToBytes } from 'ethereum-cryptography/utils' import { expect, test } from 'vitest' import { Account } from './account' @@ -11,25 +8,16 @@ test('should verify the signature', async () => { // @fixme const account = new Account({} as unknown as Client) - const message = utf8ToBytes('123') - const messageHash = keccak256(message) + const signature = await account.sign('123') - const signature = await account.sign(message) - const signatureWithoutRecoveryId = signature.slice(0, -1) - - expect( - secp.verify(signatureWithoutRecoveryId, messageHash, account.publicKey) - ).toBeTruthy() + expect(account.verify(signature, '123')).toBe(true) }) test('should not verify signature with different message', async () => { // @fixme const account = new Account({} as unknown as Client) - const message = utf8ToBytes('123') - const messageHash = keccak256(message) + const signature = await account.sign('abc') - const signature = await account.sign(utf8ToBytes('abc')) - - expect(secp.verify(signature, messageHash, account.publicKey)).toBeFalsy() + expect(account.verify(signature, '123')).toBe(false) }) diff --git a/packages/status-js/src/client/account.ts b/packages/status-js/src/client/account.ts index 6ee5109c..3cd91413 100644 --- a/packages/status-js/src/client/account.ts +++ b/packages/status-js/src/client/account.ts @@ -1,13 +1,10 @@ -import { keccak256 } from 'ethereum-cryptography/keccak' -import { getPublicKey, sign, utils } from 'ethereum-cryptography/secp256k1' -import { - bytesToHex, - concatBytes, - hexToBytes, -} from 'ethereum-cryptography/utils' +import { getPublicKey, utils } from 'ethereum-cryptography/secp256k1' +import { bytesToHex, hexToBytes } from 'ethereum-cryptography/utils' import { compressPublicKey } from '../utils/compress-public-key' +import { createUserURLWithPublicKey } from '../utils/create-url' import { generateUsername } from '../utils/generate-username' +import { signData, verifySignedData } from '../utils/sign-data' import type { Client } from './client' import type { Community } from './community/community' @@ -35,19 +32,19 @@ export class Account { this.publicKey = bytesToHex(publicKey) this.chatKey = '0x' + compressPublicKey(this.publicKey) this.username = generateUsername('0x' + this.publicKey) - this.membership = initialAccount ? initialAccount.membership : 'none' } - // sig must be a 65-byte compact ECDSA signature containing the recovery id as the last element. - async sign(payload: Uint8Array) { - const hash = keccak256(payload) - const [signature, recoverId] = await sign(hash, this.privateKey, { - recovered: true, - der: false, - }) + public get link(): URL { + return createUserURLWithPublicKey(this.chatKey) + } - return concatBytes(signature, new Uint8Array([recoverId])) + async sign(payload: Uint8Array | string) { + return await signData(payload, this.privateKey) + } + + verify(signature: Uint8Array, payload: Uint8Array | string) { + return verifySignedData(signature, payload, this.publicKey) } updateMembership(community: Community): void { diff --git a/packages/status-js/src/client/chat.ts b/packages/status-js/src/client/chat.ts index 9e78d02a..bde21051 100644 --- a/packages/status-js/src/client/chat.ts +++ b/packages/status-js/src/client/chat.ts @@ -11,6 +11,7 @@ import { } from '../protos/chat-message_pb' import { EmojiReaction, EmojiReaction_Type } from '../protos/emoji-reaction_pb' import { MessageType } from '../protos/enums_pb' +import { createChannelURLWithPublicKey } from '../utils/create-url' import { generateKeyFromPassword } from '../utils/generate-key-from-password' import { getNextClock } from '../utils/get-next-clock' import { idToContentTopic } from '../utils/id-to-content-topic' @@ -153,6 +154,13 @@ export class Chat { return this.#messages.get(id) } + public get link(): URL { + return createChannelURLWithPublicKey( + this.uuid, + this.client.community.publicKey + ) + } + public onChange = (callback: (description: CommunityChat) => void) => { this.chatCallbacks.add(callback) diff --git a/packages/status-js/src/client/community/community.ts b/packages/status-js/src/client/community/community.ts index cb31c41d..5e051d10 100644 --- a/packages/status-js/src/client/community/community.ts +++ b/packages/status-js/src/client/community/community.ts @@ -7,6 +7,7 @@ import { ApplicationMetadataMessage_Type } from '../../protos/application-metada import { CommunityRequestToJoin } from '../../protos/communities_pb' import { MessageType } from '../../protos/enums_pb' import { compressPublicKey } from '../../utils/compress-public-key' +import { createCommunityURLWithPublicKey } from '../../utils/create-url' import { generateKeyFromPassword } from '../../utils/generate-key-from-password' import { getNextClock } from '../../utils/get-next-clock' import { idToContentTopic } from '../../utils/id-to-content-topic' @@ -83,6 +84,10 @@ export class Community { return this.#members.get(publicKey) } + public get link(): URL { + return createCommunityURLWithPublicKey(this.publicKey) + } + public fetch = async () => { // most recent page first await this.client.waku.store.queryOrderedCallback( diff --git a/packages/status-js/src/client/member.ts b/packages/status-js/src/client/member.ts index c9f13635..22e8ddf0 100644 --- a/packages/status-js/src/client/member.ts +++ b/packages/status-js/src/client/member.ts @@ -1,4 +1,5 @@ import { compressPublicKey } from '../utils/compress-public-key' +import { createUserURLWithPublicKey } from '../utils/create-url' import { generateUsername } from '../utils/generate-username' import { publicKeyToColorHash } from '../utils/public-key-to-color-hash' @@ -16,4 +17,8 @@ export class Member { this.username = generateUsername(publicKey) this.colorHash = publicKeyToColorHash(publicKey) } + + public get link(): URL { + return createUserURLWithPublicKey(this.chatKey) + } } diff --git a/packages/status-js/src/index.ts b/packages/status-js/src/index.ts index 1d88f5c7..26ece9ac 100644 --- a/packages/status-js/src/index.ts +++ b/packages/status-js/src/index.ts @@ -17,4 +17,10 @@ export type { UserInfo } from './request-client/map-user' export type { RequestClient } from './request-client/request-client' export { createRequestClient } from './request-client/request-client' export { deserializePublicKey } from './utils/deserialize-public-key' +export { + decodeChannelURLData, + decodeCommunityURLData, + decodeUserURLData, +} from './utils/encode-url-data' export { publicKeyToEmojiHash } from './utils/public-key-to-emoji-hash' +export { verifyEncodedURLData } from './utils/sign-url-data' diff --git a/packages/status-js/src/protos/communities.proto b/packages/status-js/src/protos/communities.proto index 4d8412ec..3b1d29de 100644 --- a/packages/status-js/src/protos/communities.proto +++ b/packages/status-js/src/protos/communities.proto @@ -1,6 +1,7 @@ syntax = "proto3"; import "chat-identity.proto"; +import "url-data.proto"; message Grant { bytes community_id = 1; @@ -47,6 +48,7 @@ message CommunityDescription { string outro_message = 12; bool encrypted = 13; repeated string tags = 14; + URLParams url_params = 15; } message CommunityAdminSettings { @@ -59,6 +61,7 @@ message CommunityChat { ChatIdentity identity = 3; string category_id = 4; int32 position = 5; + URLParams url_params = 6; } message CommunityCategory { diff --git a/packages/status-js/src/protos/communities_pb.ts b/packages/status-js/src/protos/communities_pb.ts index 2236d70a..09744c3f 100644 --- a/packages/status-js/src/protos/communities_pb.ts +++ b/packages/status-js/src/protos/communities_pb.ts @@ -6,6 +6,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; import { Message, proto3, protoInt64 } from "@bufbuild/protobuf"; import { ChatIdentity } from "./chat-identity_pb.js"; +import { URLParams } from "./url-data_pb.js"; /** * @generated from message Grant @@ -283,6 +284,11 @@ export class CommunityDescription extends Message { */ tags: string[] = []; + /** + * @generated from field: URLParams url_params = 15; + */ + urlParams?: URLParams; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -304,6 +310,7 @@ export class CommunityDescription extends Message { { no: 12, name: "outro_message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 13, name: "encrypted", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 14, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + { no: 15, name: "url_params", kind: "message", T: URLParams }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): CommunityDescription { @@ -389,6 +396,11 @@ export class CommunityChat extends Message { */ position = 0; + /** + * @generated from field: URLParams url_params = 6; + */ + urlParams?: URLParams; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -402,6 +414,7 @@ export class CommunityChat extends Message { { no: 3, name: "identity", kind: "message", T: ChatIdentity }, { no: 4, name: "category_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 5, name: "position", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 6, name: "url_params", kind: "message", T: URLParams }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): CommunityChat { diff --git a/packages/status-js/src/protos/push-notifications.proto b/packages/status-js/src/protos/push-notifications.proto index 078b0ac3..19696db9 100644 --- a/packages/status-js/src/protos/push-notifications.proto +++ b/packages/status-js/src/protos/push-notifications.proto @@ -1,6 +1,7 @@ syntax = "proto3"; import "chat-identity.proto"; +import "url-data.proto"; message PushNotificationRegistration { enum TokenType { @@ -41,6 +42,7 @@ message PushNotificationRegistrationResponse { message ContactCodeAdvertisement { repeated PushNotificationQueryInfo push_notification_info = 1; ChatIdentity chat_identity = 2; + URLParams url_params = 3; } message PushNotificationQuery { diff --git a/packages/status-js/src/protos/push-notifications_pb.ts b/packages/status-js/src/protos/push-notifications_pb.ts index 47ac8f92..707738de 100644 --- a/packages/status-js/src/protos/push-notifications_pb.ts +++ b/packages/status-js/src/protos/push-notifications_pb.ts @@ -6,6 +6,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; import { Message, proto3, protoInt64 } from "@bufbuild/protobuf"; import { ChatIdentity } from "./chat-identity_pb.js"; +import { URLParams } from "./url-data_pb.js"; /** * @generated from message PushNotificationRegistration @@ -249,6 +250,11 @@ export class ContactCodeAdvertisement extends Message */ chatIdentity?: ChatIdentity; + /** + * @generated from field: URLParams url_params = 3; + */ + urlParams?: URLParams; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -259,6 +265,7 @@ export class ContactCodeAdvertisement extends Message static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "push_notification_info", kind: "message", T: PushNotificationQueryInfo, repeated: true }, { no: 2, name: "chat_identity", kind: "message", T: ChatIdentity }, + { no: 3, name: "url_params", kind: "message", T: URLParams }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ContactCodeAdvertisement { diff --git a/packages/status-js/src/protos/url-data.proto b/packages/status-js/src/protos/url-data.proto new file mode 100644 index 00000000..a8a1c697 --- /dev/null +++ b/packages/status-js/src/protos/url-data.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +message Community { + string display_name = 1; + string description = 2; + uint32 members_count = 3; + string color = 4; + repeated uint32 tag_indices = 5; +} + +message Channel { + string display_name = 1; + string description = 2; + string emoji = 3; + string color = 4; + Community community = 5; + string uuid = 6; +} + +message User { + string display_name = 1; + string description = 2; + string color = 3; +} + +message URLData { + // Community, Channel, or User + bytes content = 1; +} + +message URLParams { + string encoded_url_data = 1; + // Signature of encoded URL data + string encoded_signature = 2; +} diff --git a/packages/status-js/src/protos/url-data_pb.ts b/packages/status-js/src/protos/url-data_pb.ts new file mode 100644 index 00000000..18ed7b99 --- /dev/null +++ b/packages/status-js/src/protos/url-data_pb.ts @@ -0,0 +1,269 @@ +// @generated by protoc-gen-es v1.0.0 with parameter "target=ts" +// @generated from file url-data.proto (syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3 } from "@bufbuild/protobuf"; + +/** + * @generated from message Community + */ +export class Community extends Message { + /** + * @generated from field: string display_name = 1; + */ + displayName = ""; + + /** + * @generated from field: string description = 2; + */ + description = ""; + + /** + * @generated from field: uint32 members_count = 3; + */ + membersCount = 0; + + /** + * @generated from field: string color = 4; + */ + color = ""; + + /** + * @generated from field: repeated uint32 tag_indices = 5; + */ + tagIndices: number[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime = proto3; + static readonly typeName = "Community"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "display_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "members_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 4, name: "color", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "tag_indices", kind: "scalar", T: 13 /* ScalarType.UINT32 */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Community { + return new Community().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Community { + return new Community().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Community { + return new Community().fromJsonString(jsonString, options); + } + + static equals(a: Community | PlainMessage | undefined, b: Community | PlainMessage | undefined): boolean { + return proto3.util.equals(Community, a, b); + } +} + +/** + * @generated from message Channel + */ +export class Channel extends Message { + /** + * @generated from field: string display_name = 1; + */ + displayName = ""; + + /** + * @generated from field: string description = 2; + */ + description = ""; + + /** + * @generated from field: string emoji = 3; + */ + emoji = ""; + + /** + * @generated from field: string color = 4; + */ + color = ""; + + /** + * @generated from field: Community community = 5; + */ + community?: Community; + + /** + * @generated from field: string uuid = 6; + */ + uuid = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime = proto3; + static readonly typeName = "Channel"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "display_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "emoji", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "color", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "community", kind: "message", T: Community }, + { no: 6, name: "uuid", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Channel { + return new Channel().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Channel { + return new Channel().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Channel { + return new Channel().fromJsonString(jsonString, options); + } + + static equals(a: Channel | PlainMessage | undefined, b: Channel | PlainMessage | undefined): boolean { + return proto3.util.equals(Channel, a, b); + } +} + +/** + * @generated from message User + */ +export class User extends Message { + /** + * @generated from field: string display_name = 1; + */ + displayName = ""; + + /** + * @generated from field: string description = 2; + */ + description = ""; + + /** + * @generated from field: string color = 3; + */ + color = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime = proto3; + static readonly typeName = "User"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "display_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "color", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): User { + return new User().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): User { + return new User().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): User { + return new User().fromJsonString(jsonString, options); + } + + static equals(a: User | PlainMessage | undefined, b: User | PlainMessage | undefined): boolean { + return proto3.util.equals(User, a, b); + } +} + +/** + * @generated from message URLData + */ +export class URLData extends Message { + /** + * Community, Channel, or User + * + * @generated from field: bytes content = 1; + */ + content = new Uint8Array(0); + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime = proto3; + static readonly typeName = "URLData"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "content", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): URLData { + return new URLData().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): URLData { + return new URLData().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): URLData { + return new URLData().fromJsonString(jsonString, options); + } + + static equals(a: URLData | PlainMessage | undefined, b: URLData | PlainMessage | undefined): boolean { + return proto3.util.equals(URLData, a, b); + } +} + +/** + * @generated from message URLParams + */ +export class URLParams extends Message { + /** + * @generated from field: string encoded_url_data = 1; + */ + encodedUrlData = ""; + + /** + * Signature of encoded URL data + * + * @generated from field: string encoded_signature = 2; + */ + encodedSignature = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime = proto3; + static readonly typeName = "URLParams"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "encoded_url_data", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "encoded_signature", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): URLParams { + return new URLParams().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): URLParams { + return new URLParams().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): URLParams { + return new URLParams().fromJsonString(jsonString, options); + } + + static equals(a: URLParams | PlainMessage | undefined, b: URLParams | PlainMessage | undefined): boolean { + return proto3.util.equals(URLParams, a, b); + } +} + diff --git a/packages/status-js/src/utils/create-url.test.ts b/packages/status-js/src/utils/create-url.test.ts new file mode 100644 index 00000000..c48d78ff --- /dev/null +++ b/packages/status-js/src/utils/create-url.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'vitest' + +import { + createChannelURLWithPublicKey, + createChannelURLWithSignature, + createCommunityURLWithPublicKey, + createCommunityURLWithSignature, + createUserURLWithPublicKey, + createUserURLWithSignature, +} from './create-url' + +describe('Create URLs', () => { + test('should create community URL', () => { + expect( + createCommunityURLWithPublicKey( + 'zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU' + ).toString() + ).toBe( + 'https://status.app/c#zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU' + ) + expect( + createCommunityURLWithSignature( + 'G74AgK0ObFNmYT-WC_Jcc9KfSjHXAQo9THKEEbgPaJoItceMES-bUxr2Tj9efv447rRefBIUg9CEsSFyjBOFTRdZ9PH2wUOW8hVNYqIje3BC96mZ8uFogqM6k7gCCJnMHy4ulsmsgHTdeh5dAzTNNuG8m9XB8oVeildTCKlRhINnTZh4kAl5sP8SzBB4V2_I41a8PKl3mcS0z_eF5gA=', + new Uint8Array([ + 94, 52, 162, 140, 177, 216, 189, 16, 47, 100, 230, 195, 33, 131, 3, + 66, 86, 100, 186, 198, 234, 159, 193, 19, 133, 58, 232, 29, 52, 159, + 5, 113, 2, 146, 158, 85, 67, 236, 96, 96, 219, 109, 146, 23, 0, 141, + 1, 30, 20, 187, 181, 204, 82, 68, 22, 26, 208, 232, 206, 93, 52, 119, + 148, 57, 0, + ]) + ).toString() + ).toBe( + 'https://status.app/c/G74AgK0ObFNmYT-WC_Jcc9KfSjHXAQo9THKEEbgPaJoItceMES-bUxr2Tj9efv447rRefBIUg9CEsSFyjBOFTRdZ9PH2wUOW8hVNYqIje3BC96mZ8uFogqM6k7gCCJnMHy4ulsmsgHTdeh5dAzTNNuG8m9XB8oVeildTCKlRhINnTZh4kAl5sP8SzBB4V2_I41a8PKl3mcS0z_eF5gA=#XjSijLHYvRAvZObDIYMDQlZkusbqn8EThTroHTSfBXECkp5VQ-xgYNttkhcAjQEeFLu1zFJEFhrQ6M5dNHeUOQA=' + ) + }) + + test('should create channel URL', () => { + expect( + createChannelURLWithPublicKey( + '30804ea7-bd66-4d5d-91eb-b2dcfe2515b3', + 'zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU' + ).toString() + ).toBe( + 'https://status.app/cc/30804ea7-bd66-4d5d-91eb-b2dcfe2515b3#zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU' + ) + expect( + createChannelURLWithSignature( + 'G70BYJwHdqxloHnQV-SSlY7OfdEB_f8igUIHtomMR1igUTaaRSFVBhJ-mjSn8BPqdBHk0PiHrEsBk8WBTo6_gK0tSiwQDLCWpwnmKeU2Bo7j005CuygCCwWebictMe-XLrHfyPEUmLllOKoRCBtcLDALSYQvF5NCoieM550vx-sAmlmSK871edYL67bCK-PPYghGByWEGNMFs9lOIoFx2H_mJDkNNs9bYsbbaRl_uoStzrokUn0u578yAg16mYwLh-287482y4Ibg9640rAW9JNkrfwstJ2qbLLXJ2CYUOa5ftZlFZk2TnzTxIGvfdznZLVXePelos5rWwI=', + new Uint8Array([ + 94, 52, 162, 140, 177, 216, 189, 16, 47, 100, 230, 195, 33, 131, 3, + 66, 86, 100, 186, 198, 234, 159, 193, 19, 133, 58, 232, 29, 52, 159, + 5, 113, 2, 146, 158, 85, 67, 236, 96, 96, 219, 109, 146, 23, 0, 141, + 1, 30, 20, 187, 181, 204, 82, 68, 22, 26, 208, 232, 206, 93, 52, 119, + 148, 57, 0, + ]) + ).toString() + ).toBe( + 'https://status.app/cc/G70BYJwHdqxloHnQV-SSlY7OfdEB_f8igUIHtomMR1igUTaaRSFVBhJ-mjSn8BPqdBHk0PiHrEsBk8WBTo6_gK0tSiwQDLCWpwnmKeU2Bo7j005CuygCCwWebictMe-XLrHfyPEUmLllOKoRCBtcLDALSYQvF5NCoieM550vx-sAmlmSK871edYL67bCK-PPYghGByWEGNMFs9lOIoFx2H_mJDkNNs9bYsbbaRl_uoStzrokUn0u578yAg16mYwLh-287482y4Ibg9640rAW9JNkrfwstJ2qbLLXJ2CYUOa5ftZlFZk2TnzTxIGvfdznZLVXePelos5rWwI=#XjSijLHYvRAvZObDIYMDQlZkusbqn8EThTroHTSfBXECkp5VQ-xgYNttkhcAjQEeFLu1zFJEFhrQ6M5dNHeUOQA=' + ) + }) + + test('should create user URL', () => { + expect( + createUserURLWithPublicKey( + 'zQ3shUHp2rAM1yqBYeo6LhFbtrozG5mZeA6cRoGohsudtsieT' + ).toString() + ).toBe( + 'https://status.app/u#zQ3shUHp2rAM1yqBYeo6LhFbtrozG5mZeA6cRoGohsudtsieT' + ) + expect( + createUserURLWithSignature( + 'GxgBoJwHdsOLl4DWt55mGELN6clGsb1UKTEkT0KUMDfwhWFpUyWH_cefTnvlcSf2JUXCOAWoY5ywzry-LnJ-PjgOGT1Pkb8riQp7ghv6Zu-x70x4m8lncZaRWpDN-sEfT85idUCWvppT_QFNa2A6J3Gr69UJGvWmL3S4DBwX2Jr7LBTNOvFPo6lejNUb-xizlAMUTrokunCH-qNmgtU6UK0J6Vkn8Ce35XGBFObxpxnAtnC_J_D-SrBCBnjiUlwH0ViNr3lHBg==', + new Uint8Array([ + 96, 175, 248, 14, 248, 9, 32, 79, 13, 43, 138, 182, 215, 25, 138, 187, + 188, 246, 133, 199, 190, 112, 234, 162, 99, 181, 248, 13, 136, 66, 65, + 37, 106, 108, 229, 159, 10, 69, 241, 50, 134, 122, 138, 171, 62, 252, + 197, 77, 125, 77, 161, 58, 114, 26, 200, 93, 51, 255, 113, 127, 132, + 154, 145, 164, 1, + ]) + ).toString() + ).toBe( + 'https://status.app/u/GxgBoJwHdsOLl4DWt55mGELN6clGsb1UKTEkT0KUMDfwhWFpUyWH_cefTnvlcSf2JUXCOAWoY5ywzry-LnJ-PjgOGT1Pkb8riQp7ghv6Zu-x70x4m8lncZaRWpDN-sEfT85idUCWvppT_QFNa2A6J3Gr69UJGvWmL3S4DBwX2Jr7LBTNOvFPo6lejNUb-xizlAMUTrokunCH-qNmgtU6UK0J6Vkn8Ce35XGBFObxpxnAtnC_J_D-SrBCBnjiUlwH0ViNr3lHBg==#YK_4DvgJIE8NK4q21xmKu7z2hce-cOqiY7X4DYhCQSVqbOWfCkXxMoZ6iqs-_MVNfU2hOnIayF0z_3F_hJqRpAE=' + ) + }) +}) diff --git a/packages/status-js/src/utils/create-url.ts b/packages/status-js/src/utils/create-url.ts new file mode 100644 index 00000000..aee220dc --- /dev/null +++ b/packages/status-js/src/utils/create-url.ts @@ -0,0 +1,45 @@ +import { base64url } from '@scure/base' + +const BASE_URL = 'https://status.app' + +export function createCommunityURLWithPublicKey(publicKey: string): URL { + return new URL(`${BASE_URL}/c#${publicKey}`) +} + +export function createCommunityURLWithSignature( + encodedCommunityURLData: string, + signature: Uint8Array +): URL { + return new URL( + `${BASE_URL}/c/${encodedCommunityURLData}#${base64url.encode(signature)}` + ) +} + +export function createChannelURLWithPublicKey( + channelUuid: string, + communityPublicKey: string +): URL { + return new URL(`${BASE_URL}/cc/${channelUuid}#${communityPublicKey}`) +} + +export function createChannelURLWithSignature( + encodedChannelURLData: string, + signature: Uint8Array +): URL { + return new URL( + `${BASE_URL}/cc/${encodedChannelURLData}#${base64url.encode(signature)}` + ) +} + +export function createUserURLWithPublicKey(publicKey: string): URL { + return new URL(`${BASE_URL}/u#${publicKey}`) +} + +export function createUserURLWithSignature( + encodedURLData: string, + signature: Uint8Array +): URL { + return new URL( + `${BASE_URL}/u/${encodedURLData}#${base64url.encode(signature)}` + ) +} diff --git a/packages/status-js/src/utils/encode-url-data.test.ts b/packages/status-js/src/utils/encode-url-data.test.ts new file mode 100644 index 00000000..4244958a --- /dev/null +++ b/packages/status-js/src/utils/encode-url-data.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'vitest' + +import { + decodeChannelURLData, + decodeCommunityURLData, + decodeUserURLData, + encodeChannelURLData, + encodeCommunityURLData, + encodeUserURLData, +} from './encode-url-data' + +describe('Encode URL data', () => { + test('should encode and decode community', () => { + const data = { + displayName: 'Lorem ipsum dolor sit egestas.', + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus non dui vitae augue elementum laoreet ac pharetra odio. Morbi vestibulum.', + membersCount: 1_000_000, + color: '#4360DF', + tagIndices: [1, 2, 3, 4], + } + + const encodedData = encodeCommunityURLData(data) + const decodedData = decodeCommunityURLData(encodedData) + + expect(encodedData).toBe( + 'G8QAgC0OzDOfHB4N5V1zajCKmHvbUAXB6XK6XYLS60WrOmCEEVgFEJaHsLkpTevR-XHc03r4B2pKTOoYJwqbLrLw9u2DhyzlK5rEWE09Dy7oPbVSPhwlOKozCQuAsMX84eJimcwKWNer82gPcCrbhPM-Zx1s3-glfEojrEYRDp61MM2DTNiD92_BDIN3eYvvcQsfT-quKYmaf1_i9Kpzk0Fi' + ) + expect(decodedData).toEqual(data) + }) + + test('should encode and decode channel', () => { + const data = { + emoji: '🏴󠁧󠁢󠁥󠁮󠁧󠁿', + displayName: 'lorem-ipsum-dolore-nulla', + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus non dui vitae augue elementum laoreet ac pharetra odio. Morbi vestibulum.', + color: '#EAB700', + uuid: '30804ea7-bd66-4d5d-91eb-b2dcfe2515b3', + community: { + displayName: 'Lorem ipsum dolor sit egestas.', + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus non dui vitae augue elementum laoreet ac pharetra odio. Morbi vestibulum.', + membersCount: 1_000_000, + color: '#4360DF', + tagIndices: [1, 2, 3, 4], + }, + } + + const encodedData = encodeChannelURLData(data) + const decodedData = decodeChannelURLData(encodedData) + + expect(encodedData).toBe( + 'G70BYJwHdqxloHnQV-SSlY7OfdEB_f8igUIHtomMR1igUTaaRSFVBhJ-mjSn8BPqdBHk0PiHrEsBk8WBTo6_gK0tSiwQDLCWpwnmKeU2Bo7j005CuygCCwWebictMe-XLrHfyPEUmLllOKoRCBtcLDALSYQvF5NCoieM550vx-sAmlmSK871edYL67bCK-PPYghGByWEGNMFs9lOIoFx2H_mJDkNNs9bYsbbaRl_uoStzrokUn0u578yAg16mYwLh-287482y4Ibg9640rAW9JNkrfwstJ2qbLLXJ2CYUOa5ftZlFZk2TnzTxIGvfdznZLVXePelos5rWwI=' + ) + expect(decodedData).toEqual(data) + }) + + test('should encode and decode user', () => { + const data = { + displayName: 'Lorem ipsum dolore nulla', + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce finibus eleifend urna. Sed euismod tellus vel tellus interdum molestie. Maecenas ut fringilla dui. Duis auctor quis magna at congue. Duis euismod tempor pharetra. Morbi blandit.', + color: '#EAB700', + } + + const encodedData = encodeUserURLData(data) + const decodedData = decodeUserURLData(encodedData) + + expect(encodedData).toBe( + 'GxgBoJwHdsOLl4DWt55mGELN6clGsb1UKTEkT0KUMDfwhWFpUyWH_cefTnvlcSf2JUXCOAWoY5ywzry-LnJ-PjgOGT1Pkb8riQp7ghv6Zu-x70x4m8lncZaRWpDN-sEfT85idUCWvppT_QFNa2A6J3Gr69UJGvWmL3S4DBwX2Jr7LBTNOvFPo6lejNUb-xizlAMUTrokunCH-qNmgtU6UK0J6Vkn8Ce35XGBFObxpxnAtnC_J_D-SrBCBnjiUlwH0ViNr3lHBg==' + ) + expect(decodedData).toEqual(data) + }) +}) diff --git a/packages/status-js/src/utils/encode-url-data.ts b/packages/status-js/src/utils/encode-url-data.ts new file mode 100644 index 00000000..f173ac48 --- /dev/null +++ b/packages/status-js/src/utils/encode-url-data.ts @@ -0,0 +1,65 @@ +import { base64url } from '@scure/base' +import { brotliCompressSync, brotliDecompressSync } from 'zlib' + +import { Channel, Community, URLData, User } from '../protos/url-data_pb' + +import type { PlainMessage } from '@bufbuild/protobuf' + +export type EncodedURLData = string & { _: 'EncodedURLData' } + +export function encodeCommunityURLData( + data: PlainMessage +): EncodedURLData { + return encodeURLData(new Community(data).toBinary()) as EncodedURLData +} + +// note: PlainMessage type does not ensure returning of only own properties +export function decodeCommunityURLData(data: string): PlainMessage { + const deserialized = decodeURLData(data) + + return Community.fromBinary( + deserialized.content + ).toJson() as PlainMessage +} + +export function encodeChannelURLData( + data: PlainMessage +): EncodedURLData { + return encodeURLData(new Channel(data).toBinary()) as EncodedURLData +} + +export function decodeChannelURLData(data: string): PlainMessage { + const deserialized = decodeURLData(data) + + return Channel.fromBinary( + deserialized.content + ).toJson() as PlainMessage +} + +export function encodeUserURLData(data: PlainMessage): EncodedURLData { + return encodeURLData(new User(data).toBinary()) as EncodedURLData +} + +export function decodeUserURLData(data: string): PlainMessage { + const deserialized = decodeURLData(data) + + return User.fromBinary(deserialized.content).toJson() as PlainMessage +} + +function encodeURLData(data: Uint8Array): string { + const serialized = new URLData({ + content: data, + }).toBinary() + const compressed = brotliCompressSync(serialized) + const encoded = base64url.encode(compressed) + + return encoded +} + +function decodeURLData(data: string): URLData { + const decoded = base64url.decode(data) + const decompressed = brotliDecompressSync(decoded) + const deserialized = URLData.fromBinary(decompressed) + + return deserialized +} diff --git a/packages/status-js/src/utils/sign-data.ts b/packages/status-js/src/utils/sign-data.ts new file mode 100644 index 00000000..00f3882e --- /dev/null +++ b/packages/status-js/src/utils/sign-data.ts @@ -0,0 +1,50 @@ +import { keccak256 } from 'ethereum-cryptography/keccak' +import { sign, verify } from 'ethereum-cryptography/secp256k1' +import { + concatBytes, + toHex, + utf8ToBytes as toBytes, +} from 'ethereum-cryptography/utils' + +import { recoverPublicKey } from './recover-public-key' + +/** + * @returns 65-byte compact ECDSA signature containing the recovery id as the last element. + */ +export async function signData( + data: Uint8Array | string, + privateKey: Uint8Array | string +): Promise { + const bytes = ensureBytes(data) + const hash = keccak256(bytes) + + const [signature, recoverId] = await sign(hash, privateKey, { + recovered: true, + der: false, + }) + + return concatBytes(signature, new Uint8Array([recoverId])) +} + +export function verifySignedData( + signature: Uint8Array, + data: Uint8Array | string, + publicKey?: string +): boolean { + const bytes = ensureBytes(data) + const hash = keccak256(bytes) + + let _publicKey + if (!publicKey) { + const recoveredKey = recoverPublicKey(signature, bytes) + _publicKey = toHex(recoveredKey) + } else { + _publicKey = publicKey.replace(/^0[xX]/, '') + } + + return verify(signature.slice(0, -1), hash, _publicKey) +} + +function ensureBytes(data: Uint8Array | string): Uint8Array { + return data instanceof Uint8Array ? data : toBytes(data) +} diff --git a/packages/status-js/src/utils/sign-url-data.test.ts b/packages/status-js/src/utils/sign-url-data.test.ts new file mode 100644 index 00000000..7b07da49 --- /dev/null +++ b/packages/status-js/src/utils/sign-url-data.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest' + +import { signEncodedURLData, verifyEncodedURLData } from './sign-url-data' + +import type { EncodedURLData } from './encode-url-data' + +describe('Sign URL data', () => { + const privateKey = new Uint8Array([ + 233, 34, 68, 49, 2, 175, 16, 66, 41, 112, 38, 154, 139, 197, 117, 203, 223, + 215, 4, 135, 228, 217, 5, 31, 75, 9, 30, 221, 141, 239, 82, 84, + ]) + + test('should sign and verify URL data', async () => { + const encodedURLData = + 'G74AgK0ObFNmYT-WC_Jcc9KfSjHXAQo9THKEEbgPaJoItceMES-bUxr2Tj9efv447rRefBIUg9CEsSFyjBOFTRdZ9PH2wUOW8hVNYqIje3BC96mZ8uFogqM6k7gCCJnMHy4ulsmsgHTdeh5dAzTNNuG8m9XB8oVeildTCKlRhINnTZh4kAl5sP8SzBB4V2_I41a8PKl3mcS0z_eF5gA=' as EncodedURLData + + const signature = await signEncodedURLData(encodedURLData, privateKey) + + expect(signature).toBe( + 'k-n7d-9Pcx6ht87F4riP5xAw1v7S-e1HGMRaeaO068Q3IF1Jo8xOyeMT9Yr3Wv349Z2CdBzylw8M83CgQhcMogA=' + ) + expect(verifyEncodedURLData(signature, encodedURLData)).toBe(true) + }) +}) diff --git a/packages/status-js/src/utils/sign-url-data.ts b/packages/status-js/src/utils/sign-url-data.ts new file mode 100644 index 00000000..6fea248a --- /dev/null +++ b/packages/status-js/src/utils/sign-url-data.ts @@ -0,0 +1,23 @@ +import { base64url } from '@scure/base' + +import { signData, verifySignedData } from './sign-data' + +import type { EncodedURLData } from './encode-url-data' + +export async function signEncodedURLData( + encodedURLData: EncodedURLData, + privateKey: Uint8Array | string +): Promise { + const signature = await signData(encodedURLData, privateKey) + + return base64url.encode(signature) +} + +export function verifyEncodedURLData( + encodedSignature: string, + encodedURLData: EncodedURLData +): boolean { + const signature = base64url.decode(encodedSignature) + + return verifySignedData(signature, encodedURLData) +} diff --git a/packages/status-js/vite.config.ts b/packages/status-js/vite.config.ts index dd7622d7..3734ccd5 100644 --- a/packages/status-js/vite.config.ts +++ b/packages/status-js/vite.config.ts @@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => { sourcemap: true, emptyOutDir: mode === 'production', rollupOptions: { - external, + external: [...external, 'zlib'], }, }, resolve: { diff --git a/yarn.lock b/yarn.lock index 6a89a170..f20dd5df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2205,6 +2205,11 @@ estree-walker "^2.0.1" picomatch "^2.2.2" +"@scure/base@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" + integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== + "@scure/base@~1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.0.0.tgz#109fb595021de285f05a7db6806f2f48296fcee7"