@reley/crypto 0.1.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.
- package/dist/cipher.d.ts +16 -0
- package/dist/cipher.d.ts.map +1 -0
- package/dist/cipher.js +44 -0
- package/dist/cipher.js.map +1 -0
- package/dist/ecdh.d.ts +6 -0
- package/dist/ecdh.d.ts.map +1 -0
- package/dist/ecdh.js +10 -0
- package/dist/ecdh.js.map +1 -0
- package/dist/hkdf.d.ts +16 -0
- package/dist/hkdf.d.ts.map +1 -0
- package/dist/hkdf.js +54 -0
- package/dist/hkdf.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +52 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +80 -0
- package/dist/keys.js.map +1 -0
- package/dist/qr-payload.d.ts +19 -0
- package/dist/qr-payload.d.ts.map +1 -0
- package/dist/qr-payload.js +84 -0
- package/dist/qr-payload.js.map +1 -0
- package/dist/ratchet.d.ts +34 -0
- package/dist/ratchet.d.ts.map +1 -0
- package/dist/ratchet.js +91 -0
- package/dist/ratchet.js.map +1 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +37 -0
- package/dist/utils.js.map +1 -0
- package/package.json +32 -0
- package/src/__tests__/cipher.test.ts +57 -0
- package/src/__tests__/ecdh.test.ts +27 -0
- package/src/__tests__/hkdf.test.ts +62 -0
- package/src/__tests__/keys.test.ts +62 -0
- package/src/__tests__/qr-payload.test.ts +62 -0
- package/src/__tests__/ratchet.test.ts +119 -0
- package/src/cipher.ts +90 -0
- package/src/ecdh.ts +13 -0
- package/src/hkdf.ts +69 -0
- package/src/index.ts +46 -0
- package/src/keys.ts +105 -0
- package/src/qr-payload.ts +112 -0
- package/src/ratchet.ts +124 -0
- package/src/utils.ts +41 -0
package/dist/utils.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ensureSodium } from './keys.js';
|
|
2
|
+
/**
|
|
3
|
+
* Compute a human-readable fingerprint from two public keys for MITM verification.
|
|
4
|
+
* Keys are sorted lexicographically to ensure both parties produce the same fingerprint.
|
|
5
|
+
* Uses Blake2b-128 hash, formatted as uppercase hex groups: "XXXX-XXXX-..."
|
|
6
|
+
*/
|
|
7
|
+
export async function computeFingerprint(pk1, pk2) {
|
|
8
|
+
const s = await ensureSodium();
|
|
9
|
+
const sorted = [pk1, pk2].sort((a, b) => {
|
|
10
|
+
for (let i = 0; i < a.length; i++) {
|
|
11
|
+
if (a[i] !== b[i])
|
|
12
|
+
return a[i] - b[i];
|
|
13
|
+
}
|
|
14
|
+
return 0;
|
|
15
|
+
});
|
|
16
|
+
const combined = new Uint8Array(sorted[0].length + sorted[1].length);
|
|
17
|
+
combined.set(sorted[0], 0);
|
|
18
|
+
combined.set(sorted[1], sorted[0].length);
|
|
19
|
+
const hash = s.crypto_generichash(16, combined, null);
|
|
20
|
+
const hex = s.to_hex(hash).toUpperCase();
|
|
21
|
+
return hex.match(/.{4}/g).join('-');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Encode a Uint8Array to a base64url string (no padding).
|
|
25
|
+
*/
|
|
26
|
+
export async function toBase64Url(data) {
|
|
27
|
+
const s = await ensureSodium();
|
|
28
|
+
return s.to_base64(data, s.base64_variants.URLSAFE_NO_PADDING);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Decode a base64url string to a Uint8Array.
|
|
32
|
+
*/
|
|
33
|
+
export async function fromBase64Url(str) {
|
|
34
|
+
const s = await ensureSodium();
|
|
35
|
+
return s.from_base64(str, s.base64_variants.URLSAFE_NO_PADDING);
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAEzC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,GAAe,EACf,GAAe;IAEf,MAAM,CAAC,GAAG,MAAM,YAAY,EAAE,CAAC;IAC/B,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACtC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YAClC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;gBAAE,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACxC,CAAC;QACD,OAAO,CAAC,CAAC;IACX,CAAC,CAAC,CAAC;IACH,MAAM,QAAQ,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACrE,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3B,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAC1C,MAAM,IAAI,GAAG,CAAC,CAAC,kBAAkB,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC;IACtD,MAAM,GAAG,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC;IACzC,OAAO,GAAG,CAAC,KAAK,CAAC,OAAO,CAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACvC,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAgB;IAChD,MAAM,CAAC,GAAG,MAAM,YAAY,EAAE,CAAC;IAC/B,OAAO,CAAC,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,CAAC,eAAe,CAAC,kBAAkB,CAAC,CAAC;AACjE,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAW;IAC7C,MAAM,CAAC,GAAG,MAAM,YAAY,EAAE,CAAC;IAC/B,OAAO,CAAC,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC,CAAC,eAAe,CAAC,kBAAkB,CAAC,CAAC;AAClE,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reley/crypto",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "E2E encryption primitives for Reley (X25519, HKDF, XChaCha20-Poly1305, Double Ratchet)",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": ["dist", "src", "LICENSE"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"clean": "rm -rf dist"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"libsodium-wrappers-sumo": "^0.7.15"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/libsodium-wrappers-sumo": "^0.7.8",
|
|
29
|
+
"typescript": "^5.7.0",
|
|
30
|
+
"vitest": "^2.1.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { encrypt, decrypt, KEY_LENGTH } from '../cipher.js';
|
|
3
|
+
import { ensureSodium } from '../keys.js';
|
|
4
|
+
|
|
5
|
+
describe('cipher (XChaCha20-Poly1305)', () => {
|
|
6
|
+
it('should encrypt and decrypt round-trip', async () => {
|
|
7
|
+
const s = await ensureSodium();
|
|
8
|
+
const key = s.randombytes_buf(KEY_LENGTH);
|
|
9
|
+
const plaintext = new TextEncoder().encode('Hello, Reley!');
|
|
10
|
+
|
|
11
|
+
const { nonce, ciphertext } = await encrypt(plaintext, key);
|
|
12
|
+
const decrypted = await decrypt(ciphertext, nonce, key);
|
|
13
|
+
|
|
14
|
+
expect(new TextDecoder().decode(decrypted)).toBe('Hello, Reley!');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should fail with wrong key', async () => {
|
|
18
|
+
const s = await ensureSodium();
|
|
19
|
+
const key1 = s.randombytes_buf(KEY_LENGTH);
|
|
20
|
+
const key2 = s.randombytes_buf(KEY_LENGTH);
|
|
21
|
+
const plaintext = new TextEncoder().encode('secret');
|
|
22
|
+
|
|
23
|
+
const { nonce, ciphertext } = await encrypt(plaintext, key1);
|
|
24
|
+
await expect(decrypt(ciphertext, nonce, key2)).rejects.toThrow('Decryption failed');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should fail with tampered ciphertext', async () => {
|
|
28
|
+
const s = await ensureSodium();
|
|
29
|
+
const key = s.randombytes_buf(KEY_LENGTH);
|
|
30
|
+
const plaintext = new TextEncoder().encode('secret');
|
|
31
|
+
|
|
32
|
+
const { nonce, ciphertext } = await encrypt(plaintext, key);
|
|
33
|
+
ciphertext[0] ^= 0xff; // tamper
|
|
34
|
+
await expect(decrypt(ciphertext, nonce, key)).rejects.toThrow('Decryption failed');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should support AAD', async () => {
|
|
38
|
+
const s = await ensureSodium();
|
|
39
|
+
const key = s.randombytes_buf(KEY_LENGTH);
|
|
40
|
+
const plaintext = new TextEncoder().encode('with aad');
|
|
41
|
+
const aad = new TextEncoder().encode('additional data');
|
|
42
|
+
|
|
43
|
+
const { nonce, ciphertext } = await encrypt(plaintext, key, aad);
|
|
44
|
+
const decrypted = await decrypt(ciphertext, nonce, key, aad);
|
|
45
|
+
expect(new TextDecoder().decode(decrypted)).toBe('with aad');
|
|
46
|
+
|
|
47
|
+
// Wrong AAD should fail
|
|
48
|
+
const wrongAad = new TextEncoder().encode('wrong');
|
|
49
|
+
await expect(decrypt(ciphertext, nonce, key, wrongAad)).rejects.toThrow('Decryption failed');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should reject invalid key length', async () => {
|
|
53
|
+
const plaintext = new TextEncoder().encode('test');
|
|
54
|
+
const badKey = new Uint8Array(16);
|
|
55
|
+
await expect(encrypt(plaintext, badKey)).rejects.toThrow('Key must be 32 bytes');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateEphemeralKeyPair } from '../keys.js';
|
|
3
|
+
import { computeSharedSecret } from '../ecdh.js';
|
|
4
|
+
|
|
5
|
+
describe('ecdh', () => {
|
|
6
|
+
it('should compute matching shared secrets', async () => {
|
|
7
|
+
const alice = await generateEphemeralKeyPair();
|
|
8
|
+
const bob = await generateEphemeralKeyPair();
|
|
9
|
+
|
|
10
|
+
const secretAlice = await computeSharedSecret(alice.secretKey, bob.publicKey);
|
|
11
|
+
const secretBob = await computeSharedSecret(bob.secretKey, alice.publicKey);
|
|
12
|
+
|
|
13
|
+
expect(secretAlice).toHaveLength(32);
|
|
14
|
+
expect(Buffer.from(secretAlice)).toEqual(Buffer.from(secretBob));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should produce different secrets for different key pairs', async () => {
|
|
18
|
+
const alice = await generateEphemeralKeyPair();
|
|
19
|
+
const bob = await generateEphemeralKeyPair();
|
|
20
|
+
const carol = await generateEphemeralKeyPair();
|
|
21
|
+
|
|
22
|
+
const secretAB = await computeSharedSecret(alice.secretKey, bob.publicKey);
|
|
23
|
+
const secretAC = await computeSharedSecret(alice.secretKey, carol.publicKey);
|
|
24
|
+
|
|
25
|
+
expect(Buffer.from(secretAB)).not.toEqual(Buffer.from(secretAC));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { deriveSessionKeys, deriveChainKey } from '../hkdf.js';
|
|
3
|
+
import { ensureSodium } from '../keys.js';
|
|
4
|
+
|
|
5
|
+
describe('hkdf', () => {
|
|
6
|
+
it('should derive different send and recv keys', async () => {
|
|
7
|
+
const s = await ensureSodium();
|
|
8
|
+
const sharedSecret = s.randombytes_buf(32);
|
|
9
|
+
|
|
10
|
+
const { sendKey, recvKey } = await deriveSessionKeys(sharedSecret);
|
|
11
|
+
expect(sendKey).toHaveLength(32);
|
|
12
|
+
expect(recvKey).toHaveLength(32);
|
|
13
|
+
expect(Buffer.from(sendKey)).not.toEqual(Buffer.from(recvKey));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should produce deterministic keys for same input', async () => {
|
|
17
|
+
const s = await ensureSodium();
|
|
18
|
+
const sharedSecret = s.randombytes_buf(32);
|
|
19
|
+
|
|
20
|
+
const keys1 = await deriveSessionKeys(sharedSecret);
|
|
21
|
+
const keys2 = await deriveSessionKeys(sharedSecret);
|
|
22
|
+
|
|
23
|
+
expect(Buffer.from(keys1.sendKey)).toEqual(Buffer.from(keys2.sendKey));
|
|
24
|
+
expect(Buffer.from(keys1.recvKey)).toEqual(Buffer.from(keys2.recvKey));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should produce different keys for different secrets', async () => {
|
|
28
|
+
const s = await ensureSodium();
|
|
29
|
+
const secret1 = s.randombytes_buf(32);
|
|
30
|
+
const secret2 = s.randombytes_buf(32);
|
|
31
|
+
|
|
32
|
+
const keys1 = await deriveSessionKeys(secret1);
|
|
33
|
+
const keys2 = await deriveSessionKeys(secret2);
|
|
34
|
+
|
|
35
|
+
expect(Buffer.from(keys1.sendKey)).not.toEqual(Buffer.from(keys2.sendKey));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should derive chain keys progressively', async () => {
|
|
39
|
+
const s = await ensureSodium();
|
|
40
|
+
const chainKey = s.randombytes_buf(32);
|
|
41
|
+
|
|
42
|
+
const { messageKey, nextChainKey } = await deriveChainKey(chainKey);
|
|
43
|
+
expect(messageKey).toHaveLength(32);
|
|
44
|
+
expect(nextChainKey).toHaveLength(32);
|
|
45
|
+
|
|
46
|
+
// Chain key should change
|
|
47
|
+
expect(Buffer.from(nextChainKey)).not.toEqual(Buffer.from(chainKey));
|
|
48
|
+
// Message key should differ from chain key
|
|
49
|
+
expect(Buffer.from(messageKey)).not.toEqual(Buffer.from(chainKey));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should produce deterministic chain progression', async () => {
|
|
53
|
+
const s = await ensureSodium();
|
|
54
|
+
const chainKey = s.randombytes_buf(32);
|
|
55
|
+
|
|
56
|
+
const result1 = await deriveChainKey(chainKey);
|
|
57
|
+
const result2 = await deriveChainKey(chainKey);
|
|
58
|
+
|
|
59
|
+
expect(Buffer.from(result1.messageKey)).toEqual(Buffer.from(result2.messageKey));
|
|
60
|
+
expect(Buffer.from(result1.nextChainKey)).toEqual(Buffer.from(result2.nextChainKey));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
generateIdentityKeyPair,
|
|
4
|
+
generateEphemeralKeyPair,
|
|
5
|
+
ed25519ToX25519Public,
|
|
6
|
+
ed25519ToX25519Secret,
|
|
7
|
+
generateOneTimeCode,
|
|
8
|
+
sign,
|
|
9
|
+
verify,
|
|
10
|
+
} from '../keys.js';
|
|
11
|
+
|
|
12
|
+
describe('keys', () => {
|
|
13
|
+
it('should generate Ed25519 identity key pair', async () => {
|
|
14
|
+
const kp = await generateIdentityKeyPair();
|
|
15
|
+
expect(kp.keyType).toBe('ed25519');
|
|
16
|
+
expect(kp.publicKey).toHaveLength(32);
|
|
17
|
+
expect(kp.secretKey).toHaveLength(64);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should generate unique key pairs', async () => {
|
|
21
|
+
const kp1 = await generateIdentityKeyPair();
|
|
22
|
+
const kp2 = await generateIdentityKeyPair();
|
|
23
|
+
expect(kp1.publicKey).not.toEqual(kp2.publicKey);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should generate X25519 ephemeral key pair', async () => {
|
|
27
|
+
const kp = await generateEphemeralKeyPair();
|
|
28
|
+
expect(kp.keyType).toBe('x25519');
|
|
29
|
+
expect(kp.publicKey).toHaveLength(32);
|
|
30
|
+
expect(kp.secretKey).toHaveLength(32);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should convert Ed25519 to X25519 keys', async () => {
|
|
34
|
+
const ed = await generateIdentityKeyPair();
|
|
35
|
+
const x25519Pk = await ed25519ToX25519Public(ed.publicKey);
|
|
36
|
+
const x25519Sk = await ed25519ToX25519Secret(ed.secretKey);
|
|
37
|
+
expect(x25519Pk).toHaveLength(32);
|
|
38
|
+
expect(x25519Sk).toHaveLength(32);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should generate one-time code', async () => {
|
|
42
|
+
const otc = await generateOneTimeCode(32);
|
|
43
|
+
expect(otc).toHaveLength(32);
|
|
44
|
+
const otc2 = await generateOneTimeCode(32);
|
|
45
|
+
expect(otc).not.toEqual(otc2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should sign and verify messages', async () => {
|
|
49
|
+
const kp = await generateIdentityKeyPair();
|
|
50
|
+
const message = new TextEncoder().encode('hello world');
|
|
51
|
+
const signature = await sign(message, kp.secretKey);
|
|
52
|
+
expect(signature).toHaveLength(64);
|
|
53
|
+
|
|
54
|
+
const valid = await verify(signature, message, kp.publicKey);
|
|
55
|
+
expect(valid).toBe(true);
|
|
56
|
+
|
|
57
|
+
// Tampered message should fail
|
|
58
|
+
const tampered = new TextEncoder().encode('hello world!');
|
|
59
|
+
const invalid = await verify(signature, tampered, kp.publicKey);
|
|
60
|
+
expect(invalid).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { encodeQRPayload, decodeQRPayload, hashOneTimeCode } from '../qr-payload.js';
|
|
3
|
+
import { ensureSodium } from '../keys.js';
|
|
4
|
+
|
|
5
|
+
describe('qr-payload', () => {
|
|
6
|
+
it('should encode and decode QR payload round-trip', async () => {
|
|
7
|
+
const s = await ensureSodium();
|
|
8
|
+
const payload = {
|
|
9
|
+
releyUrl: 'wss://reley.example.com',
|
|
10
|
+
publicKey: s.randombytes_buf(32),
|
|
11
|
+
oneTimeCode: s.randombytes_buf(32),
|
|
12
|
+
jwt: 'eyJhbGciOiJIUzI1NiJ9.test.signature',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const encoded = await encodeQRPayload(payload);
|
|
16
|
+
expect(typeof encoded).toBe('string');
|
|
17
|
+
|
|
18
|
+
const decoded = await decodeQRPayload(encoded);
|
|
19
|
+
expect(decoded.releyUrl).toBe(payload.releyUrl);
|
|
20
|
+
expect(Buffer.from(decoded.publicKey)).toEqual(Buffer.from(payload.publicKey));
|
|
21
|
+
expect(Buffer.from(decoded.oneTimeCode)).toEqual(Buffer.from(payload.oneTimeCode));
|
|
22
|
+
expect(decoded.jwt).toBe(payload.jwt);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should reject too-short payload', async () => {
|
|
26
|
+
const s = await ensureSodium();
|
|
27
|
+
const badData = s.to_base64(
|
|
28
|
+
new TextEncoder().encode('XX1bad'),
|
|
29
|
+
s.base64_variants.URLSAFE_NO_PADDING,
|
|
30
|
+
);
|
|
31
|
+
await expect(decodeQRPayload(badData)).rejects.toThrow('QR payload too short');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should reject invalid magic', async () => {
|
|
35
|
+
const s = await ensureSodium();
|
|
36
|
+
// Build a buffer that passes the length check but has wrong magic
|
|
37
|
+
const buf = new Uint8Array(71);
|
|
38
|
+
buf[0] = 'X'.charCodeAt(0);
|
|
39
|
+
buf[1] = 'X'.charCodeAt(0);
|
|
40
|
+
buf[2] = '1'.charCodeAt(0);
|
|
41
|
+
const badData = s.to_base64(buf, s.base64_variants.URLSAFE_NO_PADDING);
|
|
42
|
+
await expect(decodeQRPayload(badData)).rejects.toThrow('Invalid QR payload magic');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should hash one-time codes consistently', async () => {
|
|
46
|
+
const s = await ensureSodium();
|
|
47
|
+
const otc = s.randombytes_buf(32);
|
|
48
|
+
const hash1 = await hashOneTimeCode(otc);
|
|
49
|
+
const hash2 = await hashOneTimeCode(otc);
|
|
50
|
+
expect(Buffer.from(hash1)).toEqual(Buffer.from(hash2));
|
|
51
|
+
expect(hash1).toHaveLength(32);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should produce different hashes for different OTCs', async () => {
|
|
55
|
+
const s = await ensureSodium();
|
|
56
|
+
const otc1 = s.randombytes_buf(32);
|
|
57
|
+
const otc2 = s.randombytes_buf(32);
|
|
58
|
+
const hash1 = await hashOneTimeCode(otc1);
|
|
59
|
+
const hash2 = await hashOneTimeCode(otc2);
|
|
60
|
+
expect(Buffer.from(hash1)).not.toEqual(Buffer.from(hash2));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateEphemeralKeyPair } from '../keys.js';
|
|
3
|
+
import { computeSharedSecret } from '../ecdh.js';
|
|
4
|
+
import { deriveSessionKeys } from '../hkdf.js';
|
|
5
|
+
import {
|
|
6
|
+
initRatchet,
|
|
7
|
+
ratchetEncrypt,
|
|
8
|
+
ratchetDecrypt,
|
|
9
|
+
needsKeyRotation,
|
|
10
|
+
KEY_ROTATION_INTERVAL,
|
|
11
|
+
} from '../ratchet.js';
|
|
12
|
+
|
|
13
|
+
describe('ratchet', () => {
|
|
14
|
+
async function createPairedRatchets() {
|
|
15
|
+
const alice = await generateEphemeralKeyPair();
|
|
16
|
+
const bob = await generateEphemeralKeyPair();
|
|
17
|
+
|
|
18
|
+
const sharedAlice = await computeSharedSecret(alice.secretKey, bob.publicKey);
|
|
19
|
+
const sharedBob = await computeSharedSecret(bob.secretKey, alice.publicKey);
|
|
20
|
+
|
|
21
|
+
const keysAlice = await deriveSessionKeys(sharedAlice);
|
|
22
|
+
const keysBob = await deriveSessionKeys(sharedBob);
|
|
23
|
+
|
|
24
|
+
// Alice's send = Bob's recv, Alice's recv = Bob's send
|
|
25
|
+
const aliceState = initRatchet(keysAlice.sendKey, keysAlice.recvKey);
|
|
26
|
+
const bobState = initRatchet(keysBob.recvKey, keysBob.sendKey);
|
|
27
|
+
|
|
28
|
+
return { aliceState, bobState };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it('should encrypt and decrypt a message', async () => {
|
|
32
|
+
let { aliceState, bobState } = await createPairedRatchets();
|
|
33
|
+
|
|
34
|
+
const plaintext = new TextEncoder().encode('Hello Bob!');
|
|
35
|
+
const encrypted = await ratchetEncrypt(aliceState, plaintext);
|
|
36
|
+
aliceState = encrypted.state;
|
|
37
|
+
|
|
38
|
+
const decrypted = await ratchetDecrypt(
|
|
39
|
+
bobState,
|
|
40
|
+
encrypted.ciphertext,
|
|
41
|
+
encrypted.nonce,
|
|
42
|
+
encrypted.counter,
|
|
43
|
+
);
|
|
44
|
+
bobState = decrypted.state;
|
|
45
|
+
|
|
46
|
+
expect(new TextDecoder().decode(decrypted.plaintext)).toBe('Hello Bob!');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should handle multiple messages', async () => {
|
|
50
|
+
let { aliceState, bobState } = await createPairedRatchets();
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < 10; i++) {
|
|
53
|
+
const msg = `Message ${i}`;
|
|
54
|
+
const encrypted = await ratchetEncrypt(aliceState, new TextEncoder().encode(msg));
|
|
55
|
+
aliceState = encrypted.state;
|
|
56
|
+
|
|
57
|
+
const decrypted = await ratchetDecrypt(
|
|
58
|
+
bobState,
|
|
59
|
+
encrypted.ciphertext,
|
|
60
|
+
encrypted.nonce,
|
|
61
|
+
encrypted.counter,
|
|
62
|
+
);
|
|
63
|
+
bobState = decrypted.state;
|
|
64
|
+
|
|
65
|
+
expect(new TextDecoder().decode(decrypted.plaintext)).toBe(msg);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
expect(aliceState.sendCounter).toBe(10);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should detect replay attacks', async () => {
|
|
72
|
+
let { aliceState, bobState } = await createPairedRatchets();
|
|
73
|
+
|
|
74
|
+
const encrypted = await ratchetEncrypt(
|
|
75
|
+
aliceState,
|
|
76
|
+
new TextEncoder().encode('first'),
|
|
77
|
+
);
|
|
78
|
+
aliceState = encrypted.state;
|
|
79
|
+
|
|
80
|
+
const decrypted = await ratchetDecrypt(
|
|
81
|
+
bobState,
|
|
82
|
+
encrypted.ciphertext,
|
|
83
|
+
encrypted.nonce,
|
|
84
|
+
encrypted.counter,
|
|
85
|
+
);
|
|
86
|
+
bobState = decrypted.state;
|
|
87
|
+
|
|
88
|
+
// Replay the same message
|
|
89
|
+
await expect(
|
|
90
|
+
ratchetDecrypt(bobState, encrypted.ciphertext, encrypted.nonce, encrypted.counter),
|
|
91
|
+
).rejects.toThrow('Replay attack detected');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should signal key rotation need', () => {
|
|
95
|
+
const state = initRatchet(new Uint8Array(32), new Uint8Array(32));
|
|
96
|
+
expect(needsKeyRotation(state)).toBe(false);
|
|
97
|
+
|
|
98
|
+
const rotationState = { ...state, sendCounter: KEY_ROTATION_INTERVAL };
|
|
99
|
+
expect(needsKeyRotation(rotationState)).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle bidirectional communication', async () => {
|
|
103
|
+
let { aliceState, bobState } = await createPairedRatchets();
|
|
104
|
+
|
|
105
|
+
// Alice → Bob
|
|
106
|
+
const enc1 = await ratchetEncrypt(aliceState, new TextEncoder().encode('A→B'));
|
|
107
|
+
aliceState = enc1.state;
|
|
108
|
+
const dec1 = await ratchetDecrypt(bobState, enc1.ciphertext, enc1.nonce, enc1.counter);
|
|
109
|
+
bobState = dec1.state;
|
|
110
|
+
expect(new TextDecoder().decode(dec1.plaintext)).toBe('A→B');
|
|
111
|
+
|
|
112
|
+
// Bob → Alice
|
|
113
|
+
const enc2 = await ratchetEncrypt(bobState, new TextEncoder().encode('B→A'));
|
|
114
|
+
bobState = enc2.state;
|
|
115
|
+
const dec2 = await ratchetDecrypt(aliceState, enc2.ciphertext, enc2.nonce, enc2.counter);
|
|
116
|
+
aliceState = dec2.state;
|
|
117
|
+
expect(new TextDecoder().decode(dec2.plaintext)).toBe('B→A');
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/cipher.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XChaCha20-Poly1305 AEAD cipher.
|
|
3
|
+
*
|
|
4
|
+
* Despite the original filename "aes-gcm.ts", the actual algorithm has always
|
|
5
|
+
* been XChaCha20-Poly1305 via libsodium — AES-GCM was not used because it
|
|
6
|
+
* requires hardware AES-NI support which is unavailable on many platforms.
|
|
7
|
+
*
|
|
8
|
+
* XChaCha20-Poly1305 is a modern AEAD cipher providing:
|
|
9
|
+
* - 256-bit key security
|
|
10
|
+
* - 192-bit nonce (24 bytes) — practically eliminates nonce collision risk
|
|
11
|
+
* - Poly1305 authentication tag (16 bytes)
|
|
12
|
+
*
|
|
13
|
+
* Nonce handling:
|
|
14
|
+
* We generate a 12-byte random nonce and zero-pad it to 24 bytes for the
|
|
15
|
+
* XChaCha20-Poly1305 IETF construction. The 12-byte nonce is transmitted on
|
|
16
|
+
* the wire to save bandwidth; the receiver pads identically before decryption.
|
|
17
|
+
* With random 12-byte nonces the collision probability is ~2^-48 per message
|
|
18
|
+
* pair, which is safe for the expected message volume.
|
|
19
|
+
*/
|
|
20
|
+
import type _sodium from 'libsodium-wrappers-sumo';
|
|
21
|
+
import { ensureSodium } from './keys.js';
|
|
22
|
+
|
|
23
|
+
export const NONCE_LENGTH = 12;
|
|
24
|
+
export const TAG_LENGTH = 16;
|
|
25
|
+
export const KEY_LENGTH = 32;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Encrypt data with XChaCha20-Poly1305.
|
|
29
|
+
* @returns { nonce, ciphertext } where ciphertext includes the auth tag
|
|
30
|
+
*/
|
|
31
|
+
export async function encrypt(
|
|
32
|
+
plaintext: Uint8Array,
|
|
33
|
+
key: Uint8Array,
|
|
34
|
+
aad: Uint8Array = new Uint8Array(0),
|
|
35
|
+
): Promise<{ nonce: Uint8Array; ciphertext: Uint8Array }> {
|
|
36
|
+
const s = await ensureSodium();
|
|
37
|
+
|
|
38
|
+
if (key.length !== KEY_LENGTH) {
|
|
39
|
+
throw new Error(`Key must be ${KEY_LENGTH} bytes, got ${key.length}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const nonce = s.randombytes_buf(NONCE_LENGTH);
|
|
43
|
+
const ciphertext = s.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
44
|
+
plaintext,
|
|
45
|
+
aad.length > 0 ? aad : null,
|
|
46
|
+
null, // nsec (unused)
|
|
47
|
+
// xchacha uses 24-byte nonce, pad our 12-byte nonce
|
|
48
|
+
padNonce(nonce, s),
|
|
49
|
+
key,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return { nonce, ciphertext };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decrypt data with XChaCha20-Poly1305.
|
|
57
|
+
*/
|
|
58
|
+
export async function decrypt(
|
|
59
|
+
ciphertext: Uint8Array,
|
|
60
|
+
nonce: Uint8Array,
|
|
61
|
+
key: Uint8Array,
|
|
62
|
+
aad: Uint8Array = new Uint8Array(0),
|
|
63
|
+
): Promise<Uint8Array> {
|
|
64
|
+
const s = await ensureSodium();
|
|
65
|
+
|
|
66
|
+
if (key.length !== KEY_LENGTH) {
|
|
67
|
+
throw new Error(`Key must be ${KEY_LENGTH} bytes, got ${key.length}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
return s.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
|
72
|
+
null, // nsec (unused)
|
|
73
|
+
ciphertext,
|
|
74
|
+
aad.length > 0 ? aad : null,
|
|
75
|
+
padNonce(nonce, s),
|
|
76
|
+
key,
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error('Decryption failed: invalid ciphertext or key');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Pad a 12-byte nonce to 24 bytes for xchacha20poly1305.
|
|
85
|
+
*/
|
|
86
|
+
function padNonce(nonce: Uint8Array, s: typeof _sodium): Uint8Array {
|
|
87
|
+
const padded = new Uint8Array(s.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
88
|
+
padded.set(nonce, 0);
|
|
89
|
+
return padded;
|
|
90
|
+
}
|
package/src/ecdh.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ensureSodium } from './keys.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute X25519 ECDH shared secret from our secret key and their public key.
|
|
5
|
+
* Returns 32-byte raw shared secret.
|
|
6
|
+
*/
|
|
7
|
+
export async function computeSharedSecret(
|
|
8
|
+
ourSecretKey: Uint8Array,
|
|
9
|
+
theirPublicKey: Uint8Array,
|
|
10
|
+
): Promise<Uint8Array> {
|
|
11
|
+
const s = await ensureSodium();
|
|
12
|
+
return s.crypto_scalarmult(ourSecretKey, theirPublicKey);
|
|
13
|
+
}
|
package/src/hkdf.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ensureSodium } from './keys.js';
|
|
2
|
+
|
|
3
|
+
const HKDF_HASH_LEN = 32; // SHA-256
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HKDF-Extract: PRK = HMAC-SHA256(salt, IKM)
|
|
7
|
+
*/
|
|
8
|
+
async function hkdfExtract(salt: Uint8Array, ikm: Uint8Array): Promise<Uint8Array> {
|
|
9
|
+
const s = await ensureSodium();
|
|
10
|
+
const key = salt.length > 0 ? salt : new Uint8Array(HKDF_HASH_LEN);
|
|
11
|
+
return s.crypto_auth_hmacsha256(ikm, key);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* HKDF-Expand: OKM = T(1) || T(2) || ... truncated to length
|
|
16
|
+
*/
|
|
17
|
+
async function hkdfExpand(prk: Uint8Array, info: Uint8Array, length: number): Promise<Uint8Array> {
|
|
18
|
+
const s = await ensureSodium();
|
|
19
|
+
const n = Math.ceil(length / HKDF_HASH_LEN);
|
|
20
|
+
const okm = new Uint8Array(n * HKDF_HASH_LEN);
|
|
21
|
+
let prev = new Uint8Array(0);
|
|
22
|
+
|
|
23
|
+
for (let i = 1; i <= n; i++) {
|
|
24
|
+
const input = new Uint8Array(prev.length + info.length + 1);
|
|
25
|
+
input.set(prev, 0);
|
|
26
|
+
input.set(info, prev.length);
|
|
27
|
+
input[prev.length + info.length] = i;
|
|
28
|
+
prev = new Uint8Array(s.crypto_auth_hmacsha256(input, prk));
|
|
29
|
+
okm.set(prev, (i - 1) * HKDF_HASH_LEN);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return okm.slice(0, length);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Derive symmetric keys from ECDH shared secret.
|
|
37
|
+
* Returns { sendKey, recvKey } each 32 bytes for AEAD cipher.
|
|
38
|
+
*/
|
|
39
|
+
export async function deriveSessionKeys(
|
|
40
|
+
sharedSecret: Uint8Array,
|
|
41
|
+
salt: Uint8Array = new Uint8Array(0),
|
|
42
|
+
info: string = 'reley-v1',
|
|
43
|
+
): Promise<{ sendKey: Uint8Array; recvKey: Uint8Array }> {
|
|
44
|
+
const infoBytes = new TextEncoder().encode(info);
|
|
45
|
+
const prk = await hkdfExtract(salt, sharedSecret);
|
|
46
|
+
// 64 bytes: first 32 = send key, next 32 = recv key
|
|
47
|
+
const okm = await hkdfExpand(prk, infoBytes, 64);
|
|
48
|
+
return {
|
|
49
|
+
sendKey: okm.slice(0, 32),
|
|
50
|
+
recvKey: okm.slice(32, 64),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Derive a single chain key for ratchet progression.
|
|
56
|
+
*/
|
|
57
|
+
export async function deriveChainKey(
|
|
58
|
+
chainKey: Uint8Array,
|
|
59
|
+
info: string = 'reley-chain',
|
|
60
|
+
): Promise<{ messageKey: Uint8Array; nextChainKey: Uint8Array }> {
|
|
61
|
+
const s = await ensureSodium();
|
|
62
|
+
const msgKeyInput = new TextEncoder().encode(info + '-msg');
|
|
63
|
+
const chainKeyInput = new TextEncoder().encode(info + '-chain');
|
|
64
|
+
|
|
65
|
+
const messageKey = s.crypto_auth_hmacsha256(msgKeyInput, chainKey);
|
|
66
|
+
const nextChainKey = s.crypto_auth_hmacsha256(chainKeyInput, chainKey);
|
|
67
|
+
|
|
68
|
+
return { messageKey, nextChainKey };
|
|
69
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ensureSodium,
|
|
3
|
+
generateIdentityKeyPair,
|
|
4
|
+
generateEphemeralKeyPair,
|
|
5
|
+
ed25519ToX25519Public,
|
|
6
|
+
ed25519ToX25519Secret,
|
|
7
|
+
generateOneTimeCode,
|
|
8
|
+
sign,
|
|
9
|
+
verify,
|
|
10
|
+
type Ed25519KeyPair,
|
|
11
|
+
type X25519KeyPair,
|
|
12
|
+
} from './keys.js';
|
|
13
|
+
|
|
14
|
+
export { computeSharedSecret } from './ecdh.js';
|
|
15
|
+
|
|
16
|
+
export { deriveSessionKeys, deriveChainKey } from './hkdf.js';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
encrypt,
|
|
20
|
+
decrypt,
|
|
21
|
+
NONCE_LENGTH,
|
|
22
|
+
TAG_LENGTH,
|
|
23
|
+
KEY_LENGTH,
|
|
24
|
+
} from './cipher.js';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
initRatchet,
|
|
28
|
+
ratchetEncrypt,
|
|
29
|
+
ratchetDecrypt,
|
|
30
|
+
needsKeyRotation,
|
|
31
|
+
KEY_ROTATION_INTERVAL,
|
|
32
|
+
type RatchetState,
|
|
33
|
+
} from './ratchet.js';
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
encodeQRPayload,
|
|
37
|
+
decodeQRPayload,
|
|
38
|
+
hashOneTimeCode,
|
|
39
|
+
type QRPayload,
|
|
40
|
+
} from './qr-payload.js';
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
computeFingerprint,
|
|
44
|
+
toBase64Url,
|
|
45
|
+
fromBase64Url,
|
|
46
|
+
} from './utils.js';
|