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
This commit is contained in:
Felicio Mununga 2023-03-21 11:05:59 +01:00 committed by GitHub
parent fd372f9445
commit 672b7c2c62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 745 additions and 33 deletions

View File

@ -5,3 +5,4 @@
**/protos
**/coverage
.next
**/.data

View File

@ -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"

View File

@ -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)
})

View File

@ -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 {

View File

@ -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)

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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'

View File

@ -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 {

View File

@ -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<CommunityDescription> {
*/
tags: string[] = [];
/**
* @generated from field: URLParams url_params = 15;
*/
urlParams?: URLParams;
constructor(data?: PartialMessage<CommunityDescription>) {
super();
proto3.util.initPartial(data, this);
@ -304,6 +310,7 @@ export class CommunityDescription extends Message<CommunityDescription> {
{ 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<BinaryReadOptions>): CommunityDescription {
@ -389,6 +396,11 @@ export class CommunityChat extends Message<CommunityChat> {
*/
position = 0;
/**
* @generated from field: URLParams url_params = 6;
*/
urlParams?: URLParams;
constructor(data?: PartialMessage<CommunityChat>) {
super();
proto3.util.initPartial(data, this);
@ -402,6 +414,7 @@ export class CommunityChat extends Message<CommunityChat> {
{ 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<BinaryReadOptions>): CommunityChat {

View File

@ -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 {

View File

@ -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<ContactCodeAdvertisement>
*/
chatIdentity?: ChatIdentity;
/**
* @generated from field: URLParams url_params = 3;
*/
urlParams?: URLParams;
constructor(data?: PartialMessage<ContactCodeAdvertisement>) {
super();
proto3.util.initPartial(data, this);
@ -259,6 +265,7 @@ export class ContactCodeAdvertisement extends Message<ContactCodeAdvertisement>
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<BinaryReadOptions>): ContactCodeAdvertisement {

View File

@ -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;
}

View File

@ -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<Community> {
/**
* @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<Community>) {
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<BinaryReadOptions>): Community {
return new Community().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Community {
return new Community().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Community {
return new Community().fromJsonString(jsonString, options);
}
static equals(a: Community | PlainMessage<Community> | undefined, b: Community | PlainMessage<Community> | undefined): boolean {
return proto3.util.equals(Community, a, b);
}
}
/**
* @generated from message Channel
*/
export class Channel extends Message<Channel> {
/**
* @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<Channel>) {
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<BinaryReadOptions>): Channel {
return new Channel().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Channel {
return new Channel().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Channel {
return new Channel().fromJsonString(jsonString, options);
}
static equals(a: Channel | PlainMessage<Channel> | undefined, b: Channel | PlainMessage<Channel> | undefined): boolean {
return proto3.util.equals(Channel, a, b);
}
}
/**
* @generated from message User
*/
export class User extends Message<User> {
/**
* @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<User>) {
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<BinaryReadOptions>): User {
return new User().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): User {
return new User().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): User {
return new User().fromJsonString(jsonString, options);
}
static equals(a: User | PlainMessage<User> | undefined, b: User | PlainMessage<User> | undefined): boolean {
return proto3.util.equals(User, a, b);
}
}
/**
* @generated from message URLData
*/
export class URLData extends Message<URLData> {
/**
* Community, Channel, or User
*
* @generated from field: bytes content = 1;
*/
content = new Uint8Array(0);
constructor(data?: PartialMessage<URLData>) {
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<BinaryReadOptions>): URLData {
return new URLData().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): URLData {
return new URLData().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): URLData {
return new URLData().fromJsonString(jsonString, options);
}
static equals(a: URLData | PlainMessage<URLData> | undefined, b: URLData | PlainMessage<URLData> | undefined): boolean {
return proto3.util.equals(URLData, a, b);
}
}
/**
* @generated from message URLParams
*/
export class URLParams extends Message<URLParams> {
/**
* @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<URLParams>) {
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<BinaryReadOptions>): URLParams {
return new URLParams().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): URLParams {
return new URLParams().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): URLParams {
return new URLParams().fromJsonString(jsonString, options);
}
static equals(a: URLParams | PlainMessage<URLParams> | undefined, b: URLParams | PlainMessage<URLParams> | undefined): boolean {
return proto3.util.equals(URLParams, a, b);
}
}

View File

@ -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='
)
})
})

View File

@ -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)}`
)
}

View File

@ -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)
})
})

View File

@ -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<Community>
): EncodedURLData {
return encodeURLData(new Community(data).toBinary()) as EncodedURLData
}
// note: PlainMessage<T> type does not ensure returning of only own properties
export function decodeCommunityURLData(data: string): PlainMessage<Community> {
const deserialized = decodeURLData(data)
return Community.fromBinary(
deserialized.content
).toJson() as PlainMessage<Community>
}
export function encodeChannelURLData(
data: PlainMessage<Channel>
): EncodedURLData {
return encodeURLData(new Channel(data).toBinary()) as EncodedURLData
}
export function decodeChannelURLData(data: string): PlainMessage<Channel> {
const deserialized = decodeURLData(data)
return Channel.fromBinary(
deserialized.content
).toJson() as PlainMessage<Channel>
}
export function encodeUserURLData(data: PlainMessage<User>): EncodedURLData {
return encodeURLData(new User(data).toBinary()) as EncodedURLData
}
export function decodeUserURLData(data: string): PlainMessage<User> {
const deserialized = decodeURLData(data)
return User.fromBinary(deserialized.content).toJson() as PlainMessage<User>
}
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
}

View File

@ -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<Uint8Array> {
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)
}

View File

@ -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)
})
})

View File

@ -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<string> {
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)
}

View File

@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => {
sourcemap: true,
emptyOutDir: mode === 'production',
rollupOptions: {
external,
external: [...external, 'zlib'],
},
},
resolve: {

View File

@ -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"