feat: blah crypto

This commit is contained in:
Shibo Lyu 2024-09-01 13:44:38 +08:00
parent 9f05926c67
commit 7e39e55926
9 changed files with 206 additions and 8 deletions

7
package-lock.json generated
View file

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

View file

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

View file

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

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

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

View 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
View 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)));
}

View file

@ -40,7 +40,7 @@
>
<RichTextRenderer
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>