mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-05-01 08:41:08 +00:00
feat: blah crypto
This commit is contained in:
parent
9f05926c67
commit
7e39e55926
9 changed files with 206 additions and 8 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zeabur/svelte-adapter": "^1.0.0",
|
"@zeabur/svelte-adapter": "^1.0.0",
|
||||||
|
"canonicalize": "^2.0.0",
|
||||||
"svelte-boring-avatars": "^1.2.6",
|
"svelte-boring-avatars": "^1.2.6",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"typewriter-editor": "^0.12.6",
|
"typewriter-editor": "^0.12.6",
|
||||||
|
@ -2248,6 +2249,12 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chai": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz",
|
||||||
|
|
|
@ -37,6 +37,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zeabur/svelte-adapter": "^1.0.0",
|
"@zeabur/svelte-adapter": "^1.0.0",
|
||||||
|
"canonicalize": "^2.0.0",
|
||||||
"svelte-boring-avatars": "^1.2.6",
|
"svelte-boring-avatars": "^1.2.6",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"typewriter-editor": "^0.12.6",
|
"typewriter-editor": "^0.12.6",
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
78
src/lib/blah/keypair.ts
Normal file
78
src/lib/blah/keypair.ts
Normal file
|
@ -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<BlahKeyPair> {
|
||||||
|
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<BlahKeyPair> {
|
||||||
|
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<EncodedBlahKeyPair> {
|
||||||
|
return {
|
||||||
|
v: '0',
|
||||||
|
id: this.publicIdentity.id,
|
||||||
|
privateKey: await crypto.subtle.exportKey('jwk', this.privateKey)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async signPayload<P>(payload: P, date: Date = new Date()): Promise<BlahSignedPayload<P>> {
|
||||||
|
const nonceBuf = new Uint32Array(1);
|
||||||
|
crypto.getRandomValues(nonceBuf);
|
||||||
|
|
||||||
|
const timestamp = Math.floor(date.getTime() / 1000);
|
||||||
|
|
||||||
|
const signee: BlahPayloadSignee<P> = {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
53
src/lib/blah/publicIdentity.ts
Normal file
53
src/lib/blah/publicIdentity.ts
Normal file
|
@ -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<BlahPublicIdentity> {
|
||||||
|
const rawKey = await crypto.subtle.exportKey('raw', publicKey);
|
||||||
|
const id = bufToHex(rawKey);
|
||||||
|
return new BlahPublicIdentity(publicKey, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromID(id: string): Promise<BlahPublicIdentity> {
|
||||||
|
const rawKey = hexToBuf(id);
|
||||||
|
const publicKey = await crypto.subtle.importKey('raw', rawKey, { name: 'Ed25519' }, true, [
|
||||||
|
'verify'
|
||||||
|
]);
|
||||||
|
return new BlahPublicIdentity(publicKey, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifyPayload<P>(
|
||||||
|
signedPayload: BlahSignedPayload<P>
|
||||||
|
): Promise<{ payload: P; identity: BlahPublicIdentity }> {
|
||||||
|
const { signee } = signedPayload;
|
||||||
|
const identity = await BlahPublicIdentity.fromID(signee.user);
|
||||||
|
return { payload: await identity.verifyPayload(signedPayload), identity };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPayload<P>(signedPayload: BlahSignedPayload<P>): Promise<P> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
11
src/lib/blah/signedPayload.ts
Normal file
11
src/lib/blah/signedPayload.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export type BlahPayloadSignee<P> = {
|
||||||
|
nonce: number;
|
||||||
|
payload: P;
|
||||||
|
timestamp: number;
|
||||||
|
user: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlahSignedPayload<P> = {
|
||||||
|
sig: string;
|
||||||
|
signee: BlahPayloadSignee<P>;
|
||||||
|
};
|
48
src/lib/blah/tests/crypto.test.ts
Normal file
48
src/lib/blah/tests/crypto.test.ts
Normal file
|
@ -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);
|
||||||
|
});
|
7
src/lib/blah/utils.ts
Normal file
7
src/lib/blah/utils.ts
Normal file
|
@ -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)));
|
||||||
|
}
|
|
@ -40,7 +40,7 @@
|
||||||
>
|
>
|
||||||
<RichTextRenderer
|
<RichTextRenderer
|
||||||
content={message.content}
|
content={message.content}
|
||||||
class="z-10 select-text overflow-hidden px-3 py-2"
|
class="z-10 select-text overflow-hidden rounded-2xl px-3 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Add table
Reference in a new issue