@immahq/aegis 0.0.1
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/LICENSE +21 -0
- package/README.md +257 -0
- package/dist/constants.js +42 -0
- package/dist/crypto-manager.js +306 -0
- package/dist/e2ee.js +207 -0
- package/dist/group-manager.js +606 -0
- package/dist/identity-manager.js +112 -0
- package/dist/index.js +11 -0
- package/dist/logger.js +23 -0
- package/dist/prekey-manager.js +63 -0
- package/dist/ratchet-manager.js +130 -0
- package/dist/ratchet.js +168 -0
- package/dist/replay-protection.js +37 -0
- package/dist/session-manager.js +177 -0
- package/dist/session.js +131 -0
- package/dist/storage.js +54 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +27 -0
- package/package.json +54 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
|
2
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
3
|
+
import { blake3 } from "@noble/hashes/blake3.js";
|
|
4
|
+
import { bytesToHex, concatBytes } from "@noble/hashes/utils.js";
|
|
5
|
+
import { Logger } from "./logger.js";
|
|
6
|
+
import { PreKeyManager } from "./prekey-manager.js";
|
|
7
|
+
import { ERRORS } from "./constants.js";
|
|
8
|
+
export class IdentityManager {
|
|
9
|
+
constructor(storage) {
|
|
10
|
+
Object.defineProperty(this, "storage", {
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
writable: true,
|
|
14
|
+
value: void 0
|
|
15
|
+
});
|
|
16
|
+
Object.defineProperty(this, "preKeyManager", {
|
|
17
|
+
enumerable: true,
|
|
18
|
+
configurable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
value: void 0
|
|
21
|
+
});
|
|
22
|
+
this.storage = storage;
|
|
23
|
+
this.preKeyManager = new PreKeyManager();
|
|
24
|
+
}
|
|
25
|
+
async createIdentity() {
|
|
26
|
+
try {
|
|
27
|
+
Logger.log("Identity", "Creating new identity");
|
|
28
|
+
const kemKeyPair = ml_kem768.keygen();
|
|
29
|
+
const dsaKeyPair = ml_dsa65.keygen();
|
|
30
|
+
const userId = bytesToHex(blake3(concatBytes(kemKeyPair.publicKey, dsaKeyPair.publicKey), {
|
|
31
|
+
dkLen: 32,
|
|
32
|
+
}));
|
|
33
|
+
const preKeyPair = ml_kem768.keygen();
|
|
34
|
+
const preKeySignature = ml_dsa65.sign(preKeyPair.publicKey, dsaKeyPair.secretKey);
|
|
35
|
+
const preKey = {
|
|
36
|
+
id: 1,
|
|
37
|
+
keyPair: preKeyPair,
|
|
38
|
+
signature: preKeySignature,
|
|
39
|
+
used: false,
|
|
40
|
+
createdAt: Date.now(),
|
|
41
|
+
};
|
|
42
|
+
await this.preKeyManager.savePreKey(preKey);
|
|
43
|
+
const identity = {
|
|
44
|
+
kemKeyPair,
|
|
45
|
+
dsaKeyPair,
|
|
46
|
+
userId,
|
|
47
|
+
createdAt: Date.now(),
|
|
48
|
+
preKeySecret: preKeyPair.secretKey,
|
|
49
|
+
};
|
|
50
|
+
const publicBundle = {
|
|
51
|
+
userId,
|
|
52
|
+
kemPublicKey: kemKeyPair.publicKey,
|
|
53
|
+
dsaPublicKey: dsaKeyPair.publicKey,
|
|
54
|
+
preKey: {
|
|
55
|
+
id: preKey.id,
|
|
56
|
+
key: preKeyPair.publicKey,
|
|
57
|
+
signature: preKeySignature,
|
|
58
|
+
},
|
|
59
|
+
createdAt: Date.now(),
|
|
60
|
+
};
|
|
61
|
+
await this.storage.saveIdentity(identity);
|
|
62
|
+
Logger.log("Identity", "Identity created successfully", {
|
|
63
|
+
userId: userId.substring(0, 16) + "...",
|
|
64
|
+
});
|
|
65
|
+
return { identity, publicBundle };
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
Logger.error("Identity", "Failed to create identity", error);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async getIdentity() {
|
|
73
|
+
const identity = await this.storage.getIdentity();
|
|
74
|
+
if (!identity)
|
|
75
|
+
throw new Error(ERRORS.IDENTITY_NOT_FOUND);
|
|
76
|
+
return identity;
|
|
77
|
+
}
|
|
78
|
+
async getPublicBundle() {
|
|
79
|
+
const identity = await this.getIdentity();
|
|
80
|
+
const preKey = await this.preKeyManager.getUnusedPreKey();
|
|
81
|
+
if (!preKey) {
|
|
82
|
+
const newPreKey = await this.preKeyManager.generatePreKey(identity);
|
|
83
|
+
return {
|
|
84
|
+
userId: identity.userId,
|
|
85
|
+
kemPublicKey: identity.kemKeyPair.publicKey,
|
|
86
|
+
dsaPublicKey: identity.dsaKeyPair.publicKey,
|
|
87
|
+
preKey: {
|
|
88
|
+
id: newPreKey.id,
|
|
89
|
+
key: newPreKey.keyPair.publicKey,
|
|
90
|
+
signature: newPreKey.signature,
|
|
91
|
+
},
|
|
92
|
+
createdAt: identity.createdAt,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
userId: identity.userId,
|
|
97
|
+
kemPublicKey: identity.kemKeyPair.publicKey,
|
|
98
|
+
dsaPublicKey: identity.dsaKeyPair.publicKey,
|
|
99
|
+
preKey: {
|
|
100
|
+
id: preKey.id,
|
|
101
|
+
key: preKey.keyPair.publicKey,
|
|
102
|
+
signature: preKey.signature,
|
|
103
|
+
},
|
|
104
|
+
createdAt: identity.createdAt,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
async rotateIdentity() {
|
|
108
|
+
const result = await this.createIdentity();
|
|
109
|
+
await this.preKeyManager.clear();
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { E2EE as Aegis } from "./e2ee.js";
|
|
2
|
+
export { MemoryStorage } from "./storage.js";
|
|
3
|
+
export { Logger } from "./logger.js";
|
|
4
|
+
export { KemRatchet } from "./ratchet.js";
|
|
5
|
+
export { SessionKeyExchange } from "./session.js";
|
|
6
|
+
export { IdentityManager } from "./identity-manager.js";
|
|
7
|
+
export { SessionManager } from "./session-manager.js";
|
|
8
|
+
export { CryptoManager } from "./crypto-manager.js";
|
|
9
|
+
export { RatchetManager } from "./ratchet-manager.js";
|
|
10
|
+
export { ReplayProtection } from "./replay-protection.js";
|
|
11
|
+
export { GroupManager } from "./group-manager.js";
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class Logger {
|
|
2
|
+
static log(component, message, data) {
|
|
3
|
+
const safeData = data ? { ...data } : undefined;
|
|
4
|
+
// Remove any sensitive data from logs
|
|
5
|
+
if (safeData) {
|
|
6
|
+
const sensitiveKeys = [
|
|
7
|
+
"secretKey",
|
|
8
|
+
"privateKey",
|
|
9
|
+
"rootKey",
|
|
10
|
+
"chainKey",
|
|
11
|
+
"ciphertext",
|
|
12
|
+
];
|
|
13
|
+
sensitiveKeys.forEach((key) => delete safeData[key]);
|
|
14
|
+
}
|
|
15
|
+
console.log(`[Aegis:${component}] ${message}`, safeData || "");
|
|
16
|
+
}
|
|
17
|
+
static error(component, message, error) {
|
|
18
|
+
console.error(`[Aegis:${component}] ERROR: ${message}`, error || "");
|
|
19
|
+
}
|
|
20
|
+
static warn(component, message, data) {
|
|
21
|
+
console.warn(`[Aegis:${component}] WARN: ${message}`, data || "");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
|
2
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
3
|
+
export class PreKeyManager {
|
|
4
|
+
constructor() {
|
|
5
|
+
Object.defineProperty(this, "preKeys", {
|
|
6
|
+
enumerable: true,
|
|
7
|
+
configurable: true,
|
|
8
|
+
writable: true,
|
|
9
|
+
value: new Map()
|
|
10
|
+
});
|
|
11
|
+
Object.defineProperty(this, "nextPreKeyId", {
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
writable: true,
|
|
15
|
+
value: 1
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async generatePreKey(identity) {
|
|
19
|
+
const preKeyPair = ml_kem768.keygen();
|
|
20
|
+
const preKeySignature = ml_dsa65.sign(preKeyPair.publicKey, identity.dsaKeyPair.secretKey);
|
|
21
|
+
const preKey = {
|
|
22
|
+
id: this.nextPreKeyId++,
|
|
23
|
+
keyPair: preKeyPair,
|
|
24
|
+
signature: preKeySignature,
|
|
25
|
+
used: false,
|
|
26
|
+
createdAt: Date.now(),
|
|
27
|
+
};
|
|
28
|
+
this.preKeys.set(preKey.id, preKey);
|
|
29
|
+
return preKey;
|
|
30
|
+
}
|
|
31
|
+
async savePreKey(preKey) {
|
|
32
|
+
this.preKeys.set(preKey.id, preKey);
|
|
33
|
+
}
|
|
34
|
+
getPreKey(id) {
|
|
35
|
+
return this.preKeys.get(id) || null;
|
|
36
|
+
}
|
|
37
|
+
getUnusedPreKey() {
|
|
38
|
+
for (const preKey of this.preKeys.values()) {
|
|
39
|
+
if (!preKey.used) {
|
|
40
|
+
return preKey;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
markPreKeyAsUsed(id) {
|
|
46
|
+
const preKey = this.preKeys.get(id);
|
|
47
|
+
if (preKey) {
|
|
48
|
+
preKey.used = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
removeOldPreKeys(maxAge = 7 * 24 * 60 * 60 * 1000) {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
for (const [id, preKey] of this.preKeys.entries()) {
|
|
54
|
+
if (now - preKey.createdAt > maxAge) {
|
|
55
|
+
this.preKeys.delete(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
clear() {
|
|
60
|
+
this.preKeys.clear();
|
|
61
|
+
this.nextPreKeyId = 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
2
|
+
import { Logger } from "./logger.js";
|
|
3
|
+
import { RATCHET_AFTER_MESSAGES } from "./constants.js";
|
|
4
|
+
import { KemRatchet } from "./ratchet.js";
|
|
5
|
+
export class RatchetManager {
|
|
6
|
+
shouldPerformSendingRatchet(session) {
|
|
7
|
+
const messageCount = session.sendingChain?.messageNumber || 0;
|
|
8
|
+
const isFirstMessageAsInitiator = session.isInitiator && messageCount === 0;
|
|
9
|
+
return (session.peerRatchetPublicKey !== null &&
|
|
10
|
+
!isFirstMessageAsInitiator &&
|
|
11
|
+
messageCount >= RATCHET_AFTER_MESSAGES);
|
|
12
|
+
}
|
|
13
|
+
needsReceivingRatchet(session, header) {
|
|
14
|
+
return (header.isRatchetMessage === true ||
|
|
15
|
+
(session.peerRatchetPublicKey !== null &&
|
|
16
|
+
bytesToHex(header.ratchetPublicKey) !==
|
|
17
|
+
bytesToHex(session.peerRatchetPublicKey)));
|
|
18
|
+
}
|
|
19
|
+
performSendingRatchet(session) {
|
|
20
|
+
if (!session.peerRatchetPublicKey) {
|
|
21
|
+
throw new Error("No peer ratchet public key available for ratchet");
|
|
22
|
+
}
|
|
23
|
+
const result = KemRatchet.performKemRatchetEncapsulate(session.rootKey, session.peerRatchetPublicKey);
|
|
24
|
+
const newSession = { ...session };
|
|
25
|
+
const pendingRatchetState = {
|
|
26
|
+
newRootKey: result.newRootKey,
|
|
27
|
+
newRatchetKeyPair: result.newRatchetKeyPair,
|
|
28
|
+
sendingChain: result.sendingChain,
|
|
29
|
+
receivingChain: result.receivingChain,
|
|
30
|
+
kemCiphertext: result.kemCiphertext,
|
|
31
|
+
previousReceivingChain: session.receivingChain,
|
|
32
|
+
previousSendingChain: session.sendingChain,
|
|
33
|
+
};
|
|
34
|
+
newSession.pendingRatchetState = pendingRatchetState;
|
|
35
|
+
newSession.previousSendingChainLength =
|
|
36
|
+
session.sendingChain?.messageNumber ?? 0;
|
|
37
|
+
newSession.ratchetCount++;
|
|
38
|
+
newSession.state = "RATCHET_PENDING";
|
|
39
|
+
Logger.log("Ratchet", "Prepared sending KEM ratchet", {
|
|
40
|
+
ratchetCount: newSession.ratchetCount,
|
|
41
|
+
messageNumber: session.sendingChain?.messageNumber || 0,
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
session: newSession,
|
|
45
|
+
kemCiphertext: result.kemCiphertext,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
performReceivingRatchet(session, kemCiphertext) {
|
|
49
|
+
if (!session.currentRatchetKeyPair?.secretKey) {
|
|
50
|
+
throw new Error("No current ratchet secret key available");
|
|
51
|
+
}
|
|
52
|
+
const result = KemRatchet.performKemRatchetDecapsulate(session.rootKey, kemCiphertext, session.currentRatchetKeyPair.secretKey);
|
|
53
|
+
const previousReceivingChain = session.receivingChain;
|
|
54
|
+
const updatedSession = { ...session };
|
|
55
|
+
updatedSession.rootKey = result.newRootKey;
|
|
56
|
+
updatedSession.currentRatchetKeyPair = result.newRatchetKeyPair;
|
|
57
|
+
updatedSession.sendingChain = result.sendingChain;
|
|
58
|
+
updatedSession.receivingChain = result.receivingChain;
|
|
59
|
+
updatedSession.ratchetCount++;
|
|
60
|
+
updatedSession.state = "ACTIVE";
|
|
61
|
+
updatedSession.pendingRatchetState = {
|
|
62
|
+
newRootKey: result.newRootKey,
|
|
63
|
+
newRatchetKeyPair: result.newRatchetKeyPair,
|
|
64
|
+
sendingChain: result.sendingChain,
|
|
65
|
+
receivingChain: result.receivingChain,
|
|
66
|
+
kemCiphertext,
|
|
67
|
+
previousReceivingChain,
|
|
68
|
+
previousSendingChain: session.sendingChain,
|
|
69
|
+
};
|
|
70
|
+
Logger.log("Ratchet", "Performed receiving KEM ratchet", {
|
|
71
|
+
ratchetCount: updatedSession.ratchetCount,
|
|
72
|
+
hasOldChain: previousReceivingChain !== null,
|
|
73
|
+
});
|
|
74
|
+
return updatedSession;
|
|
75
|
+
}
|
|
76
|
+
applyPendingRatchet(session) {
|
|
77
|
+
if (!session.pendingRatchetState) {
|
|
78
|
+
return session;
|
|
79
|
+
}
|
|
80
|
+
const { pendingRatchetState } = session;
|
|
81
|
+
const updatedSession = { ...session };
|
|
82
|
+
updatedSession.rootKey = pendingRatchetState.newRootKey;
|
|
83
|
+
updatedSession.currentRatchetKeyPair =
|
|
84
|
+
pendingRatchetState.newRatchetKeyPair;
|
|
85
|
+
updatedSession.sendingChain = pendingRatchetState.sendingChain;
|
|
86
|
+
updatedSession.receivingChain = pendingRatchetState.receivingChain;
|
|
87
|
+
updatedSession.pendingRatchetState = undefined;
|
|
88
|
+
updatedSession.state = "ACTIVE";
|
|
89
|
+
Logger.log("Ratchet", "Applied pending ratchet state", {
|
|
90
|
+
ratchetCount: updatedSession.ratchetCount,
|
|
91
|
+
});
|
|
92
|
+
return updatedSession;
|
|
93
|
+
}
|
|
94
|
+
async triggerRatchet(sessionId, session) {
|
|
95
|
+
if (!session.peerRatchetPublicKey) {
|
|
96
|
+
throw new Error("No peer ratchet public key available");
|
|
97
|
+
}
|
|
98
|
+
const result = KemRatchet.performKemRatchetEncapsulate(session.rootKey, session.peerRatchetPublicKey);
|
|
99
|
+
const newSession = {
|
|
100
|
+
...session,
|
|
101
|
+
rootKey: result.newRootKey,
|
|
102
|
+
currentRatchetKeyPair: result.newRatchetKeyPair,
|
|
103
|
+
sendingChain: result.sendingChain,
|
|
104
|
+
receivingChain: result.receivingChain,
|
|
105
|
+
previousSendingChainLength: session.sendingChain?.messageNumber ?? 0,
|
|
106
|
+
ratchetCount: session.ratchetCount + 1,
|
|
107
|
+
state: "ACTIVE",
|
|
108
|
+
pendingRatchetState: {
|
|
109
|
+
newRootKey: result.newRootKey,
|
|
110
|
+
newRatchetKeyPair: result.newRatchetKeyPair,
|
|
111
|
+
sendingChain: result.sendingChain,
|
|
112
|
+
receivingChain: result.receivingChain,
|
|
113
|
+
kemCiphertext: result.kemCiphertext,
|
|
114
|
+
previousReceivingChain: session.receivingChain,
|
|
115
|
+
previousSendingChain: session.sendingChain,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
Logger.log("Ratchet", "Manually triggered ratchet", {
|
|
119
|
+
sessionId: sessionId.substring(0, 16) + "...",
|
|
120
|
+
newRatchetCount: newSession.ratchetCount,
|
|
121
|
+
});
|
|
122
|
+
return newSession;
|
|
123
|
+
}
|
|
124
|
+
getDecryptionChainForRatchetMessage(session) {
|
|
125
|
+
if (!session.pendingRatchetState?.previousReceivingChain) {
|
|
126
|
+
return session.receivingChain;
|
|
127
|
+
}
|
|
128
|
+
return session.pendingRatchetState.previousReceivingChain;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/dist/ratchet.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
|
2
|
+
import { blake3 } from "@noble/hashes/blake3.js";
|
|
3
|
+
import { concatBytes } from "@noble/hashes/utils.js";
|
|
4
|
+
import { ML_KEM768_PUBLIC_KEY_LENGTH } from "./constants";
|
|
5
|
+
export class KemRatchet {
|
|
6
|
+
// Perform KEM ratchet as encapsulator (when we send a new ratchet key)
|
|
7
|
+
static performKemRatchetEncapsulate(rootKey, peerRatchetPublicKey) {
|
|
8
|
+
if (!peerRatchetPublicKey) {
|
|
9
|
+
throw new Error("peerRatchetPublicKey is required for KEM ratchet encapsulation");
|
|
10
|
+
}
|
|
11
|
+
// Validate peer's ratchet public key
|
|
12
|
+
if (!this.validateRatchetPublicKey(peerRatchetPublicKey)) {
|
|
13
|
+
throw new Error("Invalid peer ratchet public key");
|
|
14
|
+
}
|
|
15
|
+
// Generate new ratchet keypair for NEXT round
|
|
16
|
+
const newRatchetKeyPair = ml_kem768.keygen();
|
|
17
|
+
// Perform KEM encapsulation to peer's CURRENT ratchet public key
|
|
18
|
+
const { sharedSecret, cipherText } = ml_kem768.encapsulate(peerRatchetPublicKey);
|
|
19
|
+
// Derive new root key
|
|
20
|
+
const newRootKey = this.deriveKey(rootKey, sharedSecret, this.CONTEXT_ROOT_KEY);
|
|
21
|
+
// Derive sending and receiving chain keys
|
|
22
|
+
const sendingChainKey = this.deriveKey(newRootKey, cipherText, this.CONTEXT_CHAIN_KEY);
|
|
23
|
+
const receivingChainKey = this.deriveKey(newRootKey, sharedSecret, this.CONTEXT_CHAIN_KEY);
|
|
24
|
+
return {
|
|
25
|
+
newRootKey,
|
|
26
|
+
newRatchetKeyPair,
|
|
27
|
+
sendingChain: {
|
|
28
|
+
chainKey: sendingChainKey,
|
|
29
|
+
messageNumber: 0,
|
|
30
|
+
},
|
|
31
|
+
receivingChain: {
|
|
32
|
+
chainKey: receivingChainKey,
|
|
33
|
+
messageNumber: 0,
|
|
34
|
+
},
|
|
35
|
+
kemCiphertext: cipherText,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Perform KEM ratchet as decapsulator (when we receive a new ratchet key)
|
|
39
|
+
static performKemRatchetDecapsulate(rootKey, kemCiphertext, currentRatchetSecretKey) {
|
|
40
|
+
if (!kemCiphertext || kemCiphertext.length === 0) {
|
|
41
|
+
throw new Error("KEM ciphertext is required for decapsulation");
|
|
42
|
+
}
|
|
43
|
+
// Decapsulate the KEM cipherText using our current secret key
|
|
44
|
+
const sharedSecret = ml_kem768.decapsulate(kemCiphertext, currentRatchetSecretKey);
|
|
45
|
+
// Generate new ratchet keypair for next round
|
|
46
|
+
const newRatchetKeyPair = ml_kem768.keygen();
|
|
47
|
+
// Derive new root key (must match encapsulator's derivation)
|
|
48
|
+
const newRootKey = this.deriveKey(rootKey, sharedSecret, this.CONTEXT_ROOT_KEY);
|
|
49
|
+
// Derive chain keys (roles are swapped compared to encapsulator)
|
|
50
|
+
const sendingChainKey = this.deriveKey(newRootKey, sharedSecret, this.CONTEXT_CHAIN_KEY);
|
|
51
|
+
const receivingChainKey = this.deriveKey(newRootKey, kemCiphertext, this.CONTEXT_CHAIN_KEY);
|
|
52
|
+
return {
|
|
53
|
+
newRootKey,
|
|
54
|
+
newRatchetKeyPair,
|
|
55
|
+
sendingChain: {
|
|
56
|
+
chainKey: sendingChainKey,
|
|
57
|
+
messageNumber: 0,
|
|
58
|
+
},
|
|
59
|
+
receivingChain: {
|
|
60
|
+
chainKey: receivingChainKey,
|
|
61
|
+
messageNumber: 0,
|
|
62
|
+
},
|
|
63
|
+
kemCiphertext: new Uint8Array(0),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// Generate confirmation MAC for session keys
|
|
67
|
+
static generateConfirmationMac(sessionId, rootKey, chainKey, isResponse = false) {
|
|
68
|
+
const context = isResponse
|
|
69
|
+
? new Uint8Array([0x06]) // Different context for response
|
|
70
|
+
: this.CONTEXT_CONFIRMATION;
|
|
71
|
+
const data = concatBytes(new TextEncoder().encode(sessionId), rootKey, chainKey, new Uint8Array([isResponse ? 1 : 0]));
|
|
72
|
+
return this.deriveKey(rootKey, data, context);
|
|
73
|
+
}
|
|
74
|
+
// Verify confirmation MAC
|
|
75
|
+
static verifyConfirmationMac(sessionId, rootKey, chainKey, receivedMac, isResponse = false) {
|
|
76
|
+
const expectedMac = this.generateConfirmationMac(sessionId, rootKey, chainKey, isResponse);
|
|
77
|
+
// Constant-time comparison
|
|
78
|
+
if (expectedMac.length !== receivedMac.length)
|
|
79
|
+
return false;
|
|
80
|
+
let result = 0;
|
|
81
|
+
for (let i = 0; i < expectedMac.length; i++) {
|
|
82
|
+
result |= expectedMac[i] ^ receivedMac[i];
|
|
83
|
+
}
|
|
84
|
+
return result === 0;
|
|
85
|
+
}
|
|
86
|
+
// Symmetric ratchet for deriving message keys within a chain
|
|
87
|
+
static symmetricRatchet(chain) {
|
|
88
|
+
// Derive message key from current chain key
|
|
89
|
+
const messageKey = this.deriveKey(chain.chainKey, new Uint8Array([0x00]), this.CONTEXT_MESSAGE_KEY);
|
|
90
|
+
// Derive new chain key
|
|
91
|
+
const newChainKey = this.deriveKey(chain.chainKey, new Uint8Array([0x01]), this.CONTEXT_CHAIN_KEY);
|
|
92
|
+
const newChain = {
|
|
93
|
+
chainKey: newChainKey,
|
|
94
|
+
messageNumber: chain.messageNumber + 1,
|
|
95
|
+
};
|
|
96
|
+
return { messageKey, newChain };
|
|
97
|
+
}
|
|
98
|
+
// Skip message keys for out-of-order messages
|
|
99
|
+
static skipMessageKeys(chain, targetMessageNumber, maxSkip) {
|
|
100
|
+
const skippedKeys = new Map();
|
|
101
|
+
if (targetMessageNumber <= chain.messageNumber) {
|
|
102
|
+
throw new Error("Cannot skip to earlier message number");
|
|
103
|
+
}
|
|
104
|
+
const skipCount = targetMessageNumber - chain.messageNumber;
|
|
105
|
+
if (skipCount > maxSkip) {
|
|
106
|
+
throw new Error(`Skip count ${skipCount} exceeds maximum ${maxSkip}`);
|
|
107
|
+
}
|
|
108
|
+
let currentChain = { ...chain };
|
|
109
|
+
// Generate keys for skipped messages
|
|
110
|
+
for (let i = 0; i < skipCount; i++) {
|
|
111
|
+
const { messageKey, newChain } = this.symmetricRatchet(currentChain);
|
|
112
|
+
skippedKeys.set(currentChain.messageNumber, messageKey);
|
|
113
|
+
currentChain = newChain;
|
|
114
|
+
}
|
|
115
|
+
return { skippedKeys, newChain: currentChain };
|
|
116
|
+
}
|
|
117
|
+
// Helper method for key derivation
|
|
118
|
+
static deriveKey(key, data, context) {
|
|
119
|
+
return blake3(concatBytes(key, data, context), { dkLen: 32 });
|
|
120
|
+
}
|
|
121
|
+
// Generate a fresh ratchet keypair
|
|
122
|
+
static generateRatchetKeyPair() {
|
|
123
|
+
return ml_kem768.keygen();
|
|
124
|
+
}
|
|
125
|
+
// Check if we should perform a KEM ratchet
|
|
126
|
+
static shouldPerformRatchet(messageCount, lastRatchetTime, maxMessages = 100, maxTime = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
127
|
+
) {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
return messageCount >= maxMessages || now - lastRatchetTime >= maxTime;
|
|
130
|
+
}
|
|
131
|
+
// Validate a ratchet public key
|
|
132
|
+
static validateRatchetPublicKey(publicKey) {
|
|
133
|
+
return (publicKey instanceof Uint8Array &&
|
|
134
|
+
publicKey.length === ML_KEM768_PUBLIC_KEY_LENGTH);
|
|
135
|
+
}
|
|
136
|
+
// Derive a unique ratchet identifier
|
|
137
|
+
static deriveRatchetId(publicKey, chainKey) {
|
|
138
|
+
const hash = blake3(concatBytes(publicKey, chainKey), { dkLen: 16 });
|
|
139
|
+
return Array.from(hash)
|
|
140
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
141
|
+
.join("");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Constants for key derivation contexts
|
|
145
|
+
Object.defineProperty(KemRatchet, "CONTEXT_ROOT_KEY", {
|
|
146
|
+
enumerable: true,
|
|
147
|
+
configurable: true,
|
|
148
|
+
writable: true,
|
|
149
|
+
value: new Uint8Array([0x01])
|
|
150
|
+
});
|
|
151
|
+
Object.defineProperty(KemRatchet, "CONTEXT_CHAIN_KEY", {
|
|
152
|
+
enumerable: true,
|
|
153
|
+
configurable: true,
|
|
154
|
+
writable: true,
|
|
155
|
+
value: new Uint8Array([0x02])
|
|
156
|
+
});
|
|
157
|
+
Object.defineProperty(KemRatchet, "CONTEXT_MESSAGE_KEY", {
|
|
158
|
+
enumerable: true,
|
|
159
|
+
configurable: true,
|
|
160
|
+
writable: true,
|
|
161
|
+
value: new Uint8Array([0x03])
|
|
162
|
+
});
|
|
163
|
+
Object.defineProperty(KemRatchet, "CONTEXT_CONFIRMATION", {
|
|
164
|
+
enumerable: true,
|
|
165
|
+
configurable: true,
|
|
166
|
+
writable: true,
|
|
167
|
+
value: new Uint8Array([0x05])
|
|
168
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MAX_STORED_MESSAGE_IDS } from "./constants.js";
|
|
2
|
+
export class ReplayProtection {
|
|
3
|
+
getSkippedKeyId(ratchetPublicKey, messageNumber) {
|
|
4
|
+
return `${this.bytesToHex(ratchetPublicKey)}:${messageNumber}`;
|
|
5
|
+
}
|
|
6
|
+
bytesToHex(bytes) {
|
|
7
|
+
return Array.from(bytes)
|
|
8
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
9
|
+
.join("");
|
|
10
|
+
}
|
|
11
|
+
cleanupSkippedKeys(session) {
|
|
12
|
+
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [keyId, key] of session.skippedMessageKeys.entries()) {
|
|
15
|
+
if (now - key.timestamp > maxAge) {
|
|
16
|
+
session.skippedMessageKeys.delete(keyId);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Simple replay protection: Store received message IDs
|
|
21
|
+
storeReceivedMessageId(session, messageId) {
|
|
22
|
+
session.receivedMessageIds.add(messageId);
|
|
23
|
+
// Keep the set size manageable
|
|
24
|
+
if (session.receivedMessageIds.size > MAX_STORED_MESSAGE_IDS) {
|
|
25
|
+
// Remove oldest entries (Set doesn't have order, so we recreate)
|
|
26
|
+
const ids = Array.from(session.receivedMessageIds);
|
|
27
|
+
session.receivedMessageIds = new Set(ids.slice(-MAX_STORED_MESSAGE_IDS));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async getReplayProtectionStatus(_sessionId, session) {
|
|
31
|
+
return {
|
|
32
|
+
storedMessageIds: session.receivedMessageIds.size,
|
|
33
|
+
lastProcessedTimestamp: session.lastProcessedTimestamp,
|
|
34
|
+
replayWindowSize: session.replayWindowSize,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|