diff --git a/package-lock.json b/package-lock.json index 42ad533..0b8dc90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@zeabur/svelte-adapter": "^1.0.0", + "canonicalize": "^2.0.0", "svelte-boring-avatars": "^1.2.6", "tailwind-merge": "^2.5.2", "typewriter-editor": "^0.12.6", @@ -2248,6 +2249,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canonicalize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.0.0.tgz", + "integrity": "sha512-ulDEYPv7asdKvqahuAY35c1selLdzDwHqugK92hfkzvlDCwXRRelDkR+Er33md/PtnpqHemgkuDPanZ4fiYZ8w==", + "license": "Apache-2.0" + }, "node_modules/chai": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", diff --git a/package.json b/package.json index ffeb681..b1bf893 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "type": "module", "dependencies": { "@zeabur/svelte-adapter": "^1.0.0", + "canonicalize": "^2.0.0", "svelte-boring-avatars": "^1.2.6", "tailwind-merge": "^2.5.2", "typewriter-editor": "^0.12.6", diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index e07cbbd..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/src/lib/blah/keypair.ts b/src/lib/blah/keypair.ts new file mode 100644 index 0000000..9dcc27c --- /dev/null +++ b/src/lib/blah/keypair.ts @@ -0,0 +1,78 @@ +import canonicalize from 'canonicalize'; +import { BlahPublicIdentity } from './publicIdentity'; +import type { BlahPayloadSignee, BlahSignedPayload } from './signedPayload'; +import { bufToHex } from './utils'; + +export type EncodedBlahKeyPair = { + v: '0'; + id: string; + privateKey: JsonWebKey; +}; + +export class BlahKeyPair { + publicIdentity: BlahPublicIdentity; + private privateKey: CryptoKey; + + get id() { + return this.publicIdentity.id; + } + + private constructor(publicIdentity: BlahPublicIdentity, privateKey: CryptoKey) { + this.publicIdentity = publicIdentity; + this.privateKey = privateKey; + } + + static async generate(): Promise { + const { publicKey, privateKey } = await crypto.subtle.generateKey('Ed25519', true, [ + 'sign', + 'verify' + ]); + const publicIdentity = await BlahPublicIdentity.fromPublicKey(publicKey); + return new BlahKeyPair(publicIdentity, privateKey); + } + + static async fromEncoded(encoded: EncodedBlahKeyPair): Promise { + if (encoded.v !== '0') { + throw new Error('Unsupported version'); + } + const publicIdentity = await BlahPublicIdentity.fromID(encoded.id); + const privateKey = await crypto.subtle.importKey( + 'jwk', + encoded.privateKey, + { name: 'Ed25519' }, + true, + ['sign'] + ); + + return new BlahKeyPair(publicIdentity, privateKey); + } + + async encode(): Promise { + return { + v: '0', + id: this.publicIdentity.id, + privateKey: await crypto.subtle.exportKey('jwk', this.privateKey) + }; + } + + async signPayload

(payload: P, date: Date = new Date()): Promise> { + const nonceBuf = new Uint32Array(1); + crypto.getRandomValues(nonceBuf); + + const timestamp = Math.floor(date.getTime() / 1000); + + const signee: BlahPayloadSignee

= { + nonce: nonceBuf[0], + payload, + timestamp, + user: this.id + }; + const signeeBytes = new TextEncoder().encode(canonicalize(signee)); + + const rawSig = await crypto.subtle.sign('Ed25519', this.privateKey, signeeBytes); + return { + sig: bufToHex(rawSig), + signee + }; + } +} diff --git a/src/lib/blah/publicIdentity.ts b/src/lib/blah/publicIdentity.ts new file mode 100644 index 0000000..90e959b --- /dev/null +++ b/src/lib/blah/publicIdentity.ts @@ -0,0 +1,53 @@ +import canonicalize from 'canonicalize'; +import type { BlahSignedPayload } from './signedPayload'; +import { bufToHex, hexToBuf } from './utils'; + +export class BlahPublicIdentity { + private publicKey: CryptoKey; + id: string; + + private constructor(publicKey: CryptoKey, id: string) { + this.publicKey = publicKey; + this.id = id; + } + + static async fromPublicKey(publicKey: CryptoKey): Promise { + const rawKey = await crypto.subtle.exportKey('raw', publicKey); + const id = bufToHex(rawKey); + return new BlahPublicIdentity(publicKey, id); + } + + static async fromID(id: string): Promise { + const rawKey = hexToBuf(id); + const publicKey = await crypto.subtle.importKey('raw', rawKey, { name: 'Ed25519' }, true, [ + 'verify' + ]); + return new BlahPublicIdentity(publicKey, id); + } + + static async verifyPayload

( + signedPayload: BlahSignedPayload

+ ): Promise<{ payload: P; identity: BlahPublicIdentity }> { + const { signee } = signedPayload; + const identity = await BlahPublicIdentity.fromID(signee.user); + return { payload: await identity.verifyPayload(signedPayload), identity }; + } + + async verifyPayload

(signedPayload: BlahSignedPayload

): Promise

{ + const { sig, signee } = signedPayload; + if (signee.user !== this.id) { + throw new Error(`Payload is not signed by this identity. Was signed by ${signee.user}.`); + } + const signeeBytes = new TextEncoder().encode(canonicalize(signee)); + const result = await crypto.subtle.verify( + 'Ed25519', + this.publicKey, + hexToBuf(sig), + signeeBytes + ); + if (!result) { + throw new Error('Invalid signature'); + } + return signee.payload; + } +} diff --git a/src/lib/blah/signedPayload.ts b/src/lib/blah/signedPayload.ts new file mode 100644 index 0000000..50d590c --- /dev/null +++ b/src/lib/blah/signedPayload.ts @@ -0,0 +1,11 @@ +export type BlahPayloadSignee

= { + nonce: number; + payload: P; + timestamp: number; + user: string; +}; + +export type BlahSignedPayload

= { + sig: string; + signee: BlahPayloadSignee

; +}; diff --git a/src/lib/blah/tests/crypto.test.ts b/src/lib/blah/tests/crypto.test.ts new file mode 100644 index 0000000..f494412 --- /dev/null +++ b/src/lib/blah/tests/crypto.test.ts @@ -0,0 +1,48 @@ +import { test, expect } from 'vitest'; +import { BlahKeyPair } from '../keypair'; + +let keypair: BlahKeyPair; + +test('generate keypair', async () => { + keypair = await BlahKeyPair.generate(); +}); + +test('encode & decode keypair', async () => { + const encoded = await keypair.encode(); + const decoded = await BlahKeyPair.fromEncoded(encoded); + + expect(decoded.id).toBe(keypair.id); +}); + +test('sign & verify payload', async () => { + const payload = { foo: 'bar', baz: 123 }; + const signedPayload = await keypair.signPayload(payload); + const verifiedPayload = await keypair.publicIdentity.verifyPayload(signedPayload); + + expect(verifiedPayload).toEqual(payload); +}); + +test('sign & verify payload with wrong keypair', async () => { + const keypair2 = await BlahKeyPair.generate(); + const payload = { foo: 'bar', baz: 123 }; + const signedPayload = await keypair.signPayload(payload); + expect(async () => { + await keypair2.publicIdentity.verifyPayload(signedPayload); + }).rejects.toThrowError(); +}); + +test('sign & verify payload with wrong key order but should still work', async () => { + const payload = { foo: 'bar', baz: 123 }; + const signedPayload = await keypair.signPayload(payload); + const signedPayload2 = { + sig: signedPayload.sig, + signee: { + payload: { baz: 123, foo: 'bar' }, + user: signedPayload.signee.user, + nonce: signedPayload.signee.nonce, + timestamp: signedPayload.signee.timestamp + } + }; + const verifiedPayload = await keypair.publicIdentity.verifyPayload(signedPayload2); + expect(verifiedPayload).toEqual(payload); +}); diff --git a/src/lib/blah/utils.ts b/src/lib/blah/utils.ts new file mode 100644 index 0000000..d1af9fd --- /dev/null +++ b/src/lib/blah/utils.ts @@ -0,0 +1,7 @@ +export function bufToHex(buf: ArrayBufferLike): string { + return [...new Uint8Array(buf)].map((x) => x.toString(16).padStart(2, '0')).join(''); +} + +export function hexToBuf(hex: string): Uint8Array { + return new Uint8Array((hex.match(/[\da-f]{2}/gi) ?? []).map((m) => parseInt(m, 16))); +} diff --git a/src/routes/(app)/chats/[chatId]/ChatMessage.svelte b/src/routes/(app)/chats/[chatId]/ChatMessage.svelte index 6593b21..e59973f 100644 --- a/src/routes/(app)/chats/[chatId]/ChatMessage.svelte +++ b/src/routes/(app)/chats/[chatId]/ChatMessage.svelte @@ -40,7 +40,7 @@ >