@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/src/keys.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key generation, signing, and libsodium initialization.
|
|
3
|
+
*
|
|
4
|
+
* Uses a default import of libsodium-wrappers-sumo which works across
|
|
5
|
+
* environments:
|
|
6
|
+
* - Node.js: vitest alias / bundler resolves to the CJS module
|
|
7
|
+
* - Browser: bundlers (webpack/vite) handle ESM resolution
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import _sodium from 'libsodium-wrappers-sumo';
|
|
11
|
+
|
|
12
|
+
let initialized = false;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Ensure libsodium is loaded and ready.
|
|
16
|
+
*/
|
|
17
|
+
export async function ensureSodium(): Promise<typeof _sodium> {
|
|
18
|
+
if (!initialized) {
|
|
19
|
+
await _sodium.ready;
|
|
20
|
+
initialized = true;
|
|
21
|
+
}
|
|
22
|
+
return _sodium;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Ed25519KeyPair {
|
|
26
|
+
publicKey: Uint8Array; // 32 bytes
|
|
27
|
+
secretKey: Uint8Array; // 64 bytes
|
|
28
|
+
keyType: 'ed25519';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface X25519KeyPair {
|
|
32
|
+
publicKey: Uint8Array; // 32 bytes
|
|
33
|
+
secretKey: Uint8Array; // 32 bytes
|
|
34
|
+
keyType: 'x25519';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate an Ed25519 identity key pair (long-term, per-device).
|
|
39
|
+
*/
|
|
40
|
+
export async function generateIdentityKeyPair(): Promise<Ed25519KeyPair> {
|
|
41
|
+
const s = await ensureSodium();
|
|
42
|
+
const kp = s.crypto_sign_keypair();
|
|
43
|
+
return {
|
|
44
|
+
publicKey: kp.publicKey,
|
|
45
|
+
secretKey: kp.privateKey,
|
|
46
|
+
keyType: 'ed25519',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate an X25519 ephemeral key pair (per-session).
|
|
52
|
+
*/
|
|
53
|
+
export async function generateEphemeralKeyPair(): Promise<X25519KeyPair> {
|
|
54
|
+
const s = await ensureSodium();
|
|
55
|
+
const kp = s.crypto_kx_keypair();
|
|
56
|
+
return {
|
|
57
|
+
publicKey: kp.publicKey,
|
|
58
|
+
secretKey: kp.privateKey,
|
|
59
|
+
keyType: 'x25519',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Convert an Ed25519 public key to X25519 for ECDH.
|
|
65
|
+
*/
|
|
66
|
+
export async function ed25519ToX25519Public(ed25519Pk: Uint8Array): Promise<Uint8Array> {
|
|
67
|
+
const s = await ensureSodium();
|
|
68
|
+
return s.crypto_sign_ed25519_pk_to_curve25519(ed25519Pk);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert an Ed25519 secret key to X25519 for ECDH.
|
|
73
|
+
*/
|
|
74
|
+
export async function ed25519ToX25519Secret(ed25519Sk: Uint8Array): Promise<Uint8Array> {
|
|
75
|
+
const s = await ensureSodium();
|
|
76
|
+
return s.crypto_sign_ed25519_sk_to_curve25519(ed25519Sk);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generate a cryptographically secure random one-time code.
|
|
81
|
+
*/
|
|
82
|
+
export async function generateOneTimeCode(length: number = 32): Promise<Uint8Array> {
|
|
83
|
+
const s = await ensureSodium();
|
|
84
|
+
return s.randombytes_buf(length);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sign a message with an Ed25519 secret key.
|
|
89
|
+
*/
|
|
90
|
+
export async function sign(message: Uint8Array, secretKey: Uint8Array): Promise<Uint8Array> {
|
|
91
|
+
const s = await ensureSodium();
|
|
92
|
+
return s.crypto_sign_detached(message, secretKey);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Verify an Ed25519 signature.
|
|
97
|
+
*/
|
|
98
|
+
export async function verify(
|
|
99
|
+
signature: Uint8Array,
|
|
100
|
+
message: Uint8Array,
|
|
101
|
+
publicKey: Uint8Array,
|
|
102
|
+
): Promise<boolean> {
|
|
103
|
+
const s = await ensureSodium();
|
|
104
|
+
return s.crypto_sign_verify_detached(signature, message, publicKey);
|
|
105
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ensureSodium } from './keys.js';
|
|
2
|
+
|
|
3
|
+
export interface QRPayload {
|
|
4
|
+
releyUrl: string;
|
|
5
|
+
publicKey: Uint8Array; // 32 bytes - X25519 public key
|
|
6
|
+
oneTimeCode: Uint8Array; // 32 bytes
|
|
7
|
+
jwt: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAGIC = 'CB1'; // Reley v1
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Encode a QR payload to a base64url string.
|
|
14
|
+
*/
|
|
15
|
+
export async function encodeQRPayload(payload: QRPayload): Promise<string> {
|
|
16
|
+
const s = await ensureSodium();
|
|
17
|
+
|
|
18
|
+
const releyBytes = new TextEncoder().encode(payload.releyUrl);
|
|
19
|
+
const jwtBytes = new TextEncoder().encode(payload.jwt);
|
|
20
|
+
|
|
21
|
+
// Format: MAGIC(3) + releyLen(2 BE) + reley + pubKey(32) + otc(32) + jwtLen(2 BE) + jwt
|
|
22
|
+
const totalLen =
|
|
23
|
+
3 + 2 + releyBytes.length + 32 + 32 + 2 + jwtBytes.length;
|
|
24
|
+
const buf = new Uint8Array(totalLen);
|
|
25
|
+
let offset = 0;
|
|
26
|
+
|
|
27
|
+
// Magic
|
|
28
|
+
buf[offset++] = MAGIC.charCodeAt(0);
|
|
29
|
+
buf[offset++] = MAGIC.charCodeAt(1);
|
|
30
|
+
buf[offset++] = MAGIC.charCodeAt(2);
|
|
31
|
+
|
|
32
|
+
// Reley URL length + data
|
|
33
|
+
buf[offset++] = (releyBytes.length >>> 8) & 0xff;
|
|
34
|
+
buf[offset++] = releyBytes.length & 0xff;
|
|
35
|
+
buf.set(releyBytes, offset);
|
|
36
|
+
offset += releyBytes.length;
|
|
37
|
+
|
|
38
|
+
// Public key (32 bytes)
|
|
39
|
+
buf.set(payload.publicKey, offset);
|
|
40
|
+
offset += 32;
|
|
41
|
+
|
|
42
|
+
// One-time code (32 bytes)
|
|
43
|
+
buf.set(payload.oneTimeCode, offset);
|
|
44
|
+
offset += 32;
|
|
45
|
+
|
|
46
|
+
// JWT length + data
|
|
47
|
+
buf[offset++] = (jwtBytes.length >>> 8) & 0xff;
|
|
48
|
+
buf[offset++] = jwtBytes.length & 0xff;
|
|
49
|
+
buf.set(jwtBytes, offset);
|
|
50
|
+
|
|
51
|
+
return s.to_base64(buf, s.base64_variants.URLSAFE_NO_PADDING);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decode a QR payload from a base64url string.
|
|
56
|
+
*/
|
|
57
|
+
export async function decodeQRPayload(encoded: string): Promise<QRPayload> {
|
|
58
|
+
const s = await ensureSodium();
|
|
59
|
+
|
|
60
|
+
const buf = s.from_base64(encoded, s.base64_variants.URLSAFE_NO_PADDING);
|
|
61
|
+
|
|
62
|
+
// Minimum: MAGIC(3) + releyLen(2) + pubKey(32) + otc(32) + jwtLen(2) = 71 bytes
|
|
63
|
+
if (buf.length < 71) {
|
|
64
|
+
throw new Error(`QR payload too short: ${buf.length} bytes (minimum 71)`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let offset = 0;
|
|
68
|
+
|
|
69
|
+
// Verify magic
|
|
70
|
+
const magic = String.fromCharCode(buf[offset++], buf[offset++], buf[offset++]);
|
|
71
|
+
if (magic !== MAGIC) {
|
|
72
|
+
throw new Error(`Invalid QR payload magic: ${magic}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Reley URL
|
|
76
|
+
const releyLen = (buf[offset] << 8) | buf[offset + 1];
|
|
77
|
+
offset += 2;
|
|
78
|
+
if (offset + releyLen + 32 + 32 + 2 > buf.length) {
|
|
79
|
+
throw new Error('QR payload truncated: reley URL overflows buffer');
|
|
80
|
+
}
|
|
81
|
+
const releyUrl = new TextDecoder().decode(buf.slice(offset, offset + releyLen));
|
|
82
|
+
offset += releyLen;
|
|
83
|
+
|
|
84
|
+
// Public key (32 bytes)
|
|
85
|
+
const publicKey = buf.slice(offset, offset + 32);
|
|
86
|
+
offset += 32;
|
|
87
|
+
|
|
88
|
+
// One-time code (32 bytes)
|
|
89
|
+
const oneTimeCode = buf.slice(offset, offset + 32);
|
|
90
|
+
offset += 32;
|
|
91
|
+
|
|
92
|
+
// JWT
|
|
93
|
+
if (offset + 2 > buf.length) {
|
|
94
|
+
throw new Error('QR payload truncated: missing JWT length');
|
|
95
|
+
}
|
|
96
|
+
const jwtLen = (buf[offset] << 8) | buf[offset + 1];
|
|
97
|
+
offset += 2;
|
|
98
|
+
if (offset + jwtLen > buf.length) {
|
|
99
|
+
throw new Error('QR payload truncated: JWT overflows buffer');
|
|
100
|
+
}
|
|
101
|
+
const jwt = new TextDecoder().decode(buf.slice(offset, offset + jwtLen));
|
|
102
|
+
|
|
103
|
+
return { releyUrl, publicKey, oneTimeCode, jwt };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Hash a one-time code (for safe transmission to reley).
|
|
108
|
+
*/
|
|
109
|
+
export async function hashOneTimeCode(otc: Uint8Array): Promise<Uint8Array> {
|
|
110
|
+
const s = await ensureSodium();
|
|
111
|
+
return s.crypto_generichash(32, otc, null);
|
|
112
|
+
}
|
package/src/ratchet.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { deriveChainKey } from './hkdf.js';
|
|
2
|
+
import { encrypt, decrypt } from './cipher.js';
|
|
3
|
+
|
|
4
|
+
export const KEY_ROTATION_INTERVAL = 50;
|
|
5
|
+
|
|
6
|
+
export interface RatchetState {
|
|
7
|
+
sendChainKey: Uint8Array;
|
|
8
|
+
recvChainKey: Uint8Array;
|
|
9
|
+
sendCounter: number;
|
|
10
|
+
recvCounter: number;
|
|
11
|
+
/** Highest received counter for replay protection */
|
|
12
|
+
maxRecvCounter: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Initialize a ratchet state from derived session keys.
|
|
17
|
+
*/
|
|
18
|
+
export function initRatchet(sendKey: Uint8Array, recvKey: Uint8Array): RatchetState {
|
|
19
|
+
return {
|
|
20
|
+
sendChainKey: sendKey,
|
|
21
|
+
recvChainKey: recvKey,
|
|
22
|
+
sendCounter: 0,
|
|
23
|
+
recvCounter: 0,
|
|
24
|
+
maxRecvCounter: -1,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Encrypt a message and advance the send ratchet.
|
|
30
|
+
*/
|
|
31
|
+
export async function ratchetEncrypt(
|
|
32
|
+
state: RatchetState,
|
|
33
|
+
plaintext: Uint8Array,
|
|
34
|
+
): Promise<{ ciphertext: Uint8Array; nonce: Uint8Array; counter: number; state: RatchetState }> {
|
|
35
|
+
const { messageKey, nextChainKey } = await deriveChainKey(state.sendChainKey);
|
|
36
|
+
const counter = state.sendCounter;
|
|
37
|
+
|
|
38
|
+
// Build AAD: counter as 4-byte big-endian
|
|
39
|
+
const aad = buildAAD(1, counter);
|
|
40
|
+
|
|
41
|
+
const { nonce, ciphertext } = await encrypt(plaintext, messageKey, aad);
|
|
42
|
+
|
|
43
|
+
const newState: RatchetState = {
|
|
44
|
+
...state,
|
|
45
|
+
sendChainKey: nextChainKey,
|
|
46
|
+
sendCounter: counter + 1,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return { ciphertext, nonce, counter, state: newState };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decrypt a message and advance the receive ratchet.
|
|
54
|
+
*/
|
|
55
|
+
export async function ratchetDecrypt(
|
|
56
|
+
state: RatchetState,
|
|
57
|
+
ciphertext: Uint8Array,
|
|
58
|
+
nonce: Uint8Array,
|
|
59
|
+
counter: number,
|
|
60
|
+
): Promise<{ plaintext: Uint8Array; state: RatchetState }> {
|
|
61
|
+
// Replay protection: reject messages with counter <= maxRecvCounter
|
|
62
|
+
if (counter <= state.maxRecvCounter) {
|
|
63
|
+
throw new Error(`Replay attack detected: counter ${counter} <= ${state.maxRecvCounter}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Prevent DoS via excessively large counter gaps.
|
|
67
|
+
// Over a WebSocket (TCP-ordered), gaps should never exceed a few messages.
|
|
68
|
+
const MAX_COUNTER_GAP = 1000;
|
|
69
|
+
const gap = counter - state.recvCounter;
|
|
70
|
+
if (gap > MAX_COUNTER_GAP) {
|
|
71
|
+
throw new Error(`Counter gap too large: ${gap} (max ${MAX_COUNTER_GAP})`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Advance the recv chain to the correct counter
|
|
75
|
+
let chainKey = state.recvChainKey;
|
|
76
|
+
let currentCounter = state.recvCounter;
|
|
77
|
+
let messageKey: Uint8Array | undefined;
|
|
78
|
+
|
|
79
|
+
while (currentCounter <= counter) {
|
|
80
|
+
const derived = await deriveChainKey(chainKey);
|
|
81
|
+
if (currentCounter === counter) {
|
|
82
|
+
messageKey = derived.messageKey;
|
|
83
|
+
}
|
|
84
|
+
chainKey = derived.nextChainKey;
|
|
85
|
+
currentCounter++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!messageKey) {
|
|
89
|
+
throw new Error('Failed to derive message key');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const aad = buildAAD(1, counter);
|
|
93
|
+
const plaintext = await decrypt(ciphertext, nonce, messageKey, aad);
|
|
94
|
+
|
|
95
|
+
const newState: RatchetState = {
|
|
96
|
+
...state,
|
|
97
|
+
recvChainKey: chainKey,
|
|
98
|
+
recvCounter: currentCounter,
|
|
99
|
+
maxRecvCounter: counter,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return { plaintext, state: newState };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if key rotation is needed.
|
|
107
|
+
*/
|
|
108
|
+
export function needsKeyRotation(state: RatchetState): boolean {
|
|
109
|
+
return state.sendCounter > 0 && state.sendCounter % KEY_ROTATION_INTERVAL === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build AAD bytes: version (1 byte) + type (1 byte) + counter (4 bytes BE)
|
|
114
|
+
*/
|
|
115
|
+
function buildAAD(version: number, counter: number): Uint8Array {
|
|
116
|
+
const aad = new Uint8Array(6);
|
|
117
|
+
aad[0] = version;
|
|
118
|
+
aad[1] = 0x01; // message type
|
|
119
|
+
aad[2] = (counter >>> 24) & 0xff;
|
|
120
|
+
aad[3] = (counter >>> 16) & 0xff;
|
|
121
|
+
aad[4] = (counter >>> 8) & 0xff;
|
|
122
|
+
aad[5] = counter & 0xff;
|
|
123
|
+
return aad;
|
|
124
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ensureSodium } from './keys.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute a human-readable fingerprint from two public keys for MITM verification.
|
|
5
|
+
* Keys are sorted lexicographically to ensure both parties produce the same fingerprint.
|
|
6
|
+
* Uses Blake2b-128 hash, formatted as uppercase hex groups: "XXXX-XXXX-..."
|
|
7
|
+
*/
|
|
8
|
+
export async function computeFingerprint(
|
|
9
|
+
pk1: Uint8Array,
|
|
10
|
+
pk2: Uint8Array,
|
|
11
|
+
): Promise<string> {
|
|
12
|
+
const s = await ensureSodium();
|
|
13
|
+
const sorted = [pk1, pk2].sort((a, b) => {
|
|
14
|
+
for (let i = 0; i < a.length; i++) {
|
|
15
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
16
|
+
}
|
|
17
|
+
return 0;
|
|
18
|
+
});
|
|
19
|
+
const combined = new Uint8Array(sorted[0].length + sorted[1].length);
|
|
20
|
+
combined.set(sorted[0], 0);
|
|
21
|
+
combined.set(sorted[1], sorted[0].length);
|
|
22
|
+
const hash = s.crypto_generichash(16, combined, null);
|
|
23
|
+
const hex = s.to_hex(hash).toUpperCase();
|
|
24
|
+
return hex.match(/.{4}/g)!.join('-');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Encode a Uint8Array to a base64url string (no padding).
|
|
29
|
+
*/
|
|
30
|
+
export async function toBase64Url(data: Uint8Array): Promise<string> {
|
|
31
|
+
const s = await ensureSodium();
|
|
32
|
+
return s.to_base64(data, s.base64_variants.URLSAFE_NO_PADDING);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decode a base64url string to a Uint8Array.
|
|
37
|
+
*/
|
|
38
|
+
export async function fromBase64Url(str: string): Promise<Uint8Array> {
|
|
39
|
+
const s = await ensureSodium();
|
|
40
|
+
return s.from_base64(str, s.base64_variants.URLSAFE_NO_PADDING);
|
|
41
|
+
}
|