@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.
@@ -0,0 +1,177 @@
1
+ import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
2
+ import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
3
+ import { Logger } from "./logger.js";
4
+ import { ERRORS } from "./constants.js";
5
+ import { SessionKeyExchange } from "./session.js";
6
+ import { validatePublicBundle } from "./utils.js";
7
+ export class SessionManager {
8
+ constructor(storage) {
9
+ Object.defineProperty(this, "storage", {
10
+ enumerable: true,
11
+ configurable: true,
12
+ writable: true,
13
+ value: void 0
14
+ });
15
+ this.storage = storage;
16
+ }
17
+ async createSession(identity, peerBundle) {
18
+ try {
19
+ Logger.log("Session", "Creating new session as initiator");
20
+ validatePublicBundle(peerBundle);
21
+ const isValid = ml_dsa65.verify(peerBundle.preKey.signature, peerBundle.preKey.key, peerBundle.dsaPublicKey);
22
+ if (!isValid) {
23
+ throw new Error(ERRORS.INVALID_PREKEY_SIGNATURE);
24
+ }
25
+ const { sessionId, rootKey, sendingChainKey, receivingChainKey, ciphertext, confirmationMac, } = SessionKeyExchange.createInitiatorSession(identity, peerBundle);
26
+ const ratchetKeyPair = ml_kem768.keygen();
27
+ const session = {
28
+ sessionId,
29
+ peerUserId: peerBundle.userId,
30
+ peerDsaPublicKey: peerBundle.dsaPublicKey,
31
+ rootKey,
32
+ currentRatchetKeyPair: ratchetKeyPair,
33
+ peerRatchetPublicKey: null,
34
+ sendingChain: {
35
+ chainKey: sendingChainKey,
36
+ messageNumber: 0,
37
+ },
38
+ receivingChain: {
39
+ chainKey: receivingChainKey,
40
+ messageNumber: 0,
41
+ },
42
+ previousSendingChainLength: 0,
43
+ skippedMessageKeys: new Map(),
44
+ highestReceivedMessageNumber: -1,
45
+ maxSkippedMessages: 100,
46
+ createdAt: Date.now(),
47
+ lastUsed: Date.now(),
48
+ isInitiator: true,
49
+ ratchetCount: 0,
50
+ state: "CREATED",
51
+ confirmed: false,
52
+ confirmationMac,
53
+ // Simple replay protection
54
+ receivedMessageIds: new Set(),
55
+ replayWindowSize: 100,
56
+ lastProcessedTimestamp: Date.now(),
57
+ };
58
+ await this.storage.saveSession(sessionId, session);
59
+ Logger.log("Session", "Session created successfully as initiator", {
60
+ sessionId: sessionId.substring(0, 16) + "...",
61
+ });
62
+ return { sessionId, ciphertext, confirmationMac };
63
+ }
64
+ catch (error) {
65
+ Logger.error("Session", "Failed to create session", error);
66
+ throw error;
67
+ }
68
+ }
69
+ async createResponderSession(identity, peerBundle, ciphertext, initiatorConfirmationMac) {
70
+ try {
71
+ Logger.log("Session", "Creating session as responder");
72
+ validatePublicBundle(peerBundle);
73
+ const isValidSignature = ml_dsa65.verify(peerBundle.preKey.signature, peerBundle.preKey.key, peerBundle.dsaPublicKey);
74
+ if (!isValidSignature) {
75
+ throw new Error(ERRORS.INVALID_PREKEY_SIGNATURE);
76
+ }
77
+ const { sessionId, rootKey, sendingChainKey, receivingChainKey, confirmationMac, isValid, } = SessionKeyExchange.createResponderSession(identity, peerBundle, ciphertext, initiatorConfirmationMac);
78
+ if (!isValid && initiatorConfirmationMac) {
79
+ throw new Error(ERRORS.KEY_CONFIRMATION_FAILED);
80
+ }
81
+ const ratchetKeyPair = ml_kem768.keygen();
82
+ const session = {
83
+ sessionId,
84
+ peerUserId: peerBundle.userId,
85
+ peerDsaPublicKey: peerBundle.dsaPublicKey,
86
+ rootKey,
87
+ currentRatchetKeyPair: ratchetKeyPair,
88
+ peerRatchetPublicKey: null,
89
+ sendingChain: {
90
+ chainKey: sendingChainKey,
91
+ messageNumber: 0,
92
+ },
93
+ receivingChain: {
94
+ chainKey: receivingChainKey,
95
+ messageNumber: 0,
96
+ },
97
+ previousSendingChainLength: 0,
98
+ skippedMessageKeys: new Map(),
99
+ highestReceivedMessageNumber: -1,
100
+ maxSkippedMessages: 100,
101
+ createdAt: Date.now(),
102
+ lastUsed: Date.now(),
103
+ isInitiator: false,
104
+ ratchetCount: 0,
105
+ state: "CREATED",
106
+ confirmed: isValid && initiatorConfirmationMac !== undefined,
107
+ confirmationMac,
108
+ // Simple replay protection
109
+ receivedMessageIds: new Set(),
110
+ replayWindowSize: 100,
111
+ lastProcessedTimestamp: Date.now(),
112
+ };
113
+ await this.storage.saveSession(sessionId, session);
114
+ Logger.log("Session", "Session created as responder", {
115
+ sessionId: sessionId.substring(0, 16) + "...",
116
+ keyConfirmed: session.confirmed,
117
+ });
118
+ return { sessionId, confirmationMac, isValid };
119
+ }
120
+ catch (error) {
121
+ Logger.error("Session", "Failed to create responder session", error);
122
+ throw error;
123
+ }
124
+ }
125
+ async confirmSession(sessionId, responderConfirmationMac) {
126
+ try {
127
+ Logger.log("Session", "Confirming session keys");
128
+ const session = await this.storage.getSession(sessionId);
129
+ if (!session)
130
+ throw new Error(ERRORS.SESSION_NOT_FOUND);
131
+ if (!session.sendingChain) {
132
+ throw new Error("No sending chain available");
133
+ }
134
+ const isValid = SessionKeyExchange.verifyKeyConfirmation(sessionId, session.rootKey, session.receivingChain.chainKey, responderConfirmationMac);
135
+ if (isValid) {
136
+ session.confirmed = true;
137
+ session.state = "KEY_CONFIRMED";
138
+ await this.storage.saveSession(sessionId, session);
139
+ Logger.log("Session", "Session keys confirmed successfully");
140
+ }
141
+ else {
142
+ session.state = "ERROR";
143
+ await this.storage.saveSession(sessionId, session);
144
+ Logger.warn("Session", "Key confirmation failed");
145
+ }
146
+ return isValid;
147
+ }
148
+ catch (error) {
149
+ Logger.error("Session", "Failed to confirm session", error);
150
+ throw error;
151
+ }
152
+ }
153
+ async getSessions() {
154
+ const sessionIds = await this.storage.listSessions();
155
+ const sessions = [];
156
+ for (const sessionId of sessionIds) {
157
+ const session = await this.storage.getSession(sessionId);
158
+ if (session) {
159
+ sessions.push(session);
160
+ }
161
+ }
162
+ return sessions;
163
+ }
164
+ async cleanupOldSessions(maxAge) {
165
+ const sessions = await this.getSessions();
166
+ const cutoff = Date.now() - (maxAge || 30 * 24 * 60 * 60 * 1000); // Default 30 days
167
+ for (const session of sessions) {
168
+ if (session.lastUsed < cutoff) {
169
+ await this.storage.deleteSession(session.sessionId);
170
+ Logger.log("Cleanup", "Removed old session", {
171
+ sessionId: session.sessionId.substring(0, 16) + "...",
172
+ age: Math.round((Date.now() - session.lastUsed) / (1000 * 60 * 60 * 24)) + " days",
173
+ });
174
+ }
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,131 @@
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, utf8ToBytes } from "@noble/hashes/utils.js";
5
+ import { KemRatchet } from "./ratchet";
6
+ export class SessionKeyExchange {
7
+ // Helper: Sort two Uint8Arrays lexicographically and return in consistent order
8
+ static getSortedKeys(key1, key2) {
9
+ const str1 = bytesToHex(key1);
10
+ const str2 = bytesToHex(key2);
11
+ return str1 < str2 ? [key1, key2] : [key2, key1];
12
+ }
13
+ // Helper: Create deterministic session ID
14
+ static createSessionId(key1, key2, ciphertext) {
15
+ const [sortedKey1, sortedKey2] = this.getSortedKeys(key1, key2);
16
+ return bytesToHex(blake3(concatBytes(sortedKey1, sortedKey2, ciphertext), { dkLen: 32 }));
17
+ }
18
+ // For initiator (Alice): creates session with Bob's bundle
19
+ static createInitiatorSession(localIdentity, peerBundle) {
20
+ // Validate inputs
21
+ if (!(localIdentity.kemKeyPair.publicKey instanceof Uint8Array)) {
22
+ throw new Error("localIdentity.kemKeyPair.publicKey is not Uint8Array");
23
+ }
24
+ if (!(peerBundle.preKey.key instanceof Uint8Array)) {
25
+ throw new Error("peerBundle.preKey.key is not Uint8Array");
26
+ }
27
+ if (!(peerBundle.kemPublicKey instanceof Uint8Array)) {
28
+ throw new Error("peerBundle.kemPublicKey is not Uint8Array");
29
+ }
30
+ if (!(peerBundle.preKey.signature instanceof Uint8Array)) {
31
+ throw new Error("peerBundle.preKey.signature is not Uint8Array");
32
+ }
33
+ // Verify the prekey signature
34
+ const isValidPreKeySignature = SessionKeyExchange.verifyPreKeySignature(peerBundle.preKey.key, peerBundle.preKey.signature, peerBundle.dsaPublicKey);
35
+ if (!isValidPreKeySignature) {
36
+ throw new Error("Invalid prekey signature");
37
+ }
38
+ // Perform KEM with peer's prekey
39
+ const prekeyResult = ml_kem768.encapsulate(peerBundle.preKey.key);
40
+ if (!prekeyResult || typeof prekeyResult !== "object") {
41
+ throw new Error("ml_kem768.encapsulate returned invalid result");
42
+ }
43
+ const prekeySecret = prekeyResult.sharedSecret;
44
+ const ciphertext = prekeyResult.cipherText;
45
+ if (!(prekeySecret instanceof Uint8Array)) {
46
+ throw new Error(`prekeySecret is not Uint8Array, got ${typeof prekeySecret}`);
47
+ }
48
+ if (!(ciphertext instanceof Uint8Array)) {
49
+ throw new Error(`ciphertext is not Uint8Array, got ${typeof ciphertext}`);
50
+ }
51
+ // Create session ID (both parties will compute same)
52
+ const sessionId = this.createSessionId(localIdentity.kemKeyPair.publicKey, peerBundle.kemPublicKey, ciphertext);
53
+ // Derive keys - BOTH PARTIES MUST USE EXACT SAME INPUTS
54
+ const [sortedKey1, sortedKey2] = this.getSortedKeys(localIdentity.kemKeyPair.publicKey, peerBundle.kemPublicKey);
55
+ const combined = concatBytes(prekeySecret, ciphertext, sortedKey1, sortedKey2);
56
+ const rootKey = blake3(combined, { dkLen: 32 });
57
+ // Initial chain keys: Initiator sends on A, receives on B
58
+ const sendingChainKey = blake3(concatBytes(rootKey, utf8ToBytes("chain_a")), {
59
+ dkLen: 32,
60
+ });
61
+ const receivingChainKey = blake3(concatBytes(rootKey, utf8ToBytes("chain_b")), {
62
+ dkLen: 32,
63
+ });
64
+ // Generate confirmation MAC
65
+ const confirmationMac = KemRatchet.generateConfirmationMac(sessionId, rootKey, sendingChainKey, false);
66
+ return {
67
+ sessionId,
68
+ rootKey,
69
+ sendingChainKey,
70
+ receivingChainKey,
71
+ ciphertext,
72
+ confirmationMac,
73
+ };
74
+ }
75
+ static createResponderSession(localIdentity, peerBundle, ciphertext, initiatorConfirmationMac) {
76
+ // Validate inputs
77
+ if (!(ciphertext instanceof Uint8Array)) {
78
+ throw new Error("ciphertext is not Uint8Array");
79
+ }
80
+ if (!localIdentity.preKeySecret ||
81
+ !(localIdentity.preKeySecret instanceof Uint8Array)) {
82
+ throw new Error("No valid prekey secret available");
83
+ }
84
+ if (!(localIdentity.kemKeyPair.publicKey instanceof Uint8Array)) {
85
+ throw new Error("localIdentity.kemKeyPair.publicKey is not Uint8Array");
86
+ }
87
+ if (!(peerBundle.kemPublicKey instanceof Uint8Array)) {
88
+ throw new Error("peerBundle.kemPublicKey is not Uint8Array");
89
+ }
90
+ // Decapsulate prekey ciphertext
91
+ const prekeySecret = ml_kem768.decapsulate(ciphertext, localIdentity.preKeySecret);
92
+ if (!(prekeySecret instanceof Uint8Array)) {
93
+ throw new Error(`ml_kem768.decapsulate returned non-Uint8Array: ${typeof prekeySecret}`);
94
+ }
95
+ const sessionId = this.createSessionId(localIdentity.kemKeyPair.publicKey, peerBundle.kemPublicKey, ciphertext);
96
+ // Derive keys - MUST USE EXACT SAME INPUTS AS INITIATOR
97
+ const [sortedKey1, sortedKey2] = this.getSortedKeys(localIdentity.kemKeyPair.publicKey, peerBundle.kemPublicKey);
98
+ const combined = concatBytes(prekeySecret, ciphertext, sortedKey1, sortedKey2);
99
+ const rootKey = blake3(combined, { dkLen: 32 });
100
+ // Initial chain keys: Responder sends on B, receives on A
101
+ const sendingChainKey = blake3(concatBytes(rootKey, utf8ToBytes("chain_b")), {
102
+ dkLen: 32,
103
+ });
104
+ const receivingChainKey = blake3(concatBytes(rootKey, utf8ToBytes("chain_a")), {
105
+ dkLen: 32,
106
+ });
107
+ // Generate response confirmation MAC
108
+ const confirmationMac = KemRatchet.generateConfirmationMac(sessionId, rootKey, sendingChainKey, true);
109
+ // Verify initiator's MAC if provided
110
+ let isValid = true;
111
+ if (initiatorConfirmationMac) {
112
+ isValid = KemRatchet.verifyConfirmationMac(sessionId, rootKey, receivingChainKey, initiatorConfirmationMac, false);
113
+ }
114
+ return {
115
+ sessionId,
116
+ rootKey,
117
+ sendingChainKey,
118
+ receivingChainKey,
119
+ confirmationMac,
120
+ isValid,
121
+ };
122
+ }
123
+ // Verify key confirmation (for initiator to verify responder's MAC)
124
+ static verifyKeyConfirmation(sessionId, rootKey, chainKey, responseMac) {
125
+ return KemRatchet.verifyConfirmationMac(sessionId, rootKey, chainKey, responseMac, true);
126
+ }
127
+ // Verify prekey signature
128
+ static verifyPreKeySignature(preKey, signature, dsaPublicKey) {
129
+ return ml_dsa65.verify(signature, preKey, dsaPublicKey);
130
+ }
131
+ }
@@ -0,0 +1,54 @@
1
+ export class MemoryStorage {
2
+ constructor() {
3
+ Object.defineProperty(this, "identity", {
4
+ enumerable: true,
5
+ configurable: true,
6
+ writable: true,
7
+ value: null
8
+ });
9
+ Object.defineProperty(this, "sessions", {
10
+ enumerable: true,
11
+ configurable: true,
12
+ writable: true,
13
+ value: new Map()
14
+ });
15
+ }
16
+ async saveIdentity(identity) {
17
+ this.identity = identity;
18
+ }
19
+ async getIdentity() {
20
+ return this.identity;
21
+ }
22
+ async deleteIdentity() {
23
+ this.identity = null;
24
+ }
25
+ async saveSession(sessionId, session) {
26
+ // Deep clone to avoid reference issues
27
+ const sessionCopy = {
28
+ ...session,
29
+ skippedMessageKeys: new Map(session.skippedMessageKeys),
30
+ receivedMessageIds: new Set(session.receivedMessageIds),
31
+ };
32
+ this.sessions.set(sessionId, sessionCopy);
33
+ }
34
+ async getSession(sessionId) {
35
+ const session = this.sessions.get(sessionId);
36
+ if (!session)
37
+ return null;
38
+ // Return a deep clone
39
+ return {
40
+ ...session,
41
+ skippedMessageKeys: new Map(session.skippedMessageKeys),
42
+ receivedMessageIds: new Set(session.receivedMessageIds),
43
+ };
44
+ }
45
+ async deleteSession(sessionId) {
46
+ this.sessions.delete(sessionId);
47
+ }
48
+ async listSessions() {
49
+ return Array.from(this.sessions.keys());
50
+ }
51
+ async deleteAllSessions() {
52
+ this.sessions.clear();
53
+ }
54
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,27 @@
1
+ import { blake3 } from "@noble/hashes/blake3.js";
2
+ import { bytesToHex, concatBytes, utf8ToBytes } from "@noble/hashes/utils.js";
3
+ export function deriveMessageKey(chainKey) {
4
+ return blake3(concatBytes(chainKey, utf8ToBytes("msg_key")), { dkLen: 32 });
5
+ }
6
+ export function deriveNewChainKey(chainKey) {
7
+ return blake3(concatBytes(chainKey, utf8ToBytes("chain_key")), { dkLen: 32 });
8
+ }
9
+ export function generateSessionId(localKemPublicKey, peerKemPublicKey, preKey) {
10
+ return bytesToHex(blake3(concatBytes(localKemPublicKey, peerKemPublicKey, preKey), {
11
+ dkLen: 32,
12
+ }));
13
+ }
14
+ export function validatePublicBundle(bundle) {
15
+ if (!bundle || !bundle.preKey || !bundle.preKey.key) {
16
+ throw new Error("Invalid peer bundle");
17
+ }
18
+ if (!bundle.userId || typeof bundle.userId !== "string") {
19
+ throw new Error("Invalid userId in bundle");
20
+ }
21
+ if (!bundle.kemPublicKey || !bundle.dsaPublicKey) {
22
+ throw new Error("Missing public keys in bundle");
23
+ }
24
+ }
25
+ export function serializeHeader(header) {
26
+ return utf8ToBytes(JSON.stringify(header));
27
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@immahq/aegis",
3
+ "version": "0.0.1",
4
+ "description": "Lightweight, storage-agnostic library for client-side End-to-End (E2E) encryption",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "scope": "immahq",
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "build": "npm run clean && tsc --build",
17
+ "clean": "rimraf ./dist",
18
+ "demo": "tsx examples/demo.ts",
19
+ "demo:group": "tsx examples/group-demo.ts",
20
+ "test": "vitest run"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+ssh://git@github.com/imma-hq/aegis.git"
25
+ },
26
+ "keywords": [
27
+ "react",
28
+ "react native",
29
+ "native",
30
+ "select",
31
+ "dropdown",
32
+ "option"
33
+ ],
34
+ "author": "Aegis",
35
+ "license": "MIT",
36
+ "bugs": {
37
+ "url": "https://github.com/imma-hq/aegis/issues"
38
+ },
39
+ "homepage": "https://github.com/imma-hq/aegis#readme",
40
+ "dependencies": {
41
+ "@noble/ciphers": "^2.0.1",
42
+ "@noble/curves": "^2.0.1",
43
+ "@noble/hashes": "^2.0.1",
44
+ "@noble/post-quantum": "^0.5.2",
45
+ "buffer": "^6.0.3"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^25.0.3",
49
+ "rimraf": "^6.1.2",
50
+ "tsx": "^4.21.0",
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^4.0.16"
53
+ }
54
+ }