@openlv/core 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,19 @@
1
- import type { SessionLinkParameters } from "../session.js";
2
- export type EncryptionKey = {
3
- toString: () => string;
4
- encrypt: (message: string) => Promise<string>;
1
+ export type PublicKey = Uint8Array<ArrayBuffer>;
2
+ export type KeyPair = {
3
+ publicKey: PublicKey;
4
+ secretKey: Uint8Array;
5
5
  };
6
- export type DecryptionKey = {
7
- toString: () => string;
8
- decrypt: (message: string) => Promise<string>;
6
+ export type Cipher = {
7
+ encrypt: (message: string) => Promise<string>;
8
+ decrypt: (ciphertext: string) => Promise<string>;
9
9
  };
10
- export declare const createEncryptionKey: (publicKey: Uint8Array) => Promise<EncryptionKey>;
11
- export declare const parseEncryptionKey: (serializedKey: string) => Promise<EncryptionKey>;
12
- export declare const createDecryptionKey: (secretKey: Uint8Array) => Promise<DecryptionKey>;
13
- export type EncKeypair = {
14
- encryptionKey: EncryptionKey;
15
- decryptionKey: DecryptionKey;
10
+ export type AcceptedPeer = {
11
+ peerKey: PublicKey;
12
+ cipher: Cipher;
13
+ message: string;
16
14
  };
17
- export declare const generateKeyPair: () => Promise<EncKeypair>;
18
- export declare const initEncryptionKeys: (initParameters?: SessionLinkParameters) => Promise<EncKeypair & {
19
- relyingEncryptionKey?: EncryptionKey;
20
- }>;
15
+ export declare const generateKeyPair: () => KeyPair;
16
+ export declare const parsePublicKey: (encoded: string) => PublicKey | undefined;
17
+ export declare const hashPublicKey: (publicKey: PublicKey) => Promise<string>;
18
+ export declare const createPeerCipher: (keyPair: KeyPair, peerKey: PublicKey) => Promise<Cipher>;
19
+ export declare const acceptPeer: (keyPair: KeyPair, ciphertext: string) => Promise<AcceptedPeer>;
@@ -1,82 +1,78 @@
1
- import { secretbox } from "@noble/ciphers/salsa.js";
1
+ import { gcm } from "@noble/ciphers/aes.js";
2
+ import { bytesToHex, bytesToUtf8, concatBytes, equalBytes, utf8ToBytes, } from "@noble/ciphers/utils.js";
2
3
  import { x25519 } from "@noble/curves/ed25519.js";
3
4
  import { fromBase64, toBase64 } from "./base64.js";
4
5
  const PUBLIC_KEY_BYTES = 32;
5
- const NONCE_BYTES = 24;
6
- const composePayload = (ephemeralPublicKey, nonce, ciphertext) => {
7
- const payload = new Uint8Array(ephemeralPublicKey.length + nonce.length + ciphertext.length);
8
- payload.set(ephemeralPublicKey, 0);
9
- payload.set(nonce, ephemeralPublicKey.length);
10
- payload.set(ciphertext, ephemeralPublicKey.length + nonce.length);
11
- return toBase64(payload);
6
+ const NONCE_BYTES = 12;
7
+ const HASH_LENGTH = 16;
8
+ const KEY_INFO = new TextEncoder().encode("openlv/1 peer-key");
9
+ export const generateKeyPair = () => {
10
+ const secretKey = x25519.utils.randomSecretKey();
11
+ return {
12
+ publicKey: Uint8Array.from(x25519.getPublicKey(secretKey)),
13
+ secretKey,
14
+ };
12
15
  };
13
- const decomposePayload = (payload) => {
14
- const bytes = fromBase64(payload);
15
- if (bytes.length <= PUBLIC_KEY_BYTES + NONCE_BYTES) {
16
- throw new Error("Encrypted payload is malformed.");
16
+ export const parsePublicKey = (encoded) => {
17
+ try {
18
+ const publicKey = fromBase64(encoded);
19
+ return publicKey.length === PUBLIC_KEY_BYTES ? publicKey : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
17
23
  }
18
- const ephemeralPublicKey = bytes.slice(0, PUBLIC_KEY_BYTES);
19
- const nonce = bytes.slice(PUBLIC_KEY_BYTES, PUBLIC_KEY_BYTES + NONCE_BYTES);
20
- const ciphertext = bytes.slice(PUBLIC_KEY_BYTES + NONCE_BYTES);
24
+ };
25
+ export const hashPublicKey = async (publicKey) => {
26
+ const hash = await crypto.subtle.digest("SHA-256", publicKey);
27
+ return bytesToHex(new Uint8Array(hash)).slice(0, HASH_LENGTH);
28
+ };
29
+ const deriveKeys = async (keyPair, peerKey) => {
30
+ const shared = await crypto.subtle.importKey("raw", Uint8Array.from(x25519.getSharedSecret(keyPair.secretKey, peerKey)), "HKDF", false, ["deriveBits"]);
31
+ const deriveDirectionKey = async (sender, recipient) => {
32
+ const key = await crypto.subtle.deriveBits({
33
+ name: "HKDF",
34
+ hash: "SHA-256",
35
+ salt: new Uint8Array(),
36
+ info: Uint8Array.from(concatBytes(KEY_INFO, sender, recipient)),
37
+ }, shared, 256);
38
+ return new Uint8Array(key);
39
+ };
21
40
  return {
22
- ephemeralPublicKey,
23
- nonce,
24
- ciphertext,
41
+ sendKey: await deriveDirectionKey(keyPair.publicKey, peerKey),
42
+ receiveKey: await deriveDirectionKey(peerKey, keyPair.publicKey),
25
43
  };
26
44
  };
27
- export const createEncryptionKey = async (publicKey) => {
28
- if (publicKey.length !== PUBLIC_KEY_BYTES) {
29
- throw new Error(`Public key must be ${PUBLIC_KEY_BYTES} bytes, got ${publicKey.length}`);
45
+ const decodeCiphertext = (ciphertext) => {
46
+ const bytes = fromBase64(ciphertext);
47
+ if (bytes.length <= PUBLIC_KEY_BYTES + NONCE_BYTES) {
48
+ throw new Error("Ciphertext is malformed");
30
49
  }
31
- const serializedPublicKey = toBase64(publicKey);
32
50
  return {
33
- toString: () => serializedPublicKey,
34
- encrypt: async (message) => {
35
- const ephemeralSecret = x25519.utils.randomSecretKey();
36
- const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);
37
- const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
38
- const { seal } = secretbox(x25519.getSharedSecret(ephemeralSecret, publicKey), nonce);
39
- const ciphertext = seal(new TextEncoder().encode(message));
40
- return composePayload(ephemeralPublic, nonce, ciphertext);
41
- },
51
+ peerKey: bytes.slice(0, PUBLIC_KEY_BYTES),
52
+ nonce: bytes.slice(PUBLIC_KEY_BYTES, PUBLIC_KEY_BYTES + NONCE_BYTES),
53
+ ciphertext: bytes.slice(PUBLIC_KEY_BYTES + NONCE_BYTES),
42
54
  };
43
55
  };
44
- export const parseEncryptionKey = async (serializedKey) => createEncryptionKey(fromBase64(serializedKey));
45
- export const createDecryptionKey = async (secretKey) => {
46
- const serializedSecretKey = toBase64(secretKey);
56
+ export const createPeerCipher = async (keyPair, peerKey) => {
57
+ const { sendKey, receiveKey } = await deriveKeys(keyPair, peerKey);
47
58
  return {
48
- toString: () => serializedSecretKey,
49
- decrypt: async (message) => {
50
- if (!message) {
51
- throw new Error("Cannot decrypt an empty payload");
59
+ encrypt: async (message) => {
60
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
61
+ const ciphertext = gcm(sendKey, nonce).encrypt(utf8ToBytes(message));
62
+ return toBase64(concatBytes(keyPair.publicKey, nonce, ciphertext));
63
+ },
64
+ decrypt: async (ciphertext) => {
65
+ const decoded = decodeCiphertext(ciphertext);
66
+ if (!equalBytes(decoded.peerKey, peerKey)) {
67
+ throw new Error("Ciphertext is not from the peer");
52
68
  }
53
- const { ephemeralPublicKey, nonce, ciphertext } = decomposePayload(message);
54
- const { open } = secretbox(x25519.getSharedSecret(secretKey, ephemeralPublicKey), nonce);
55
- return new TextDecoder().decode(open(ciphertext));
69
+ return bytesToUtf8(gcm(receiveKey, decoded.nonce).decrypt(decoded.ciphertext));
56
70
  },
57
71
  };
58
72
  };
59
- export const generateKeyPair = async () => {
60
- const secretKey = x25519.utils.randomSecretKey();
61
- const publicKey = x25519.getPublicKey(secretKey);
62
- return {
63
- encryptionKey: await createEncryptionKey(publicKey),
64
- decryptionKey: await createDecryptionKey(secretKey),
65
- };
66
- };
67
- export const initEncryptionKeys = async (initParameters) => {
68
- const { encryptionKey, decryptionKey } = await generateKeyPair();
69
- // Note: the peer key is deliberately left undefined until discovered —
70
- // falling back to our own key would silently encrypt to ourselves.
71
- const publicKey = initParameters && "publicKey" in initParameters
72
- ? initParameters.publicKey
73
- : undefined;
74
- return {
75
- encryptionKey,
76
- decryptionKey,
77
- relyingEncryptionKey: publicKey
78
- ? await parseEncryptionKey(publicKey)
79
- : undefined,
80
- };
73
+ export const acceptPeer = async (keyPair, ciphertext) => {
74
+ const { peerKey } = decodeCiphertext(ciphertext);
75
+ const cipher = await createPeerCipher(keyPair, peerKey);
76
+ return { peerKey, cipher, message: await cipher.decrypt(ciphertext) };
81
77
  };
82
78
  //# sourceMappingURL=asymmetric.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"asymmetric.js","sourceRoot":"","sources":["../../src/encryption/asymmetric.ts"],"names":[],"mappings":"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","sourcesContent":["import { secretbox } from \"@noble/ciphers/salsa.js\";\nimport { x25519 } from \"@noble/curves/ed25519.js\";\n\nimport type { SessionLinkParameters } from \"../session.js\";\nimport { fromBase64, toBase64 } from \"./base64.js\";\n\nconst PUBLIC_KEY_BYTES = 32;\nconst NONCE_BYTES = 24;\n\nconst composePayload = (\n ephemeralPublicKey: Uint8Array,\n nonce: Uint8Array,\n ciphertext: Uint8Array,\n): string => {\n const payload = new Uint8Array(\n ephemeralPublicKey.length + nonce.length + ciphertext.length,\n );\n\n payload.set(ephemeralPublicKey, 0);\n payload.set(nonce, ephemeralPublicKey.length);\n payload.set(ciphertext, ephemeralPublicKey.length + nonce.length);\n\n return toBase64(payload);\n};\n\nconst decomposePayload = (\n payload: string,\n): {\n ephemeralPublicKey: Uint8Array;\n nonce: Uint8Array;\n ciphertext: Uint8Array;\n} => {\n const bytes = fromBase64(payload);\n\n if (bytes.length <= PUBLIC_KEY_BYTES + NONCE_BYTES) {\n throw new Error(\"Encrypted payload is malformed.\");\n }\n\n const ephemeralPublicKey = bytes.slice(0, PUBLIC_KEY_BYTES);\n const nonce = bytes.slice(PUBLIC_KEY_BYTES, PUBLIC_KEY_BYTES + NONCE_BYTES);\n const ciphertext = bytes.slice(PUBLIC_KEY_BYTES + NONCE_BYTES);\n\n return {\n ephemeralPublicKey,\n nonce,\n ciphertext,\n };\n};\n\nexport type EncryptionKey = {\n toString: () => string;\n encrypt: (message: string) => Promise<string>;\n};\n\nexport type DecryptionKey = {\n toString: () => string;\n decrypt: (message: string) => Promise<string>;\n};\n\nexport const createEncryptionKey = async (\n publicKey: Uint8Array,\n): Promise<EncryptionKey> => {\n if (publicKey.length !== PUBLIC_KEY_BYTES) {\n throw new Error(\n `Public key must be ${PUBLIC_KEY_BYTES} bytes, got ${publicKey.length}`,\n );\n }\n\n const serializedPublicKey = toBase64(publicKey);\n\n return {\n toString: () => serializedPublicKey,\n encrypt: async (message: string) => {\n const ephemeralSecret = x25519.utils.randomSecretKey();\n const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);\n const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));\n const { seal } = secretbox(\n x25519.getSharedSecret(ephemeralSecret, publicKey),\n nonce,\n );\n const ciphertext = seal(new TextEncoder().encode(message));\n\n return composePayload(ephemeralPublic, nonce, ciphertext);\n },\n };\n};\n\nexport const parseEncryptionKey = async (\n serializedKey: string,\n): Promise<EncryptionKey> => createEncryptionKey(fromBase64(serializedKey));\n\nexport const createDecryptionKey = async (\n secretKey: Uint8Array,\n): Promise<DecryptionKey> => {\n const serializedSecretKey = toBase64(secretKey);\n\n return {\n toString: () => serializedSecretKey,\n decrypt: async (message: string) => {\n if (!message) {\n throw new Error(\"Cannot decrypt an empty payload\");\n }\n\n const { ephemeralPublicKey, nonce, ciphertext }\n = decomposePayload(message);\n const { open } = secretbox(\n x25519.getSharedSecret(secretKey, ephemeralPublicKey),\n nonce,\n );\n\n return new TextDecoder().decode(open(ciphertext));\n },\n };\n};\n\nexport type EncKeypair = {\n encryptionKey: EncryptionKey;\n decryptionKey: DecryptionKey;\n};\n\nexport const generateKeyPair = async (): Promise<EncKeypair> => {\n const secretKey = x25519.utils.randomSecretKey();\n const publicKey = x25519.getPublicKey(secretKey);\n\n return {\n encryptionKey: await createEncryptionKey(publicKey),\n decryptionKey: await createDecryptionKey(secretKey),\n };\n};\n\nexport const initEncryptionKeys = async (\n initParameters?: SessionLinkParameters,\n): Promise<EncKeypair & { relyingEncryptionKey?: EncryptionKey; }> => {\n const { encryptionKey, decryptionKey } = await generateKeyPair();\n\n // Note: the peer key is deliberately left undefined until discovered —\n // falling back to our own key would silently encrypt to ourselves.\n const publicKey\n = initParameters && \"publicKey\" in initParameters\n ? (initParameters.publicKey as string)\n : undefined;\n\n return {\n encryptionKey,\n decryptionKey,\n relyingEncryptionKey: publicKey\n ? await parseEncryptionKey(publicKey)\n : undefined,\n };\n};\n"]}
1
+ {"version":3,"file":"asymmetric.js","sourceRoot":"","sources":["../../src/encryption/asymmetric.ts"],"names":[],"mappings":"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","sourcesContent":["import { gcm } from \"@noble/ciphers/aes.js\";\nimport {\n bytesToHex,\n bytesToUtf8,\n concatBytes,\n equalBytes,\n utf8ToBytes,\n} from \"@noble/ciphers/utils.js\";\nimport { x25519 } from \"@noble/curves/ed25519.js\";\n\nimport { fromBase64, toBase64 } from \"./base64.js\";\n\nconst PUBLIC_KEY_BYTES = 32;\nconst NONCE_BYTES = 12;\nconst HASH_LENGTH = 16;\nconst KEY_INFO = new TextEncoder().encode(\"openlv/1 peer-key\");\n\nexport type PublicKey = Uint8Array<ArrayBuffer>;\n\nexport type KeyPair = {\n publicKey: PublicKey;\n secretKey: Uint8Array;\n};\n\nexport type Cipher = {\n encrypt: (message: string) => Promise<string>;\n decrypt: (ciphertext: string) => Promise<string>;\n};\n\nexport type AcceptedPeer = {\n peerKey: PublicKey;\n cipher: Cipher;\n message: string;\n};\n\ntype DecodedCiphertext = {\n peerKey: PublicKey;\n nonce: Uint8Array;\n ciphertext: Uint8Array;\n};\n\nexport const generateKeyPair = (): KeyPair => {\n const secretKey = x25519.utils.randomSecretKey();\n\n return {\n publicKey: Uint8Array.from(x25519.getPublicKey(secretKey)),\n secretKey,\n };\n};\n\nexport const parsePublicKey = (encoded: string): PublicKey | undefined => {\n try {\n const publicKey = fromBase64(encoded);\n\n return publicKey.length === PUBLIC_KEY_BYTES ? publicKey : undefined;\n }\n catch {\n return undefined;\n }\n};\n\nexport const hashPublicKey = async (publicKey: PublicKey): Promise<string> => {\n const hash = await crypto.subtle.digest(\"SHA-256\", publicKey);\n\n return bytesToHex(new Uint8Array(hash)).slice(0, HASH_LENGTH);\n};\n\nconst deriveKeys = async (\n keyPair: KeyPair,\n peerKey: PublicKey,\n): Promise<{ sendKey: Uint8Array; receiveKey: Uint8Array; }> => {\n const shared = await crypto.subtle.importKey(\n \"raw\",\n Uint8Array.from(x25519.getSharedSecret(keyPair.secretKey, peerKey)),\n \"HKDF\",\n false,\n [\"deriveBits\"],\n );\n const deriveDirectionKey = async (sender: PublicKey, recipient: PublicKey) => {\n const key = await crypto.subtle.deriveBits(\n {\n name: \"HKDF\",\n hash: \"SHA-256\",\n salt: new Uint8Array(),\n info: Uint8Array.from(concatBytes(KEY_INFO, sender, recipient)),\n },\n shared,\n 256,\n );\n\n return new Uint8Array(key);\n };\n\n return {\n sendKey: await deriveDirectionKey(keyPair.publicKey, peerKey),\n receiveKey: await deriveDirectionKey(peerKey, keyPair.publicKey),\n };\n};\n\nconst decodeCiphertext = (ciphertext: string): DecodedCiphertext => {\n const bytes = fromBase64(ciphertext);\n\n if (bytes.length <= PUBLIC_KEY_BYTES + NONCE_BYTES) {\n throw new Error(\"Ciphertext is malformed\");\n }\n\n return {\n peerKey: bytes.slice(0, PUBLIC_KEY_BYTES),\n nonce: bytes.slice(PUBLIC_KEY_BYTES, PUBLIC_KEY_BYTES + NONCE_BYTES),\n ciphertext: bytes.slice(PUBLIC_KEY_BYTES + NONCE_BYTES),\n };\n};\n\nexport const createPeerCipher = async (\n keyPair: KeyPair,\n peerKey: PublicKey,\n): Promise<Cipher> => {\n const { sendKey, receiveKey } = await deriveKeys(keyPair, peerKey);\n\n return {\n encrypt: async (message) => {\n const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));\n const ciphertext = gcm(sendKey, nonce).encrypt(utf8ToBytes(message));\n\n return toBase64(concatBytes(keyPair.publicKey, nonce, ciphertext));\n },\n decrypt: async (ciphertext) => {\n const decoded = decodeCiphertext(ciphertext);\n\n if (!equalBytes(decoded.peerKey, peerKey)) {\n throw new Error(\"Ciphertext is not from the peer\");\n }\n\n return bytesToUtf8(gcm(receiveKey, decoded.nonce).decrypt(decoded.ciphertext));\n },\n };\n};\n\nexport const acceptPeer = async (\n keyPair: KeyPair,\n ciphertext: string,\n): Promise<AcceptedPeer> => {\n const { peerKey } = decodeCiphertext(ciphertext);\n const cipher = await createPeerCipher(keyPair, peerKey);\n\n return { peerKey, cipher, message: await cipher.decrypt(ciphertext) };\n};\n"]}
@@ -1,36 +1,56 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { generateKeyPair, parseEncryptionKey } from "./asymmetric.js";
3
- describe("Asymmetric encryption", () => {
4
- test("should generate a key pair", async () => {
5
- const keyPair = await generateKeyPair();
6
- expect(keyPair).toBeDefined();
7
- expect(keyPair.encryptionKey).toBeDefined();
8
- expect(keyPair.decryptionKey).toBeDefined();
2
+ import { createPeerCipher, generateKeyPair } from "./asymmetric.js";
3
+ import { fromBase64, toBase64 } from "./base64.js";
4
+ describe("Peer encryption", () => {
5
+ test("should generate a key pair", () => {
6
+ const keyPair = generateKeyPair();
7
+ expect(keyPair.publicKey).toHaveLength(32);
8
+ expect(keyPair.secretKey).toHaveLength(32);
9
9
  });
10
10
  test("should encrypt and decrypt a message", async () => {
11
- const { encryptionKey, decryptionKey } = await generateKeyPair();
11
+ const alice = generateKeyPair();
12
+ const bob = generateKeyPair();
12
13
  const message = "test";
13
- // regular encryption decryption
14
- const encrypted = await encryptionKey.encrypt(message);
15
- const decrypted = await decryptionKey.decrypt(encrypted);
16
- expect(decrypted).toBe(message);
17
- });
18
- test("should encrypt and decrypt a message with a parsed key", async () => {
19
- const { encryptionKey, decryptionKey } = await generateKeyPair();
20
- const message = "test";
21
- const key = encryptionKey.toString();
22
- const parsedKey = await parseEncryptionKey(key);
23
- const parsedEncrypted = await parsedKey.encrypt(message);
24
- const decodedParsedEncrypted = await decryptionKey.decrypt(parsedEncrypted);
25
- expect(decodedParsedEncrypted).toBe(message);
14
+ const toBob = await createPeerCipher(alice, bob.publicKey);
15
+ const fromAlice = await createPeerCipher(bob, alice.publicKey);
16
+ await expect(fromAlice.decrypt(await toBob.encrypt(message))).resolves.toBe(message);
26
17
  });
27
18
  test("should be able to encrypt large blob", async () => {
28
- const { encryptionKey, decryptionKey } = await generateKeyPair();
19
+ const keyPair = generateKeyPair();
20
+ const cipher = await createPeerCipher(keyPair, keyPair.publicKey);
29
21
  const message = Array.from({ length: 1024 * 1024 }).fill("a")
30
22
  .join("");
31
- const encrypted = await encryptionKey.encrypt(message);
32
- const decrypted = await decryptionKey.decrypt(encrypted);
33
- expect(decrypted).toBe(message);
23
+ await expect(cipher.decrypt(await cipher.encrypt(message))).resolves.toBe(message);
24
+ });
25
+ test("should decrypt the shared test vector", async () => {
26
+ // Same bytes as the Rust test: sender secret 0x01 * 32, receiver secret 0x02 * 32, nonce 0x03 * 12.
27
+ const keyPair = {
28
+ publicKey: fromBase64("zo060cy2M+x7cMF4FKXHbs0CloUFDTRHRboFhw5YfVk="),
29
+ secretKey: fromBase64("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="),
30
+ };
31
+ const peerKey = fromBase64("pOCSkrZRwni5dyxWn1+puxPZBrRqtoyd+dwrRAn4ogk=");
32
+ const cipher = await createPeerCipher(keyPair, peerKey);
33
+ const ciphertext = "pOCSkrZRwni5dyxWn1+puxPZBrRqtoyd+dwrRAn4ogkDAwMDAwMDAwMDAwOL3klAuRZ1a2kM5EryzNjgXIUN6C2hj8Att8QbUqi3iEg5GNHlNL2Q3C//";
34
+ await expect(cipher.decrypt(ciphertext)).resolves.toBe("openlv peer-key test vector");
35
+ });
36
+ test("should reject a ciphertext from a different peer key", async () => {
37
+ const alice = generateKeyPair();
38
+ const bob = generateKeyPair();
39
+ const mallory = generateKeyPair();
40
+ const fromMallory = await createPeerCipher(mallory, bob.publicKey);
41
+ const expectingMallory = await createPeerCipher(bob, mallory.publicKey);
42
+ const expectingAlice = await createPeerCipher(bob, alice.publicKey);
43
+ const ciphertext = await fromMallory.encrypt("test");
44
+ await expect(expectingMallory.decrypt(ciphertext)).resolves.toBe("test");
45
+ await expect(expectingAlice.decrypt(ciphertext)).rejects.toThrow("Ciphertext is not from the peer");
46
+ });
47
+ test("should reject a ciphertext reflected back to its sender", async () => {
48
+ const alice = generateKeyPair();
49
+ const bob = generateKeyPair();
50
+ const withBob = await createPeerCipher(alice, bob.publicKey);
51
+ const reflected = fromBase64(await withBob.encrypt("test"));
52
+ reflected.set(bob.publicKey, 0);
53
+ await expect(withBob.decrypt(toBase64(reflected))).rejects.toThrow();
34
54
  });
35
55
  });
36
56
  //# sourceMappingURL=asymmetric.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"asymmetric.test.js","sourceRoot":"","sources":["../../src/encryption/asymmetric.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAEtE,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,IAAI,CAAC,4BAA4B,EAAE,KAAK,IAAI,EAAE;QAC5C,MAAM,OAAO,GAAG,MAAM,eAAe,EAAE,CAAC;QAExC,MAAM,CAAC,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;QAC9B,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,WAAW,EAAE,CAAC;QAC5C,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,WAAW,EAAE,CAAC;IAC9C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,sCAAsC,EAAE,KAAK,IAAI,EAAE;QACtD,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,GAAG,MAAM,eAAe,EAAE,CAAC;QACjE,MAAM,OAAO,GAAG,MAAM,CAAC;QAEvB,gCAAgC;QAChC,MAAM,SAAS,GAAG,MAAM,aAAa,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QACvD,MAAM,SAAS,GAAG,MAAM,aAAa,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAEzD,MAAM,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAClC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,wDAAwD,EAAE,KAAK,IAAI,EAAE;QACxE,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,GAAG,MAAM,eAAe,EAAE,CAAC;QACjE,MAAM,OAAO,GAAG,MAAM,CAAC;QAEvB,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,EAAE,CAAC;QACrC,MAAM,SAAS,GAAG,MAAM,kBAAkB,CAAC,GAAG,CAAC,CAAC;QAChD,MAAM,eAAe,GAAG,MAAM,SAAS,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAEzD,MAAM,sBAAsB,GAAG,MAAM,aAAa,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC;QAE5E,MAAM,CAAC,sBAAsB,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,sCAAsC,EAAE,KAAK,IAAI,EAAE;QACtD,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,GAAG,MAAM,eAAe,EAAE,CAAC;QACjE,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,GAAG,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;aAC1D,IAAI,CAAC,EAAE,CAAC,CAAC;QAEZ,MAAM,SAAS,GAAG,MAAM,aAAa,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QACvD,MAAM,SAAS,GAAG,MAAM,aAAa,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAEzD,MAAM,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAClC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import { describe, expect, test } from \"vitest\";\n\nimport { generateKeyPair, parseEncryptionKey } from \"./asymmetric.js\";\n\ndescribe(\"Asymmetric encryption\", () => {\n test(\"should generate a key pair\", async () => {\n const keyPair = await generateKeyPair();\n\n expect(keyPair).toBeDefined();\n expect(keyPair.encryptionKey).toBeDefined();\n expect(keyPair.decryptionKey).toBeDefined();\n });\n\n test(\"should encrypt and decrypt a message\", async () => {\n const { encryptionKey, decryptionKey } = await generateKeyPair();\n const message = \"test\";\n\n // regular encryption decryption\n const encrypted = await encryptionKey.encrypt(message);\n const decrypted = await decryptionKey.decrypt(encrypted);\n\n expect(decrypted).toBe(message);\n });\n\n test(\"should encrypt and decrypt a message with a parsed key\", async () => {\n const { encryptionKey, decryptionKey } = await generateKeyPair();\n const message = \"test\";\n\n const key = encryptionKey.toString();\n const parsedKey = await parseEncryptionKey(key);\n const parsedEncrypted = await parsedKey.encrypt(message);\n\n const decodedParsedEncrypted = await decryptionKey.decrypt(parsedEncrypted);\n\n expect(decodedParsedEncrypted).toBe(message);\n });\n\n test(\"should be able to encrypt large blob\", async () => {\n const { encryptionKey, decryptionKey } = await generateKeyPair();\n const message = Array.from({ length: 1024 * 1024 }).fill(\"a\")\n .join(\"\");\n\n const encrypted = await encryptionKey.encrypt(message);\n const decrypted = await decryptionKey.decrypt(encrypted);\n\n expect(decrypted).toBe(message);\n });\n});\n"]}
1
+ {"version":3,"file":"asymmetric.test.js","sourceRoot":"","sources":["../../src/encryption/asymmetric.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, expect, test } from \"vitest\";\n\nimport { createPeerCipher, generateKeyPair } from \"./asymmetric.js\";\nimport { fromBase64, toBase64 } from \"./base64.js\";\n\ndescribe(\"Peer encryption\", () => {\n test(\"should generate a key pair\", () => {\n const keyPair = generateKeyPair();\n\n expect(keyPair.publicKey).toHaveLength(32);\n expect(keyPair.secretKey).toHaveLength(32);\n });\n\n test(\"should encrypt and decrypt a message\", async () => {\n const alice = generateKeyPair();\n const bob = generateKeyPair();\n const message = \"test\";\n\n const toBob = await createPeerCipher(alice, bob.publicKey);\n const fromAlice = await createPeerCipher(bob, alice.publicKey);\n\n await expect(fromAlice.decrypt(await toBob.encrypt(message))).resolves.toBe(message);\n });\n\n test(\"should be able to encrypt large blob\", async () => {\n const keyPair = generateKeyPair();\n const cipher = await createPeerCipher(keyPair, keyPair.publicKey);\n const message = Array.from({ length: 1024 * 1024 }).fill(\"a\")\n .join(\"\");\n\n await expect(cipher.decrypt(await cipher.encrypt(message))).resolves.toBe(message);\n });\n\n test(\"should decrypt the shared test vector\", async () => {\n // Same bytes as the Rust test: sender secret 0x01 * 32, receiver secret 0x02 * 32, nonce 0x03 * 12.\n const keyPair = {\n publicKey: fromBase64(\"zo060cy2M+x7cMF4FKXHbs0CloUFDTRHRboFhw5YfVk=\"),\n secretKey: fromBase64(\"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\"),\n };\n const peerKey = fromBase64(\"pOCSkrZRwni5dyxWn1+puxPZBrRqtoyd+dwrRAn4ogk=\");\n const cipher = await createPeerCipher(keyPair, peerKey);\n const ciphertext = \"pOCSkrZRwni5dyxWn1+puxPZBrRqtoyd+dwrRAn4ogkDAwMDAwMDAwMDAwOL3klAuRZ1a2kM5EryzNjgXIUN6C2hj8Att8QbUqi3iEg5GNHlNL2Q3C//\";\n\n await expect(cipher.decrypt(ciphertext)).resolves.toBe(\"openlv peer-key test vector\");\n });\n\n test(\"should reject a ciphertext from a different peer key\", async () => {\n const alice = generateKeyPair();\n const bob = generateKeyPair();\n const mallory = generateKeyPair();\n const fromMallory = await createPeerCipher(mallory, bob.publicKey);\n const expectingMallory = await createPeerCipher(bob, mallory.publicKey);\n const expectingAlice = await createPeerCipher(bob, alice.publicKey);\n const ciphertext = await fromMallory.encrypt(\"test\");\n\n await expect(expectingMallory.decrypt(ciphertext)).resolves.toBe(\"test\");\n await expect(expectingAlice.decrypt(ciphertext)).rejects.toThrow(\"Ciphertext is not from the peer\");\n });\n\n test(\"should reject a ciphertext reflected back to its sender\", async () => {\n const alice = generateKeyPair();\n const bob = generateKeyPair();\n const withBob = await createPeerCipher(alice, bob.publicKey);\n const reflected = fromBase64(await withBob.encrypt(\"test\"));\n\n reflected.set(bob.publicKey, 0);\n\n await expect(withBob.decrypt(toBase64(reflected))).rejects.toThrow();\n });\n});\n"]}
@@ -1,2 +1,2 @@
1
1
  export declare const toBase64: (data: Uint8Array) => string;
2
- export declare const fromBase64: (value: string) => Uint8Array;
2
+ export declare const fromBase64: (value: string) => Uint8Array<ArrayBuffer>;
@@ -5,12 +5,5 @@ export const toBase64 = (data) => {
5
5
  }
6
6
  return btoa(binary);
7
7
  };
8
- export const fromBase64 = (value) => {
9
- const binary = atob(value);
10
- const bytes = new Uint8Array(binary.length);
11
- for (let index = 0; index < binary.length; index += 1) {
12
- bytes[index] = binary.codePointAt(index);
13
- }
14
- return bytes;
15
- };
8
+ export const fromBase64 = (value) => Uint8Array.from(atob(value), char => char.codePointAt(0) ?? 0);
16
9
  //# sourceMappingURL=base64.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"base64.js","sourceRoot":"","sources":["../../src/encryption/base64.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,IAAgB,EAAU,EAAE;IACnD,IAAI,MAAM,GAAG,EAAE,CAAC;IAEhB,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QACzB,MAAM,IAAI,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;IACxC,CAAC;IAED,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;AACtB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,KAAa,EAAc,EAAE;IACtD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;IAC3B,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAE5C,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACtD,KAAK,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC,WAAW,CAAC,KAAK,CAAE,CAAC;IAC5C,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC,CAAC","sourcesContent":["export const toBase64 = (data: Uint8Array): string => {\n let binary = \"\";\n\n for (const datum of data) {\n binary += String.fromCodePoint(datum);\n }\n\n return btoa(binary);\n};\n\nexport const fromBase64 = (value: string): Uint8Array => {\n const binary = atob(value);\n const bytes = new Uint8Array(binary.length);\n\n for (let index = 0; index < binary.length; index += 1) {\n bytes[index] = binary.codePointAt(index)!;\n }\n\n return bytes;\n};\n"]}
1
+ {"version":3,"file":"base64.js","sourceRoot":"","sources":["../../src/encryption/base64.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,IAAgB,EAAU,EAAE;IACnD,IAAI,MAAM,GAAG,EAAE,CAAC;IAEhB,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QACzB,MAAM,IAAI,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;IACxC,CAAC;IAED,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;AACtB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,KAAa,EAA2B,EAAE,CACnE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC","sourcesContent":["export const toBase64 = (data: Uint8Array): string => {\n let binary = \"\";\n\n for (const datum of data) {\n binary += String.fromCodePoint(datum);\n }\n\n return btoa(binary);\n};\n\nexport const fromBase64 = (value: string): Uint8Array<ArrayBuffer> =>\n Uint8Array.from(atob(value), char => char.codePointAt(0) ?? 0);\n"]}
@@ -1,10 +1,3 @@
1
- export type SymmetricKey = {
2
- toString: () => string;
3
- encrypt: (message: string) => Promise<string>;
4
- decrypt: (message: string) => Promise<string>;
5
- };
6
- export declare const deriveSymmetricKey: (k: string) => Promise<SymmetricKey>;
7
- /**
8
- * Generate a 16-byte random shared key and return as hex
9
- */
10
- export declare const generateHandshakeKey: () => Promise<SymmetricKey>;
1
+ import type { Cipher } from "./asymmetric.js";
2
+ export declare const createHandshakeCipher: (k: string) => Promise<Cipher>;
3
+ export declare const generateHandshakeKey: () => string;
@@ -1,40 +1,31 @@
1
- import { bytesToHex, hexToBytes } from "@noble/ciphers/utils.js";
1
+ import { bytesToHex, concatBytes, hexToBytes } from "@noble/ciphers/utils.js";
2
+ import { fromBase64, toBase64 } from "./base64.js";
2
3
  const HANDSHAKE_KEY_BYTES = 16;
3
4
  const IV_BYTES = 12;
4
- export const deriveSymmetricKey = async (k) => {
5
+ export const createHandshakeCipher = async (k) => {
5
6
  const baseKey = Uint8Array.from(hexToBytes(k));
6
7
  if (baseKey.length !== HANDSHAKE_KEY_BYTES) {
7
8
  throw new Error(`Handshake key must be ${HANDSHAKE_KEY_BYTES} bytes, got ${baseKey.length}`);
8
9
  }
9
10
  const sharedSecret = await crypto.subtle.importKey("raw", baseKey, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
10
11
  return {
11
- toString: () => k,
12
12
  encrypt: async (message) => {
13
13
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
14
14
  const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, sharedSecret, new TextEncoder().encode(message));
15
- const combined = new Uint8Array(iv.length + encrypted.byteLength);
16
- combined.set(iv);
17
- combined.set(new Uint8Array(encrypted), iv.length);
18
- return btoa(String.fromCharCode.apply(null, Array.from(combined)));
15
+ return toBase64(concatBytes(iv, new Uint8Array(encrypted)));
19
16
  },
20
- decrypt: async (message) => {
21
- const combined = new Uint8Array(atob(message)
22
- .split("")
23
- .map(char => char.codePointAt(0)));
17
+ decrypt: async (ciphertext) => {
18
+ const combined = fromBase64(ciphertext);
24
19
  const iv = combined.slice(0, IV_BYTES);
25
20
  const encrypted = combined.slice(IV_BYTES);
26
21
  const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, sharedSecret, encrypted);
27
- const decoder = new TextDecoder();
28
- return decoder.decode(decrypted);
22
+ return new TextDecoder().decode(decrypted);
29
23
  },
30
24
  };
31
25
  };
32
- /**
33
- * Generate a 16-byte random shared key and return as hex
34
- */
35
- export const generateHandshakeKey = async () => {
26
+ export const generateHandshakeKey = () => {
36
27
  const array = new Uint8Array(HANDSHAKE_KEY_BYTES);
37
28
  crypto.getRandomValues(array);
38
- return await deriveSymmetricKey(bytesToHex(array));
29
+ return bytesToHex(array);
39
30
  };
40
31
  //# sourceMappingURL=handshake.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"handshake.js","sourceRoot":"","sources":["../../src/encryption/handshake.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAQjE,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAC/B,MAAM,QAAQ,GAAG,EAAE,CAAC;AAEpB,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,EAAE,CAAS,EAAyB,EAAE;IAC3E,MAAM,OAAO,GAAG,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;IAE/C,IAAI,OAAO,CAAC,MAAM,KAAK,mBAAmB,EAAE,CAAC;QAC3C,MAAM,IAAI,KAAK,CACb,yBAAyB,mBAAmB,eAAe,OAAO,CAAC,MAAM,EAAE,CAC5E,CAAC;IACJ,CAAC;IAED,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CAChD,KAAK,EACL,OAAO,EACP,EAAE,IAAI,EAAE,SAAS,EAAE,EACnB,KAAK,EACL,CAAC,SAAS,EAAE,SAAS,CAAC,CACvB,CAAC;IAEF,OAAO;QACL,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC;QACjB,OAAO,EAAE,KAAK,EAAE,OAAe,EAAE,EAAE;YACjC,MAAM,EAAE,GAAG,MAAM,CAAC,eAAe,CAAC,IAAI,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC5D,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,OAAO,CAC3C,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,EACvB,YAAY,EACZ,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAClC,CAAC;YACF,MAAM,QAAQ,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,MAAM,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;YAElE,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YACjB,QAAQ,CAAC,GAAG,CAAC,IAAI,UAAU,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC;YAEnD,OAAO,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;QACrE,CAAC;QACD,OAAO,EAAE,KAAK,EAAE,OAAe,EAAE,EAAE;YACjC,MAAM,QAAQ,GAAG,IAAI,UAAU,CAC7B,IAAI,CAAC,OAAO,CAAC;iBACV,KAAK,CAAC,EAAE,CAAC;iBACT,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAE,CAAC,CACrC,CAAC;YACF,MAAM,EAAE,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;YACvC,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;YAC3C,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,OAAO,CAC3C,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,EACvB,YAAY,EACZ,SAAS,CACV,CAAC;YACF,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;YAElC,OAAO,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACnC,CAAC;KACF,CAAC;AACJ,CAAC,CAAC;AAEF;;GAEG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,KAAK,IAA2B,EAAE;IACpE,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,mBAAmB,CAAC,CAAC;IAElD,MAAM,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;IAE9B,OAAO,MAAM,kBAAkB,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;AACrD,CAAC,CAAC","sourcesContent":["import { bytesToHex, hexToBytes } from \"@noble/ciphers/utils.js\";\n\nexport type SymmetricKey = {\n toString: () => string;\n encrypt: (message: string) => Promise<string>;\n decrypt: (message: string) => Promise<string>;\n};\n\nconst HANDSHAKE_KEY_BYTES = 16;\nconst IV_BYTES = 12;\n\nexport const deriveSymmetricKey = async (k: string): Promise<SymmetricKey> => {\n const baseKey = Uint8Array.from(hexToBytes(k));\n\n if (baseKey.length !== HANDSHAKE_KEY_BYTES) {\n throw new Error(\n `Handshake key must be ${HANDSHAKE_KEY_BYTES} bytes, got ${baseKey.length}`,\n );\n }\n\n const sharedSecret = await crypto.subtle.importKey(\n \"raw\",\n baseKey,\n { name: \"AES-GCM\" },\n false,\n [\"encrypt\", \"decrypt\"],\n );\n\n return {\n toString: () => k,\n encrypt: async (message: string) => {\n const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));\n const encrypted = await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv },\n sharedSecret,\n new TextEncoder().encode(message),\n );\n const combined = new Uint8Array(iv.length + encrypted.byteLength);\n\n combined.set(iv);\n combined.set(new Uint8Array(encrypted), iv.length);\n\n return btoa(String.fromCharCode.apply(null, Array.from(combined)));\n },\n decrypt: async (message: string) => {\n const combined = new Uint8Array(\n atob(message)\n .split(\"\")\n .map(char => char.codePointAt(0)!),\n );\n const iv = combined.slice(0, IV_BYTES);\n const encrypted = combined.slice(IV_BYTES);\n const decrypted = await crypto.subtle.decrypt(\n { name: \"AES-GCM\", iv },\n sharedSecret,\n encrypted,\n );\n const decoder = new TextDecoder();\n\n return decoder.decode(decrypted);\n },\n };\n};\n\n/**\n * Generate a 16-byte random shared key and return as hex\n */\nexport const generateHandshakeKey = async (): Promise<SymmetricKey> => {\n const array = new Uint8Array(HANDSHAKE_KEY_BYTES);\n\n crypto.getRandomValues(array);\n\n return await deriveSymmetricKey(bytesToHex(array));\n};\n"]}
1
+ {"version":3,"file":"handshake.js","sourceRoot":"","sources":["../../src/encryption/handshake.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAG9E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEnD,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAC/B,MAAM,QAAQ,GAAG,EAAE,CAAC;AAEpB,MAAM,CAAC,MAAM,qBAAqB,GAAG,KAAK,EAAE,CAAS,EAAmB,EAAE;IACxE,MAAM,OAAO,GAAG,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;IAE/C,IAAI,OAAO,CAAC,MAAM,KAAK,mBAAmB,EAAE,CAAC;QAC3C,MAAM,IAAI,KAAK,CACb,yBAAyB,mBAAmB,eAAe,OAAO,CAAC,MAAM,EAAE,CAC5E,CAAC;IACJ,CAAC;IAED,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CAChD,KAAK,EACL,OAAO,EACP,EAAE,IAAI,EAAE,SAAS,EAAE,EACnB,KAAK,EACL,CAAC,SAAS,EAAE,SAAS,CAAC,CACvB,CAAC;IAEF,OAAO;QACL,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;YACzB,MAAM,EAAE,GAAG,MAAM,CAAC,eAAe,CAAC,IAAI,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC5D,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,OAAO,CAC3C,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,EACvB,YAAY,EACZ,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAClC,CAAC;YAEF,OAAO,QAAQ,CAAC,WAAW,CAAC,EAAE,EAAE,IAAI,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;QAC9D,CAAC;QACD,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,EAAE;YAC5B,MAAM,QAAQ,GAAG,UAAU,CAAC,UAAU,CAAC,CAAC;YACxC,MAAM,EAAE,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;YACvC,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;YAC3C,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,OAAO,CAC3C,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,EACvB,YAAY,EACZ,SAAS,CACV,CAAC;YAEF,OAAO,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAC7C,CAAC;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAW,EAAE;IAC/C,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,mBAAmB,CAAC,CAAC;IAElD,MAAM,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;IAE9B,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC;AAC3B,CAAC,CAAC","sourcesContent":["import { bytesToHex, concatBytes, hexToBytes } from \"@noble/ciphers/utils.js\";\n\nimport type { Cipher } from \"./asymmetric.js\";\nimport { fromBase64, toBase64 } from \"./base64.js\";\n\nconst HANDSHAKE_KEY_BYTES = 16;\nconst IV_BYTES = 12;\n\nexport const createHandshakeCipher = async (k: string): Promise<Cipher> => {\n const baseKey = Uint8Array.from(hexToBytes(k));\n\n if (baseKey.length !== HANDSHAKE_KEY_BYTES) {\n throw new Error(\n `Handshake key must be ${HANDSHAKE_KEY_BYTES} bytes, got ${baseKey.length}`,\n );\n }\n\n const sharedSecret = await crypto.subtle.importKey(\n \"raw\",\n baseKey,\n { name: \"AES-GCM\" },\n false,\n [\"encrypt\", \"decrypt\"],\n );\n\n return {\n encrypt: async (message) => {\n const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));\n const encrypted = await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv },\n sharedSecret,\n new TextEncoder().encode(message),\n );\n\n return toBase64(concatBytes(iv, new Uint8Array(encrypted)));\n },\n decrypt: async (ciphertext) => {\n const combined = fromBase64(ciphertext);\n const iv = combined.slice(0, IV_BYTES);\n const encrypted = combined.slice(IV_BYTES);\n const decrypted = await crypto.subtle.decrypt(\n { name: \"AES-GCM\", iv },\n sharedSecret,\n encrypted,\n );\n\n return new TextDecoder().decode(decrypted);\n },\n };\n};\n\nexport const generateHandshakeKey = (): string => {\n const array = new Uint8Array(HANDSHAKE_KEY_BYTES);\n\n crypto.getRandomValues(array);\n\n return bytesToHex(array);\n};\n"]}
@@ -1,21 +1,21 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { deriveSymmetricKey, generateHandshakeKey } from "./handshake.js";
2
+ import { createHandshakeCipher, generateHandshakeKey } from "./handshake.js";
3
3
  describe("Handshake", async () => {
4
- const key = await generateHandshakeKey();
4
+ const k = generateHandshakeKey();
5
+ const cipher = await createHandshakeCipher(k);
5
6
  test("should generate a 16-byte random shared key", () => {
6
- expect(key).toBeDefined();
7
- expect(key.toString()).toHaveLength(32);
7
+ expect(k).toHaveLength(32);
8
8
  });
9
9
  test("should encrypt and decrypt a message", async () => {
10
10
  const message = "test";
11
- const encrypted = await key.encrypt(message);
12
- const decrypted = await key.decrypt(encrypted);
11
+ const encrypted = await cipher.encrypt(message);
12
+ const decrypted = await cipher.decrypt(encrypted);
13
13
  expect(decrypted).toBe(message);
14
14
  });
15
15
  test("should derive symmetric key from decoded hex bytes", async () => {
16
- const key = await deriveSymmetricKey("000102030405060708090a0b0c0d0e0f");
16
+ const key = await createHandshakeCipher("000102030405060708090a0b0c0d0e0f");
17
17
  const encrypted = await key.encrypt("hello");
18
- const rederived = await deriveSymmetricKey("000102030405060708090a0b0c0d0e0f");
18
+ const rederived = await createHandshakeCipher("000102030405060708090a0b0c0d0e0f");
19
19
  await expect(rederived.decrypt(encrypted)).resolves.toBe("hello");
20
20
  });
21
21
  });
@@ -1 +1 @@
1
- {"version":3,"file":"handshake.test.js","sourceRoot":"","sources":["../../src/encryption/handshake.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAE1E,QAAQ,CAAC,WAAW,EAAE,KAAK,IAAI,EAAE;IAC/B,MAAM,GAAG,GAAG,MAAM,oBAAoB,EAAE,CAAC;IAEzC,IAAI,CAAC,6CAA6C,EAAE,GAAG,EAAE;QACvD,MAAM,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC;QAC1B,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;IAC1C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,sCAAsC,EAAE,KAAK,IAAI,EAAE;QACtD,MAAM,OAAO,GAAG,MAAM,CAAC;QACvB,MAAM,SAAS,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC7C,MAAM,SAAS,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAE/C,MAAM,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAClC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QACpE,MAAM,GAAG,GAAG,MAAM,kBAAkB,CAAC,kCAAkC,CAAC,CAAC;QACzE,MAAM,SAAS,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC7C,MAAM,SAAS,GAAG,MAAM,kBAAkB,CAAC,kCAAkC,CAAC,CAAC;QAE/E,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACpE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import { describe, expect, test } from \"vitest\";\n\nimport { deriveSymmetricKey, generateHandshakeKey } from \"./handshake.js\";\n\ndescribe(\"Handshake\", async () => {\n const key = await generateHandshakeKey();\n\n test(\"should generate a 16-byte random shared key\", () => {\n expect(key).toBeDefined();\n expect(key.toString()).toHaveLength(32);\n });\n\n test(\"should encrypt and decrypt a message\", async () => {\n const message = \"test\";\n const encrypted = await key.encrypt(message);\n const decrypted = await key.decrypt(encrypted);\n\n expect(decrypted).toBe(message);\n });\n\n test(\"should derive symmetric key from decoded hex bytes\", async () => {\n const key = await deriveSymmetricKey(\"000102030405060708090a0b0c0d0e0f\");\n const encrypted = await key.encrypt(\"hello\");\n const rederived = await deriveSymmetricKey(\"000102030405060708090a0b0c0d0e0f\");\n\n await expect(rederived.decrypt(encrypted)).resolves.toBe(\"hello\");\n });\n});\n"]}
1
+ {"version":3,"file":"handshake.test.js","sourceRoot":"","sources":["../../src/encryption/handshake.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAE7E,QAAQ,CAAC,WAAW,EAAE,KAAK,IAAI,EAAE;IAC/B,MAAM,CAAC,GAAG,oBAAoB,EAAE,CAAC;IACjC,MAAM,MAAM,GAAG,MAAM,qBAAqB,CAAC,CAAC,CAAC,CAAC;IAE9C,IAAI,CAAC,6CAA6C,EAAE,GAAG,EAAE;QACvD,MAAM,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;IAC7B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,sCAAsC,EAAE,KAAK,IAAI,EAAE;QACtD,MAAM,OAAO,GAAG,MAAM,CAAC;QACvB,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAChD,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAElD,MAAM,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAClC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QACpE,MAAM,GAAG,GAAG,MAAM,qBAAqB,CAAC,kCAAkC,CAAC,CAAC;QAC5E,MAAM,SAAS,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC7C,MAAM,SAAS,GAAG,MAAM,qBAAqB,CAAC,kCAAkC,CAAC,CAAC;QAElF,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACpE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import { describe, expect, test } from \"vitest\";\n\nimport { createHandshakeCipher, generateHandshakeKey } from \"./handshake.js\";\n\ndescribe(\"Handshake\", async () => {\n const k = generateHandshakeKey();\n const cipher = await createHandshakeCipher(k);\n\n test(\"should generate a 16-byte random shared key\", () => {\n expect(k).toHaveLength(32);\n });\n\n test(\"should encrypt and decrypt a message\", async () => {\n const message = \"test\";\n const encrypted = await cipher.encrypt(message);\n const decrypted = await cipher.decrypt(encrypted);\n\n expect(decrypted).toBe(message);\n });\n\n test(\"should derive symmetric key from decoded hex bytes\", async () => {\n const key = await createHandshakeCipher(\"000102030405060708090a0b0c0d0e0f\");\n const encrypted = await key.encrypt(\"hello\");\n const rederived = await createHandshakeCipher(\"000102030405060708090a0b0c0d0e0f\");\n\n await expect(rederived.decrypt(encrypted)).resolves.toBe(\"hello\");\n });\n});\n"]}
@@ -1,6 +1,6 @@
1
1
  /** biome-ignore-all lint/performance/noReExportAll: package entrypoint */
2
2
  /** biome-ignore-all lint/performance/noBarrelFile: package entrypoint */
3
3
  export * from "./asymmetric.js";
4
+ export * from "./base64.js";
4
5
  export * from "./handshake.js";
5
- export * from "./hash.js";
6
6
  export * from "./random.js";
@@ -1,7 +1,7 @@
1
1
  /** biome-ignore-all lint/performance/noReExportAll: package entrypoint */
2
2
  /** biome-ignore-all lint/performance/noBarrelFile: package entrypoint */
3
3
  export * from "./asymmetric.js";
4
+ export * from "./base64.js";
4
5
  export * from "./handshake.js";
5
- export * from "./hash.js";
6
6
  export * from "./random.js";
7
7
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/encryption/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,yEAAyE;AAEzE,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC","sourcesContent":["/** biome-ignore-all lint/performance/noReExportAll: package entrypoint */\n/** biome-ignore-all lint/performance/noBarrelFile: package entrypoint */\n\nexport * from \"./asymmetric.js\";\nexport * from \"./handshake.js\";\nexport * from \"./hash.js\";\nexport * from \"./random.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/encryption/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,yEAAyE;AAEzE,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC","sourcesContent":["/** biome-ignore-all lint/performance/noReExportAll: package entrypoint */\n/** biome-ignore-all lint/performance/noBarrelFile: package entrypoint */\n\nexport * from \"./asymmetric.js\";\nexport * from \"./base64.js\";\nexport * from \"./handshake.js\";\nexport * from \"./random.js\";\n"]}
package/dist/index.d.ts CHANGED
@@ -2,5 +2,7 @@
2
2
  /** biome-ignore-all lint/performance/noBarrelFile: package entrypoint */
3
3
  export * from "./log.js";
4
4
  export * from "./make.js";
5
+ export * from "./observable.js";
6
+ export * from "./scope.js";
5
7
  export * from "./session.js";
6
8
  export * from "./url/index.js";
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  /** biome-ignore-all lint/performance/noBarrelFile: package entrypoint */
3
3
  export * from "./log.js";
4
4
  export * from "./make.js";
5
+ export * from "./observable.js";
6
+ export * from "./scope.js";
5
7
  export * from "./session.js";
6
8
  export * from "./url/index.js";
7
9
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,yEAAyE;AAEzE,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC","sourcesContent":["/** biome-ignore-all lint/performance/noReExportAll: package entrypoint */\n/** biome-ignore-all lint/performance/noBarrelFile: package entrypoint */\n\nexport * from \"./log.js\";\nexport * from \"./make.js\";\nexport * from \"./session.js\";\nexport * from \"./url/index.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,yEAAyE;AAEzE,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC","sourcesContent":["/** biome-ignore-all lint/performance/noReExportAll: package entrypoint */\n/** biome-ignore-all lint/performance/noBarrelFile: package entrypoint */\n\nexport * from \"./log.js\";\nexport * from \"./make.js\";\nexport * from \"./observable.js\";\nexport * from \"./scope.js\";\nexport * from \"./session.js\";\nexport * from \"./url/index.js\";\n"]}
@@ -0,0 +1,15 @@
1
+ export type Unsubscribe = () => void;
2
+ export type Observable<T> = {
3
+ get(): T;
4
+ /**
5
+ * Calls the listener synchronously with the current value, then on every
6
+ * change. The replay makes subscribe-then-check patterns race-free and
7
+ * matches what reactive consumers (e.g. Solid's `from`) expect.
8
+ */
9
+ subscribe(listener: (value: T) => void): Unsubscribe;
10
+ /** Resolves with the first value (including the current one) that matches. */
11
+ until(isMatch: (value: T) => boolean): Promise<T>;
12
+ };
13
+ /** Returns false when the value was already equal and no listener ran. */
14
+ export type Setter<T> = (value: T) => boolean;
15
+ export declare const observable: <T>(initial: T) => [Observable<T>, Setter<T>];
@@ -0,0 +1,36 @@
1
+ export const observable = (initial) => {
2
+ let value = initial;
3
+ const listeners = new Set();
4
+ const get = () => value;
5
+ const subscribe = (listener) => {
6
+ listeners.add(listener);
7
+ listener(value);
8
+ return () => listeners.delete(listener);
9
+ };
10
+ const until = (isMatch) => {
11
+ if (isMatch(value))
12
+ return Promise.resolve(value);
13
+ return new Promise((resolve) => {
14
+ const unsubscribe = subscribe((next) => {
15
+ // The replay on subscribe re-delivers the current value; it cannot
16
+ // match, because the fast path above already checked it.
17
+ if (!isMatch(next))
18
+ return;
19
+ resolve(next);
20
+ unsubscribe();
21
+ });
22
+ });
23
+ };
24
+ return [
25
+ { get, subscribe, until },
26
+ (next) => {
27
+ if (value === next)
28
+ return false;
29
+ value = next;
30
+ for (const listener of listeners)
31
+ listener(value);
32
+ return true;
33
+ }
34
+ ];
35
+ };
36
+ //# sourceMappingURL=observable.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observable.js","sourceRoot":"","sources":["../src/observable.ts"],"names":[],"mappings":"AAeA,MAAM,CAAC,MAAM,UAAU,GAAG,CAAI,OAAU,EAA8B,EAAE;IACtE,IAAI,KAAK,GAAG,OAAO,CAAC;IACpB,MAAM,SAAS,GAAG,IAAI,GAAG,EAAsB,CAAC;IAEhD,MAAM,GAAG,GAAG,GAAG,EAAE,CAAC,KAAK,CAAC;IAExB,MAAM,SAAS,GAAG,CAAC,QAA4B,EAAe,EAAE;QAC9D,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACxB,QAAQ,CAAC,KAAK,CAAC,CAAC;QAEhB,OAAO,GAAG,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC1C,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,CAAC,OAA8B,EAAc,EAAE;QAC3D,IAAI,OAAO,CAAC,KAAK,CAAC;YAAE,OAAO,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAElD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;YAC7B,MAAM,WAAW,GAAG,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE;gBACrC,mEAAmE;gBACnE,yDAAyD;gBACzD,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC;oBAAE,OAAO;gBAE3B,OAAO,CAAC,IAAI,CAAC,CAAC;gBACd,WAAW,EAAE,CAAC;YAChB,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC,CAAC;IAEF,OAAO;QACL,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE;QACzB,CAAC,IAAI,EAAE,EAAE;YACP,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;YAEjC,KAAK,GAAG,IAAI,CAAC;YAEb,KAAK,MAAM,QAAQ,IAAI,SAAS;gBAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;YAElD,OAAO,IAAI,CAAC;QACd,CAAC;KAAC,CAAC;AACP,CAAC,CAAC","sourcesContent":["export type Unsubscribe = () => void;\nexport type Observable<T> = {\n get(): T;\n /**\n * Calls the listener synchronously with the current value, then on every\n * change. The replay makes subscribe-then-check patterns race-free and\n * matches what reactive consumers (e.g. Solid's `from`) expect.\n */\n subscribe(listener: (value: T) => void): Unsubscribe;\n /** Resolves with the first value (including the current one) that matches. */\n until(isMatch: (value: T) => boolean): Promise<T>;\n};\n/** Returns false when the value was already equal and no listener ran. */\nexport type Setter<T> = (value: T) => boolean;\n\nexport const observable = <T>(initial: T): [Observable<T>, Setter<T>] => {\n let value = initial;\n const listeners = new Set<(value: T) => void>();\n\n const get = () => value;\n\n const subscribe = (listener: (value: T) => void): Unsubscribe => {\n listeners.add(listener);\n listener(value);\n\n return () => listeners.delete(listener);\n };\n\n const until = (isMatch: (value: T) => boolean): Promise<T> => {\n if (isMatch(value)) return Promise.resolve(value);\n\n return new Promise((resolve) => {\n const unsubscribe = subscribe((next) => {\n // The replay on subscribe re-delivers the current value; it cannot\n // match, because the fast path above already checked it.\n if (!isMatch(next)) return;\n\n resolve(next);\n unsubscribe();\n });\n });\n };\n\n return [\n { get, subscribe, until },\n (next) => {\n if (value === next) return false;\n\n value = next;\n\n for (const listener of listeners) listener(value);\n\n return true;\n }];\n};\n"]}
@@ -0,0 +1,19 @@
1
+ import type { EventEmitter } from "eventemitter3";
2
+ import type { Observable } from "./observable.js";
3
+ export type Cleanup = () => void | Promise<void>;
4
+ /**
5
+ * Owns cleanups, listeners and timers for one lifetime. `close` runs every
6
+ * cleanup once, newest first; a cleanup that throws does not stop the others,
7
+ * and `close` then rejects with all their errors. A closed scope takes no new
8
+ * work: `add` runs the cleanup at once, the rest do nothing.
9
+ */
10
+ export type Scope = {
11
+ add: (cleanup: Cleanup) => void;
12
+ listen: <Events extends EventEmitter.ValidEventTypes, Event extends EventEmitter.EventNames<Events>>(emitter: EventEmitter<Events>, event: Event, listener: EventEmitter.EventListener<Events, Event>) => void;
13
+ timeout: (delayMs: number, run: () => void) => void;
14
+ repeat: (intervalMs: number, run: () => void | Promise<void>) => void;
15
+ child: () => Scope;
16
+ closeWhen: <Value>(observable: Observable<Value>, isMatch: (value: Value) => boolean) => void;
17
+ close: () => Promise<void>;
18
+ };
19
+ export declare const createScope: () => Scope;
package/dist/scope.js ADDED
@@ -0,0 +1,84 @@
1
+ import { createLogger } from "./log.js";
2
+ const log = createLogger("scope");
3
+ // `run` starts before the first await, so the caller sees its synchronous effects at once
4
+ const runLogged = async (run, failure) => {
5
+ try {
6
+ await run();
7
+ }
8
+ catch (error) {
9
+ log(failure, error);
10
+ }
11
+ };
12
+ export const createScope = () => {
13
+ // Newest first, the order `close` runs them in
14
+ const cleanups = [];
15
+ let closing;
16
+ const remove = (cleanup) => {
17
+ const index = cleanups.indexOf(cleanup);
18
+ if (index !== -1)
19
+ cleanups.splice(index, 1);
20
+ };
21
+ const add = (cleanup) => {
22
+ if (closing) {
23
+ void runLogged(cleanup, "cleanup failed");
24
+ return;
25
+ }
26
+ cleanups.unshift(cleanup);
27
+ };
28
+ const close = () => {
29
+ closing ?? (closing = (async () => {
30
+ const pending = [...cleanups];
31
+ cleanups.length = 0;
32
+ const results = await Promise.allSettled(pending.map(async (cleanup) => cleanup()));
33
+ const errors = results.flatMap(result => (result.status === "rejected" ? [result.reason] : []));
34
+ if (errors.length > 0)
35
+ throw new AggregateError(errors, "Failed to close scope");
36
+ })());
37
+ return closing;
38
+ };
39
+ return {
40
+ add,
41
+ listen: (emitter, event, listener) => {
42
+ if (closing)
43
+ return;
44
+ emitter.on(event, listener);
45
+ add(() => {
46
+ emitter.off(event, listener);
47
+ });
48
+ },
49
+ timeout: (delayMs, run) => {
50
+ if (closing)
51
+ return;
52
+ const cancel = () => clearTimeout(timer);
53
+ const timer = setTimeout(() => {
54
+ remove(cancel);
55
+ run();
56
+ }, delayMs);
57
+ add(cancel);
58
+ },
59
+ repeat: (intervalMs, run) => {
60
+ if (closing)
61
+ return;
62
+ const timer = setInterval(() => void runLogged(run, "repeated run failed"), intervalMs);
63
+ add(() => clearInterval(timer));
64
+ void runLogged(run, "repeated run failed");
65
+ },
66
+ child: () => {
67
+ const child = createScope();
68
+ add(child.close);
69
+ // A child that closes on its own leaves its parent
70
+ child.add(() => remove(child.close));
71
+ return child;
72
+ },
73
+ closeWhen: (observable, isMatch) => {
74
+ if (closing)
75
+ return;
76
+ add(observable.subscribe((value) => {
77
+ if (isMatch(value))
78
+ void runLogged(close, "close failed");
79
+ }));
80
+ },
81
+ close,
82
+ };
83
+ };
84
+ //# sourceMappingURL=scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope.js","sourceRoot":"","sources":["../src/scope.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAGxC,MAAM,GAAG,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC;AA+BlC,0FAA0F;AAC1F,MAAM,SAAS,GAAG,KAAK,EAAE,GAA+B,EAAE,OAAe,EAAE,EAAE;IAC3E,IAAI,CAAC;QACH,MAAM,GAAG,EAAE,CAAC;IACd,CAAC;IACD,OAAO,KAAK,EAAE,CAAC;QACb,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IACtB,CAAC;AACH,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,WAAW,GAAG,GAAU,EAAE;IACrC,+CAA+C;IAC/C,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,IAAI,OAAkC,CAAC;IAEvC,MAAM,MAAM,GAAG,CAAC,OAAgB,EAAE,EAAE;QAClC,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAExC,IAAI,KAAK,KAAK,CAAC,CAAC;YAAE,QAAQ,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAC9C,CAAC,CAAC;IAEF,MAAM,GAAG,GAAG,CAAC,OAAgB,EAAE,EAAE;QAC/B,IAAI,OAAO,EAAE,CAAC;YACZ,KAAK,SAAS,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;YAE1C,OAAO;QACT,CAAC;QAED,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5B,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,GAAG,EAAE;QACjB,OAAO,KAAP,OAAO,GAAK,CAAC,KAAK,IAAI,EAAE;YACtB,MAAM,OAAO,GAAG,CAAC,GAAG,QAAQ,CAAC,CAAC;YAE9B,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC;YACpB,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAC,OAAO,EAAC,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;YAClF,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YAEhG,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;gBAAE,MAAM,IAAI,cAAc,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;QACnF,CAAC,CAAC,EAAE,EAAC;QAEL,OAAO,OAAO,CAAC;IACjB,CAAC,CAAC;IAEF,OAAO;QACL,GAAG;QACH,MAAM,EAAE,CAAC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE;YACnC,IAAI,OAAO;gBAAE,OAAO;YAEpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;YAC5B,GAAG,CAAC,GAAG,EAAE;gBACP,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;YAC/B,CAAC,CAAC,CAAC;QACL,CAAC;QACD,OAAO,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE;YACxB,IAAI,OAAO;gBAAE,OAAO;YAEpB,MAAM,MAAM,GAAG,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;YACzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBAC5B,MAAM,CAAC,MAAM,CAAC,CAAC;gBACf,GAAG,EAAE,CAAC;YACR,CAAC,EAAE,OAAO,CAAC,CAAC;YAEZ,GAAG,CAAC,MAAM,CAAC,CAAC;QACd,CAAC;QACD,MAAM,EAAE,CAAC,UAAU,EAAE,GAAG,EAAE,EAAE;YAC1B,IAAI,OAAO;gBAAE,OAAO;YAEpB,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,KAAK,SAAS,CAAC,GAAG,EAAE,qBAAqB,CAAC,EAAE,UAAU,CAAC,CAAC;YAExF,GAAG,CAAC,GAAG,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;YAChC,KAAK,SAAS,CAAC,GAAG,EAAE,qBAAqB,CAAC,CAAC;QAC7C,CAAC;QACD,KAAK,EAAE,GAAG,EAAE;YACV,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;YAE5B,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YACjB,mDAAmD;YACnD,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;YAErC,OAAO,KAAK,CAAC;QACf,CAAC;QACD,SAAS,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,EAAE;YACjC,IAAI,OAAO;gBAAE,OAAO;YAEpB,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE;gBACjC,IAAI,OAAO,CAAC,KAAK,CAAC;oBAAE,KAAK,SAAS,CAAC,KAAK,EAAE,cAAc,CAAC,CAAC;YAC5D,CAAC,CAAC,CAAC,CAAC;QACN,CAAC;QACD,KAAK;KACN,CAAC;AACJ,CAAC,CAAC","sourcesContent":["import type { EventEmitter } from \"eventemitter3\";\n\nimport { createLogger } from \"./log.js\";\nimport type { Observable } from \"./observable.js\";\n\nconst log = createLogger(\"scope\");\n\nexport type Cleanup = () => void | Promise<void>;\n\n/**\n * Owns cleanups, listeners and timers for one lifetime. `close` runs every\n * cleanup once, newest first; a cleanup that throws does not stop the others,\n * and `close` then rejects with all their errors. A closed scope takes no new\n * work: `add` runs the cleanup at once, the rest do nothing.\n */\nexport type Scope = {\n add: (cleanup: Cleanup) => void;\n listen: <\n Events extends EventEmitter.ValidEventTypes,\n Event extends EventEmitter.EventNames<Events>,\n >(\n emitter: EventEmitter<Events>,\n event: Event,\n listener: EventEmitter.EventListener<Events, Event>,\n ) => void;\n // Runs `run` once after `delayMs`, unless the scope closes first\n timeout: (delayMs: number, run: () => void) => void;\n // Runs `run` now, then every `intervalMs`; a failed run is logged and the next one tries again\n repeat: (intervalMs: number, run: () => void | Promise<void>) => void;\n // A scope with a shorter lifetime, closed with this one at the latest\n child: () => Scope;\n // Closes this scope once `observable` holds a value that `isMatch` accepts, the current one included\n closeWhen: <Value>(observable: Observable<Value>, isMatch: (value: Value) => boolean) => void;\n close: () => Promise<void>;\n};\n\n// `run` starts before the first await, so the caller sees its synchronous effects at once\nconst runLogged = async (run: () => void | Promise<void>, failure: string) => {\n try {\n await run();\n }\n catch (error) {\n log(failure, error);\n }\n};\n\nexport const createScope = (): Scope => {\n // Newest first, the order `close` runs them in\n const cleanups: Cleanup[] = [];\n let closing: Promise<void> | undefined;\n\n const remove = (cleanup: Cleanup) => {\n const index = cleanups.indexOf(cleanup);\n\n if (index !== -1) cleanups.splice(index, 1);\n };\n\n const add = (cleanup: Cleanup) => {\n if (closing) {\n void runLogged(cleanup, \"cleanup failed\");\n\n return;\n }\n\n cleanups.unshift(cleanup);\n };\n\n const close = () => {\n closing ??= (async () => {\n const pending = [...cleanups];\n\n cleanups.length = 0;\n const results = await Promise.allSettled(pending.map(async cleanup => cleanup()));\n const errors = results.flatMap(result => (result.status === \"rejected\" ? [result.reason] : []));\n\n if (errors.length > 0) throw new AggregateError(errors, \"Failed to close scope\");\n })();\n\n return closing;\n };\n\n return {\n add,\n listen: (emitter, event, listener) => {\n if (closing) return;\n\n emitter.on(event, listener);\n add(() => {\n emitter.off(event, listener);\n });\n },\n timeout: (delayMs, run) => {\n if (closing) return;\n\n const cancel = () => clearTimeout(timer);\n const timer = setTimeout(() => {\n remove(cancel);\n run();\n }, delayMs);\n\n add(cancel);\n },\n repeat: (intervalMs, run) => {\n if (closing) return;\n\n const timer = setInterval(() => void runLogged(run, \"repeated run failed\"), intervalMs);\n\n add(() => clearInterval(timer));\n void runLogged(run, \"repeated run failed\");\n },\n child: () => {\n const child = createScope();\n\n add(child.close);\n // A child that closes on its own leaves its parent\n child.add(() => remove(child.close));\n\n return child;\n },\n closeWhen: (observable, isMatch) => {\n if (closing) return;\n\n add(observable.subscribe((value) => {\n if (isMatch(value)) void runLogged(close, \"close failed\");\n }));\n },\n close,\n };\n};\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ import { EventEmitter } from "eventemitter3";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { observable } from "./observable.js";
4
+ import { createScope } from "./scope.js";
5
+ describe("scope", () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+ it("runs cleanups newest first, once", async () => {
13
+ const scope = createScope();
14
+ const order = [];
15
+ scope.add(() => {
16
+ order.push("first");
17
+ });
18
+ scope.add(async () => {
19
+ order.push("second");
20
+ });
21
+ await Promise.all([scope.close(), scope.close()]);
22
+ await scope.close();
23
+ expect(order).toEqual(["second", "first"]);
24
+ });
25
+ it("runs every cleanup when one throws, then rejects", async () => {
26
+ const scope = createScope();
27
+ const order = [];
28
+ scope.add(() => {
29
+ order.push("first");
30
+ });
31
+ scope.add(() => {
32
+ throw new Error("boom");
33
+ });
34
+ scope.add(() => {
35
+ order.push("third");
36
+ });
37
+ await expect(scope.close()).rejects.toBeInstanceOf(AggregateError);
38
+ expect(order).toEqual(["third", "first"]);
39
+ });
40
+ it("runs a cleanup at once when it is added after close", async () => {
41
+ const scope = createScope();
42
+ const cleanup = vi.fn();
43
+ await scope.close();
44
+ scope.add(cleanup);
45
+ expect(cleanup).toHaveBeenCalledOnce();
46
+ });
47
+ it("cancels a timeout on close", async () => {
48
+ const scope = createScope();
49
+ const fired = vi.fn();
50
+ scope.timeout(1000, fired);
51
+ await vi.advanceTimersByTimeAsync(999);
52
+ await scope.close();
53
+ await vi.advanceTimersByTimeAsync(10000);
54
+ expect(fired).not.toHaveBeenCalled();
55
+ });
56
+ it("fires a timeout once after its delay", async () => {
57
+ const scope = createScope();
58
+ const fired = vi.fn();
59
+ scope.timeout(1000, fired);
60
+ await vi.advanceTimersByTimeAsync(999);
61
+ expect(fired).not.toHaveBeenCalled();
62
+ await vi.advanceTimersByTimeAsync(1);
63
+ await vi.advanceTimersByTimeAsync(10000);
64
+ expect(fired).toHaveBeenCalledOnce();
65
+ });
66
+ it("repeats from now, keeps going after a failed run, and stops on close", async () => {
67
+ const scope = createScope();
68
+ let runs = 0;
69
+ scope.repeat(2000, () => {
70
+ runs += 1;
71
+ if (runs === 2)
72
+ throw new Error("lost");
73
+ });
74
+ expect(runs).toBe(1);
75
+ await vi.advanceTimersByTimeAsync(6000);
76
+ expect(runs).toBe(4);
77
+ await scope.close();
78
+ await vi.advanceTimersByTimeAsync(10000);
79
+ expect(runs).toBe(4);
80
+ });
81
+ it("starts no timer on a closed scope", async () => {
82
+ const scope = createScope();
83
+ const run = vi.fn();
84
+ await scope.close();
85
+ scope.timeout(10, run);
86
+ scope.repeat(10, run);
87
+ await vi.advanceTimersByTimeAsync(1000);
88
+ expect(run).not.toHaveBeenCalled();
89
+ });
90
+ it("removes a listener on close", async () => {
91
+ const scope = createScope();
92
+ const emitter = new EventEmitter();
93
+ const listener = vi.fn();
94
+ scope.listen(emitter, "ping", listener);
95
+ emitter.emit("ping");
96
+ await scope.close();
97
+ emitter.emit("ping");
98
+ expect(listener).toHaveBeenCalledOnce();
99
+ });
100
+ it("closes a child with its parent, and a closed child leaves its parent open", async () => {
101
+ const parent = createScope();
102
+ const first = parent.child();
103
+ const second = parent.child();
104
+ const parentTimer = vi.fn();
105
+ const secondTimer = vi.fn();
106
+ parent.timeout(1000, parentTimer);
107
+ second.timeout(1000, secondTimer);
108
+ await first.close();
109
+ await parent.close();
110
+ await vi.advanceTimersByTimeAsync(1000);
111
+ expect(parentTimer).not.toHaveBeenCalled();
112
+ expect(secondTimer).not.toHaveBeenCalled();
113
+ });
114
+ it("gives a closed child from a closed parent", async () => {
115
+ const parent = createScope();
116
+ const run = vi.fn();
117
+ await parent.close();
118
+ parent.child().timeout(10, run);
119
+ await vi.advanceTimersByTimeAsync(100);
120
+ expect(run).not.toHaveBeenCalled();
121
+ });
122
+ it("closes when an observable reaches a value, the current one included", async () => {
123
+ const [state, setState] = observable("idle");
124
+ const later = createScope();
125
+ const now = createScope();
126
+ const run = vi.fn();
127
+ later.closeWhen(state, value => value === "done");
128
+ later.repeat(1000, run);
129
+ setState("busy");
130
+ await vi.advanceTimersByTimeAsync(1000);
131
+ setState("done");
132
+ await vi.advanceTimersByTimeAsync(5000);
133
+ expect(run).toHaveBeenCalledTimes(2);
134
+ now.closeWhen(state, value => value === "done");
135
+ now.timeout(10, run);
136
+ await vi.advanceTimersByTimeAsync(100);
137
+ expect(run).toHaveBeenCalledTimes(2);
138
+ });
139
+ });
140
+ //# sourceMappingURL=scope.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope.test.js","sourceRoot":"","sources":["../src/scope.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAEzE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEzC,QAAQ,CAAC,OAAO,EAAE,GAAG,EAAE;IACrB,UAAU,CAAC,GAAG,EAAE;QACd,EAAE,CAAC,aAAa,EAAE,CAAC;IACrB,CAAC,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,EAAE;QACb,EAAE,CAAC,aAAa,EAAE,CAAC;IACrB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kCAAkC,EAAE,KAAK,IAAI,EAAE;QAChD,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,MAAM,KAAK,GAAa,EAAE,CAAC;QAE3B,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE;YACb,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACtB,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE;YACnB,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACvB,CAAC,CAAC,CAAC;QAEH,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QAClD,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;QAEpB,MAAM,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kDAAkD,EAAE,KAAK,IAAI,EAAE;QAChE,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,MAAM,KAAK,GAAa,EAAE,CAAC;QAE3B,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE;YACb,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACtB,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE;YACb,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC;QAC1B,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE;YACb,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACtB,CAAC,CAAC,CAAC;QAEH,MAAM,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,cAAc,CAAC,cAAc,CAAC,CAAC;QACnE,MAAM,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qDAAqD,EAAE,KAAK,IAAI,EAAE;QACnE,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,MAAM,OAAO,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAExB,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;QACpB,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAEnB,MAAM,CAAC,OAAO,CAAC,CAAC,oBAAoB,EAAE,CAAC;IACzC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4BAA4B,EAAE,KAAK,IAAI,EAAE;QAC1C,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,MAAM,KAAK,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAEtB,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC3B,MAAM,EAAE,CAAC,wBAAwB,CAAC,GAAG,CAAC,CAAC;QACvC,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;QACpB,MAAM,EAAE,CAAC,wBAAwB,CAAC,KAAM,CAAC,CAAC;QAE1C,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,KAAK,IAAI,EAAE;QACpD,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,MAAM,KAAK,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAEtB,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC3B,MAAM,EAAE,CAAC,wBAAwB,CAAC,GAAG,CAAC,CAAC;QACvC,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC;QAErC,MAAM,EAAE,CAAC,wBAAwB,CAAC,CAAC,CAAC,CAAC;QACrC,MAAM,EAAE,CAAC,wBAAwB,CAAC,KAAM,CAAC,CAAC;QAC1C,MAAM,CAAC,KAAK,CAAC,CAAC,oBAAoB,EAAE,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;QACpF,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,IAAI,IAAI,GAAG,CAAC,CAAC;QAEb,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;YACtB,IAAI,IAAI,CAAC,CAAC;YAEV,IAAI,IAAI,KAAK,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC;QAC1C,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAErB,MAAM,EAAE,CAAC,wBAAwB,CAAC,IAAI,CAAC,CAAC;QACxC,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAErB,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;QACpB,MAAM,EAAE,CAAC,wBAAwB,CAAC,KAAM,CAAC,CAAC;QAC1C,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACvB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mCAAmC,EAAE,KAAK,IAAI,EAAE;QACjD,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAEpB,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;QACpB,KAAK,CAAC,OAAO,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;QACvB,KAAK,CAAC,MAAM,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;QACtB,MAAM,EAAE,CAAC,wBAAwB,CAAC,IAAI,CAAC,CAAC;QAExC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC;IACrC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,6BAA6B,EAAE,KAAK,IAAI,EAAE;QAC3C,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,MAAM,OAAO,GAAG,IAAI,YAAY,EAAyB,CAAC;QAC1D,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAEzB,KAAK,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC;QACxC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACrB,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;QACpB,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAErB,MAAM,CAAC,QAAQ,CAAC,CAAC,oBAAoB,EAAE,CAAC;IAC1C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2EAA2E,EAAE,KAAK,IAAI,EAAE;QACzF,MAAM,MAAM,GAAG,WAAW,EAAE,CAAC;QAC7B,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,EAAE,CAAC;QAC7B,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,CAAC;QAC9B,MAAM,WAAW,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5B,MAAM,WAAW,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAE5B,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;QAClC,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;QAClC,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;QACrB,MAAM,EAAE,CAAC,wBAAwB,CAAC,IAAI,CAAC,CAAC;QAExC,MAAM,CAAC,WAAW,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC;QAC3C,MAAM,CAAC,WAAW,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,KAAK,IAAI,EAAE;QACzD,MAAM,MAAM,GAAG,WAAW,EAAE,CAAC;QAC7B,MAAM,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAEpB,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;QACrB,MAAM,CAAC,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;QAChC,MAAM,EAAE,CAAC,wBAAwB,CAAC,GAAG,CAAC,CAAC;QAEvC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC;IACrC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qEAAqE,EAAE,KAAK,IAAI,EAAE;QACnF,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC;QAC7C,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,WAAW,EAAE,CAAC;QAC1B,MAAM,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAEpB,KAAK,CAAC,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC,KAAK,KAAK,MAAM,CAAC,CAAC;QAClD,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QACxB,QAAQ,CAAC,MAAM,CAAC,CAAC;QACjB,MAAM,EAAE,CAAC,wBAAwB,CAAC,IAAI,CAAC,CAAC;QACxC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACjB,MAAM,EAAE,CAAC,wBAAwB,CAAC,IAAI,CAAC,CAAC;QACxC,MAAM,CAAC,GAAG,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;QAErC,GAAG,CAAC,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC,KAAK,KAAK,MAAM,CAAC,CAAC;QAChD,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;QACrB,MAAM,EAAE,CAAC,wBAAwB,CAAC,GAAG,CAAC,CAAC;QACvC,MAAM,CAAC,GAAG,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import { EventEmitter } from \"eventemitter3\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { observable } from \"./observable.js\";\nimport { createScope } from \"./scope.js\";\n\ndescribe(\"scope\", () => {\n beforeEach(() => {\n vi.useFakeTimers();\n });\n\n afterEach(() => {\n vi.useRealTimers();\n });\n\n it(\"runs cleanups newest first, once\", async () => {\n const scope = createScope();\n const order: string[] = [];\n\n scope.add(() => {\n order.push(\"first\");\n });\n scope.add(async () => {\n order.push(\"second\");\n });\n\n await Promise.all([scope.close(), scope.close()]);\n await scope.close();\n\n expect(order).toEqual([\"second\", \"first\"]);\n });\n\n it(\"runs every cleanup when one throws, then rejects\", async () => {\n const scope = createScope();\n const order: string[] = [];\n\n scope.add(() => {\n order.push(\"first\");\n });\n scope.add(() => {\n throw new Error(\"boom\");\n });\n scope.add(() => {\n order.push(\"third\");\n });\n\n await expect(scope.close()).rejects.toBeInstanceOf(AggregateError);\n expect(order).toEqual([\"third\", \"first\"]);\n });\n\n it(\"runs a cleanup at once when it is added after close\", async () => {\n const scope = createScope();\n const cleanup = vi.fn();\n\n await scope.close();\n scope.add(cleanup);\n\n expect(cleanup).toHaveBeenCalledOnce();\n });\n\n it(\"cancels a timeout on close\", async () => {\n const scope = createScope();\n const fired = vi.fn();\n\n scope.timeout(1000, fired);\n await vi.advanceTimersByTimeAsync(999);\n await scope.close();\n await vi.advanceTimersByTimeAsync(10_000);\n\n expect(fired).not.toHaveBeenCalled();\n });\n\n it(\"fires a timeout once after its delay\", async () => {\n const scope = createScope();\n const fired = vi.fn();\n\n scope.timeout(1000, fired);\n await vi.advanceTimersByTimeAsync(999);\n expect(fired).not.toHaveBeenCalled();\n\n await vi.advanceTimersByTimeAsync(1);\n await vi.advanceTimersByTimeAsync(10_000);\n expect(fired).toHaveBeenCalledOnce();\n });\n\n it(\"repeats from now, keeps going after a failed run, and stops on close\", async () => {\n const scope = createScope();\n let runs = 0;\n\n scope.repeat(2000, () => {\n runs += 1;\n\n if (runs === 2) throw new Error(\"lost\");\n });\n expect(runs).toBe(1);\n\n await vi.advanceTimersByTimeAsync(6000);\n expect(runs).toBe(4);\n\n await scope.close();\n await vi.advanceTimersByTimeAsync(10_000);\n expect(runs).toBe(4);\n });\n\n it(\"starts no timer on a closed scope\", async () => {\n const scope = createScope();\n const run = vi.fn();\n\n await scope.close();\n scope.timeout(10, run);\n scope.repeat(10, run);\n await vi.advanceTimersByTimeAsync(1000);\n\n expect(run).not.toHaveBeenCalled();\n });\n\n it(\"removes a listener on close\", async () => {\n const scope = createScope();\n const emitter = new EventEmitter<{ ping: () => void; }>();\n const listener = vi.fn();\n\n scope.listen(emitter, \"ping\", listener);\n emitter.emit(\"ping\");\n await scope.close();\n emitter.emit(\"ping\");\n\n expect(listener).toHaveBeenCalledOnce();\n });\n\n it(\"closes a child with its parent, and a closed child leaves its parent open\", async () => {\n const parent = createScope();\n const first = parent.child();\n const second = parent.child();\n const parentTimer = vi.fn();\n const secondTimer = vi.fn();\n\n parent.timeout(1000, parentTimer);\n second.timeout(1000, secondTimer);\n await first.close();\n await parent.close();\n await vi.advanceTimersByTimeAsync(1000);\n\n expect(parentTimer).not.toHaveBeenCalled();\n expect(secondTimer).not.toHaveBeenCalled();\n });\n\n it(\"gives a closed child from a closed parent\", async () => {\n const parent = createScope();\n const run = vi.fn();\n\n await parent.close();\n parent.child().timeout(10, run);\n await vi.advanceTimersByTimeAsync(100);\n\n expect(run).not.toHaveBeenCalled();\n });\n\n it(\"closes when an observable reaches a value, the current one included\", async () => {\n const [state, setState] = observable(\"idle\");\n const later = createScope();\n const now = createScope();\n const run = vi.fn();\n\n later.closeWhen(state, value => value === \"done\");\n later.repeat(1000, run);\n setState(\"busy\");\n await vi.advanceTimersByTimeAsync(1000);\n setState(\"done\");\n await vi.advanceTimersByTimeAsync(5000);\n expect(run).toHaveBeenCalledTimes(2);\n\n now.closeWhen(state, value => value === \"done\");\n now.timeout(10, run);\n await vi.advanceTimersByTimeAsync(100);\n expect(run).toHaveBeenCalledTimes(2);\n });\n});\n"]}
package/dist/session.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DecryptionKey, EncryptionKey } from "./encryption/asymmetric.js";
1
+ import type { KeyPair, PublicKey } from "./encryption/asymmetric.js";
2
2
  export type Hex<_L> = string;
3
3
  /**
4
4
  * The parameters from the connection url
@@ -11,14 +11,12 @@ export type SessionHandshakeParameters = {
11
11
  p: string;
12
12
  s: string;
13
13
  };
14
+ export type SessionCreationParameters = Pick<SessionHandshakeParameters, "p" | "s">;
14
15
  /**
15
- * The parameters that describe a session
16
- * these can be used to restart a session
16
+ * The parameters that describe a session these can be used to restart a session
17
17
  */
18
18
  export type SessionConnectionParameters = SessionHandshakeParameters & {
19
- relyingPublicKey: EncryptionKey;
20
- encryptionKey: EncryptionKey;
21
- decryptionKey: DecryptionKey;
19
+ keyPair: KeyPair;
20
+ peerKey: PublicKey;
22
21
  };
23
- export type SessionCreationParameters = Pick<SessionHandshakeParameters, "p" | "s">;
24
22
  export type SessionLinkParameters = SessionConnectionParameters | SessionHandshakeParameters | SessionCreationParameters;
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"","sourcesContent":["import type { DecryptionKey, EncryptionKey } from \"./encryption/asymmetric.js\";\n\n// typescript type for 32 character hex string\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport type Hex<_L> = string;\n\n/**\n * The parameters from the connection url\n */\nexport type SessionHandshakeParameters = {\n // Version of the openlv protocol\n version: 1;\n // Unique session identifier\n sessionId: Hex<16>;\n // Hash of peerA's public key\n h: Hex<16>;\n // Shared key for symmetric encryption during handshake (32 hex chars = 16 bytes)\n k: Hex<32>;\n // Protocol for signaling\n p: string;\n // Signaling server URL\n s: string;\n};\n\n/**\n * The parameters that describe a session\n * these can be used to restart a session\n */\nexport type SessionConnectionParameters = SessionHandshakeParameters & {\n relyingPublicKey: EncryptionKey;\n // Our public key for the relying party to encrypt messages to us\n encryptionKey: EncryptionKey;\n // Our private key for decrypting messages from the relying party\n decryptionKey: DecryptionKey;\n};\n\nexport type SessionCreationParameters = Pick<\n SessionHandshakeParameters,\n \"p\" | \"s\"\n>;\n\nexport type SessionLinkParameters\n = | SessionConnectionParameters\n | SessionHandshakeParameters\n | SessionCreationParameters;\n"]}
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"","sourcesContent":["import type { KeyPair, PublicKey } from \"./encryption/asymmetric.js\";\n\n// typescript type for 32 character hex string\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport type Hex<_L> = string;\n\n/**\n * The parameters from the connection url\n */\nexport type SessionHandshakeParameters = {\n // Version of the openlv protocol\n version: 1;\n // Unique session identifier\n sessionId: Hex<16>;\n // Hash of peerA's public key\n h: Hex<16>;\n // Shared key for symmetric encryption during handshake (32 hex chars = 16 bytes)\n k: Hex<32>;\n // Protocol for signaling\n p: string;\n // Signaling server URL\n s: string;\n};\n\nexport type SessionCreationParameters = Pick<\n SessionHandshakeParameters,\n \"p\" | \"s\"\n>;\n\n/**\n * The parameters that describe a session these can be used to restart a session\n */\nexport type SessionConnectionParameters = SessionHandshakeParameters & {\n keyPair: KeyPair;\n peerKey: PublicKey;\n};\n\nexport type SessionLinkParameters\n = | SessionConnectionParameters\n | SessionHandshakeParameters\n | SessionCreationParameters;\n"]}
package/dist/url/index.js CHANGED
@@ -28,7 +28,7 @@ export const decodeConnectionURL = (url) => {
28
28
  const h = urlObject.searchParams.get("h") || "";
29
29
  const k = urlObject.searchParams.get("k") || "";
30
30
  const s = urlObject.searchParams.get("s") || undefined;
31
- const p = urlObject.searchParams.get("p") || "mqtt";
31
+ const p = urlObject.searchParams.get("p") || "";
32
32
  if (!sessionId) {
33
33
  throw new Error("Session ID is required in URL");
34
34
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/url/index.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC;AAEzC,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,OAAmC,EAAE,EAAE;IACzE,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IAEzC,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAC/B,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAE/B,IAAI,OAAO,CAAC,CAAC,EAAE,CAAC;QACd,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACjC,CAAC;IAED,IAAI,OAAO,CAAC,CAAC,EAAE,CAAC;QACd,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACjC,CAAC;IAED,OAAO,YAAY,OAAO,CAAC,SAAS,IAAI,uBAAuB,IAAI,UAAU,CAAC,QAAQ,EAAE,EAAE,CAAC;AAC7F,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CACjC,GAAW,EACiB,EAAE;IAC9B,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,MAAM,IAAI,SAAS,CAAC,0CAA0C,OAAO,GAAG,EAAE,CAAC,CAAC;IAEzG,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;IAEtE,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,WAAW,CAAC;QAAE,MAAM,IAAI,KAAK,CAC/C,yDAAyD,GAAG,EAAE,CAC/D,CAAC;IAEF,IAAI,CAAC;QACH,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QAC/B,MAAM,SAAS,GAAG,SAAS,CAAC,QAAQ,CAAC;QACrC,MAAM,OAAO,GAAG,MAAM,CAAC,SAAS,CAAC,QAAQ,IAAI,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC;QAElF,IAAI,OAAO,KAAK,uBAAuB,EAAE,CAAC;YACxC,MAAM,IAAI,KAAK,CACb,sCAAsC,uBAAuB,SAAS,OAAO,EAAE,CAChF,CAAC;QACJ,CAAC;QAED,MAAM,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QAChD,MAAM,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QAChD,MAAM,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,SAAS,CAAC;QAEvD,MAAM,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC;QAEpD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CAAC,+BAA+B,CAAC,CAAC;QACnD,CAAC;QAED,gEAAgE;QAChE,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YAC3C,MAAM,IAAI,KAAK,CACb,2DAA2D,CAC5D,CAAC;QACJ,CAAC;QAED,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC;QACtE,CAAC;QAED,sDAAsD;QACtD,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CACb,2DAA2D,CAC5D,CAAC;QACJ,CAAC;QAED,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;QACjE,CAAC;QAED,iDAAiD;QACjD,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CAAC,gEAAgE,CAAC,CAAC;QACpF,CAAC;QAED,OAAO;YACL,OAAO;YACP,SAAS;YACT,CAAC;YACD,CAAC;YACD,CAAC,EAAE,CAAC,IAAI,EAAE;YACV,CAAC;SACF,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,EAAE,CAAC;QACb,MAAM,YAAY,GACd,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,CAAC;QAE7D,gFAAgF;QAChF,MAAM,OAAO,GAAG,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC;QAEzF,MAAM,IAAI,KAAK,CAAC,wBAAwB,OAAO,qBAAqB,YAAY,EAAE,CAAC,CAAC;IACtF,CAAC;AACH,CAAC,CAAC","sourcesContent":["import type { SessionHandshakeParameters } from \"../session.js\";\n\nexport const OPENLV_PROTOCOL_VERSION = 1;\n\nexport const encodeConnectionURL = (payload: SessionHandshakeParameters) => {\n const parameters = new URLSearchParams();\n\n parameters.set(\"h\", payload.h);\n parameters.set(\"k\", payload.k);\n\n if (payload.p) {\n parameters.set(\"p\", payload.p);\n }\n\n if (payload.s) {\n parameters.set(\"s\", payload.s);\n }\n\n return `openlv://${payload.sessionId}@${OPENLV_PROTOCOL_VERSION}?${parameters.toString()}`;\n};\n\nexport const decodeConnectionURL = (\n url: string,\n): SessionHandshakeParameters => {\n if (typeof url !== \"string\") throw new TypeError(`Invalid URL type: expected string, got ${typeof url}`);\n\n if (!url || url.trim() === \"\") throw new Error(\"URL cannot be empty\");\n\n if (!url.startsWith(\"openlv://\")) throw new Error(\n `Invalid URL format: must start with 'openlv://', got: ${url}`,\n );\n\n try {\n const urlObject = new URL(url);\n const sessionId = urlObject.username;\n const version = Number(urlObject.hostname || urlObject.pathname.replace(\"/\", \"\"));\n\n if (version !== OPENLV_PROTOCOL_VERSION) {\n throw new Error(\n `Invalid protocol version: expected ${OPENLV_PROTOCOL_VERSION}, got ${version}`,\n );\n }\n\n const h = urlObject.searchParams.get(\"h\") || \"\";\n const k = urlObject.searchParams.get(\"k\") || \"\";\n const s = urlObject.searchParams.get(\"s\") || undefined;\n\n const p = urlObject.searchParams.get(\"p\") || \"mqtt\";\n\n if (!sessionId) {\n throw new Error(\"Session ID is required in URL\");\n }\n\n // Validate session ID format (16 characters, URL-safe alphabet)\n if (!/^[A-Za-z0-9_-]{16}$/.test(sessionId)) {\n throw new Error(\n \"Invalid session ID format: must be 16 URL-safe characters\",\n );\n }\n\n if (!h) {\n throw new Error(\"Public key hash (h parameter) is required in URL\");\n }\n\n // Validate public key hash format (16 hex characters)\n if (!/^[0-9a-f]{16}$/.test(h)) {\n throw new Error(\n \"Invalid public key hash format: must be 16 hex characters\",\n );\n }\n\n if (!k) {\n throw new Error(\"Shared key (k parameter) is required in URL\");\n }\n\n // Validate shared key format (32 hex characters)\n if (!/^[0-9a-f]{32}$/.test(k)) {\n throw new Error(\"Invalid shared key format: must be 32 lowercase hex characters\");\n }\n\n return {\n version,\n sessionId,\n h,\n k,\n s: s ?? \"\",\n p,\n };\n }\n catch (error) {\n const errorMessage\n = error instanceof Error ? error.message : \"Unknown error\";\n\n // Strip query string so handshake secrets (k) are not exposed in error messages\n const safeUrl = url.includes(\"?\") ? url.slice(0, url.indexOf(\"?\")) + \"?[redacted]\" : url;\n\n throw new Error(`Failed to parse URL: ${safeUrl}. Original error: ${errorMessage}`);\n }\n};\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/url/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { SessionHandshakeParameters } from \"../session.js\";\n\nexport const OPENLV_PROTOCOL_VERSION = 1;\n\nexport const encodeConnectionURL = (payload: SessionHandshakeParameters) => {\n const parameters = new URLSearchParams();\n\n parameters.set(\"h\", payload.h);\n parameters.set(\"k\", payload.k);\n\n if (payload.p) {\n parameters.set(\"p\", payload.p);\n }\n\n if (payload.s) {\n parameters.set(\"s\", payload.s);\n }\n\n return `openlv://${payload.sessionId}@${OPENLV_PROTOCOL_VERSION}?${parameters.toString()}`;\n};\n\nexport const decodeConnectionURL = (\n url: string,\n): SessionHandshakeParameters => {\n if (typeof url !== \"string\") throw new TypeError(`Invalid URL type: expected string, got ${typeof url}`);\n\n if (!url || url.trim() === \"\") throw new Error(\"URL cannot be empty\");\n\n if (!url.startsWith(\"openlv://\")) throw new Error(\n `Invalid URL format: must start with 'openlv://', got: ${url}`,\n );\n\n try {\n const urlObject = new URL(url);\n const sessionId = urlObject.username;\n const version = Number(urlObject.hostname || urlObject.pathname.replace(\"/\", \"\"));\n\n if (version !== OPENLV_PROTOCOL_VERSION) {\n throw new Error(\n `Invalid protocol version: expected ${OPENLV_PROTOCOL_VERSION}, got ${version}`,\n );\n }\n\n const h = urlObject.searchParams.get(\"h\") || \"\";\n const k = urlObject.searchParams.get(\"k\") || \"\";\n const s = urlObject.searchParams.get(\"s\") || undefined;\n\n const p = urlObject.searchParams.get(\"p\") || \"\";\n\n if (!sessionId) {\n throw new Error(\"Session ID is required in URL\");\n }\n\n // Validate session ID format (16 characters, URL-safe alphabet)\n if (!/^[A-Za-z0-9_-]{16}$/.test(sessionId)) {\n throw new Error(\n \"Invalid session ID format: must be 16 URL-safe characters\",\n );\n }\n\n if (!h) {\n throw new Error(\"Public key hash (h parameter) is required in URL\");\n }\n\n // Validate public key hash format (16 hex characters)\n if (!/^[0-9a-f]{16}$/.test(h)) {\n throw new Error(\n \"Invalid public key hash format: must be 16 hex characters\",\n );\n }\n\n if (!k) {\n throw new Error(\"Shared key (k parameter) is required in URL\");\n }\n\n // Validate shared key format (32 hex characters)\n if (!/^[0-9a-f]{32}$/.test(k)) {\n throw new Error(\"Invalid shared key format: must be 32 lowercase hex characters\");\n }\n\n return {\n version,\n sessionId,\n h,\n k,\n s: s ?? \"\",\n p,\n };\n }\n catch (error) {\n const errorMessage\n = error instanceof Error ? error.message : \"Unknown error\";\n\n // Strip query string so handshake secrets (k) are not exposed in error messages\n const safeUrl = url.includes(\"?\") ? url.slice(0, url.indexOf(\"?\")) + \"?[redacted]\" : url;\n\n throw new Error(`Failed to parse URL: ${safeUrl}. Original error: ${errorMessage}`);\n }\n};\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlv/core",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "keywords": [
5
5
  "open-lavatory"
6
6
  ],
@@ -1,13 +0,0 @@
1
- import type { EncryptionKey } from "./asymmetric.js";
2
- /**
3
- * Hashes a public key using SHA-256 and returns the first 16 bytes as a hex string
4
- */
5
- export declare const hashPublicKey: (publicKey: EncryptionKey) => Promise<string>;
6
- export declare const validatePublicKeyHash: (publicKey: EncryptionKey, hash: string) => Promise<boolean>;
7
- /**
8
- * determines/or generates the hash and dictates wether is host or not
9
- */
10
- export declare const initHash: (initialHash: string | undefined, encryptionKey: EncryptionKey) => Promise<{
11
- hash: string;
12
- isHost: boolean;
13
- }>;
@@ -1,27 +0,0 @@
1
- import { bytesToHex } from "@noble/ciphers/utils.js";
2
- const HASH_LENGTH = 16;
3
- /**
4
- * Hashes a public key using SHA-256 and returns the first 16 bytes as a hex string
5
- */
6
- export const hashPublicKey = async (publicKey) => {
7
- const buffer = new TextEncoder().encode(publicKey.toString());
8
- const hash = await crypto.subtle.digest("SHA-256", buffer);
9
- return bytesToHex(new Uint8Array(hash)).slice(0, HASH_LENGTH);
10
- };
11
- export const validatePublicKeyHash = async (publicKey, hash) => {
12
- const computedHash = await hashPublicKey(publicKey);
13
- return computedHash === hash;
14
- };
15
- /**
16
- * determines/or generates the hash and dictates wether is host or not
17
- */
18
- export const initHash = async (initialHash, encryptionKey) => {
19
- const ourHash = await hashPublicKey(encryptionKey);
20
- const hash = initialHash || ourHash;
21
- const isHost = hash === ourHash;
22
- return {
23
- hash,
24
- isHost,
25
- };
26
- };
27
- //# sourceMappingURL=hash.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"hash.js","sourceRoot":"","sources":["../../src/encryption/hash.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAIrD,MAAM,WAAW,GAAG,EAAE,CAAC;AAEvB;;GAEG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAChC,SAAwB,EACP,EAAE;IACnB,MAAM,MAAM,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC9D,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IAE3D,OAAO,UAAU,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;AAChE,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,qBAAqB,GAAG,KAAK,EACxC,SAAwB,EACxB,IAAY,EACM,EAAE;IACpB,MAAM,YAAY,GAAG,MAAM,aAAa,CAAC,SAAS,CAAC,CAAC;IAEpD,OAAO,YAAY,KAAK,IAAI,CAAC;AAC/B,CAAC,CAAC;AAEF;;GAEG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,EAC3B,WAA+B,EAC/B,aAA4B,EAC5B,EAAE;IACF,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,aAAa,CAAC,CAAC;IACnD,MAAM,IAAI,GAAG,WAAW,IAAI,OAAO,CAAC;IACpC,MAAM,MAAM,GAAG,IAAI,KAAK,OAAO,CAAC;IAEhC,OAAO;QACL,IAAI;QACJ,MAAM;KACP,CAAC;AACJ,CAAC,CAAC","sourcesContent":["import { bytesToHex } from \"@noble/ciphers/utils.js\";\n\nimport type { EncryptionKey } from \"./asymmetric.js\";\n\nconst HASH_LENGTH = 16;\n\n/**\n * Hashes a public key using SHA-256 and returns the first 16 bytes as a hex string\n */\nexport const hashPublicKey = async (\n publicKey: EncryptionKey,\n): Promise<string> => {\n const buffer = new TextEncoder().encode(publicKey.toString());\n const hash = await crypto.subtle.digest(\"SHA-256\", buffer);\n\n return bytesToHex(new Uint8Array(hash)).slice(0, HASH_LENGTH);\n};\n\nexport const validatePublicKeyHash = async (\n publicKey: EncryptionKey,\n hash: string,\n): Promise<boolean> => {\n const computedHash = await hashPublicKey(publicKey);\n\n return computedHash === hash;\n};\n\n/**\n * determines/or generates the hash and dictates wether is host or not\n */\nexport const initHash = async (\n initialHash: string | undefined,\n encryptionKey: EncryptionKey,\n) => {\n const ourHash = await hashPublicKey(encryptionKey);\n const hash = initialHash || ourHash;\n const isHost = hash === ourHash;\n\n return {\n hash,\n isHost,\n };\n};\n"]}