@stvor/sdk 2.2.0 → 2.2.2
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/facade/app.d.ts +2 -0
- package/dist/facade/app.js +6 -1
- package/dist/facade/crypto-session.d.ts +76 -0
- package/dist/facade/crypto-session.js +175 -0
- package/dist/facade/index.d.ts +1 -1
- package/dist/facade/index.js +1 -1
- package/dist/facade/replay-manager.d.ts +58 -0
- package/dist/facade/replay-manager.js +117 -0
- package/dist/facade/sodium-singleton.d.ts +20 -0
- package/dist/facade/sodium-singleton.js +44 -0
- package/dist/facade/tofu-manager.d.ts +80 -0
- package/dist/facade/tofu-manager.js +134 -0
- package/dist/ratchet/index.d.ts +88 -0
- package/dist/ratchet/index.js +318 -0
- package/dist/ratchet/key-recovery.d.ts +45 -0
- package/dist/ratchet/key-recovery.js +148 -0
- package/dist/ratchet/replay-protection.d.ts +21 -0
- package/dist/ratchet/replay-protection.js +50 -0
- package/dist/ratchet/tests/ratchet.test.d.ts +1 -0
- package/dist/ratchet/tests/ratchet.test.js +160 -0
- package/dist/ratchet/tofu.d.ts +27 -0
- package/dist/ratchet/tofu.js +62 -0
- package/package.json +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { split, combine } from 'shamirs-secret-sharing';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
5
|
+
import { createSign, createVerify } from 'crypto';
|
|
6
|
+
/**
|
|
7
|
+
* Generate recovery key shares using Shamir's Secret Sharing.
|
|
8
|
+
* @param secret - The secret to split (e.g., a recovery key).
|
|
9
|
+
* @returns An array of two shares.
|
|
10
|
+
*/
|
|
11
|
+
export function generateRecoveryShares(secret) {
|
|
12
|
+
return split(secret, { shares: 2, threshold: 2 });
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Combine recovery key shares to reconstruct the secret.
|
|
16
|
+
* @param shares - An array of shares.
|
|
17
|
+
* @returns The reconstructed secret.
|
|
18
|
+
*/
|
|
19
|
+
export function combineRecoveryShares(shares) {
|
|
20
|
+
return combine(shares);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Lifecycle Management for Recovery Shares
|
|
24
|
+
*/
|
|
25
|
+
const recoveryShares = new Map(); // Simulated storage
|
|
26
|
+
/**
|
|
27
|
+
* Store recovery shares securely.
|
|
28
|
+
* @param userId - The user ID.
|
|
29
|
+
* @param shares - The recovery shares.
|
|
30
|
+
*/
|
|
31
|
+
export function storeRecoveryShares(userId, shares) {
|
|
32
|
+
recoveryShares.set(userId, shares);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Retrieve recovery shares for a user.
|
|
36
|
+
* @param userId - The user ID.
|
|
37
|
+
* @returns The recovery shares.
|
|
38
|
+
*/
|
|
39
|
+
export function retrieveRecoveryShares(userId) {
|
|
40
|
+
const shares = recoveryShares.get(userId);
|
|
41
|
+
if (!shares) {
|
|
42
|
+
throw new Error('No recovery shares found for user');
|
|
43
|
+
}
|
|
44
|
+
return shares;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Revoke recovery shares for a user.
|
|
48
|
+
* @param userId - The user ID.
|
|
49
|
+
*/
|
|
50
|
+
export function revokeRecoveryShares(userId) {
|
|
51
|
+
recoveryShares.delete(userId);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Tamper-Evidence for Recovery Shares
|
|
55
|
+
*/
|
|
56
|
+
function generateShareHash(share) {
|
|
57
|
+
return createHash('sha256').update(share).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
export function verifyShareIntegrity(share, expectedHash) {
|
|
60
|
+
return generateShareHash(share) === expectedHash;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Honest 2-Man Rule Limitations
|
|
64
|
+
*
|
|
65
|
+
* 1. This is NOT enterprise-grade recovery.
|
|
66
|
+
* 2. No HSM or hardware-backed tamper resistance.
|
|
67
|
+
* 3. Software-based signatures are vulnerable to compromise.
|
|
68
|
+
*/
|
|
69
|
+
// Append-only log with software-based signing
|
|
70
|
+
const PRIVATE_KEY = process.env.RECOVERY_SIGNING_KEY || '';
|
|
71
|
+
const PUBLIC_KEY = process.env.RECOVERY_VERIFICATION_KEY || '';
|
|
72
|
+
function signRecoveryAction(action) {
|
|
73
|
+
const sign = createSign('SHA256');
|
|
74
|
+
sign.update(action);
|
|
75
|
+
sign.end();
|
|
76
|
+
return sign.sign(PRIVATE_KEY, 'hex');
|
|
77
|
+
}
|
|
78
|
+
function verifyRecoveryAction(action, signature) {
|
|
79
|
+
const verify = createVerify('SHA256');
|
|
80
|
+
verify.update(action);
|
|
81
|
+
verify.end();
|
|
82
|
+
return verify.verify(PUBLIC_KEY, signature, 'hex');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Formal Policy for Recovery Shares
|
|
86
|
+
*/
|
|
87
|
+
const recoveryPolicy = {
|
|
88
|
+
minAdmins: 2,
|
|
89
|
+
tamperEvidence: true,
|
|
90
|
+
};
|
|
91
|
+
export function getRecoveryPolicy() {
|
|
92
|
+
return recoveryPolicy;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Example Admin Authentication Model
|
|
96
|
+
*/
|
|
97
|
+
export function authenticateAdmin(adminToken) {
|
|
98
|
+
const validToken = process.env.ADMIN_TOKEN; // Ensure ADMIN_TOKEN is set in the environment
|
|
99
|
+
return adminToken === validToken;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Enhanced 2-Man Rule with audit trail and approval flow
|
|
103
|
+
*/
|
|
104
|
+
const AUDIT_LOG_PATH = './audit-log.json';
|
|
105
|
+
function logRecoveryAction(action, userId, adminId) {
|
|
106
|
+
const logEntry = {
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
action,
|
|
109
|
+
userId,
|
|
110
|
+
adminId,
|
|
111
|
+
};
|
|
112
|
+
const auditLog = existsSync(AUDIT_LOG_PATH)
|
|
113
|
+
? JSON.parse(readFileSync(AUDIT_LOG_PATH, 'utf-8'))
|
|
114
|
+
: [];
|
|
115
|
+
auditLog.push(logEntry);
|
|
116
|
+
writeFileSync(AUDIT_LOG_PATH, JSON.stringify(auditLog, null, 2));
|
|
117
|
+
}
|
|
118
|
+
export function approveRecovery(userId, adminId) {
|
|
119
|
+
logRecoveryAction('APPROVE_RECOVERY', userId, adminId);
|
|
120
|
+
}
|
|
121
|
+
export function revokeRecovery(userId, adminId) {
|
|
122
|
+
logRecoveryAction('REVOKE_RECOVERY', userId, adminId);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Recommendations for enterprise-grade 2-Man Rule
|
|
126
|
+
*
|
|
127
|
+
* 1. Use HSM (Hardware Security Modules) for secure key storage.
|
|
128
|
+
* 2. Implement threshold cryptography for distributed key recovery.
|
|
129
|
+
* 3. Ensure tamper-proof audit logs with cryptographic integrity checks.
|
|
130
|
+
* 4. Define a formal compliance process for recovery actions.
|
|
131
|
+
*/
|
|
132
|
+
/**
|
|
133
|
+
* Example usage:
|
|
134
|
+
*/
|
|
135
|
+
(async () => {
|
|
136
|
+
// Generate a random recovery key
|
|
137
|
+
const recoveryKey = randomBytes(32);
|
|
138
|
+
console.log('Original Recovery Key:', recoveryKey.toString('hex'));
|
|
139
|
+
// Split the recovery key into two shares
|
|
140
|
+
const shares = generateRecoveryShares(recoveryKey);
|
|
141
|
+
console.log('Share 1:', shares[0].toString('hex'));
|
|
142
|
+
console.log('Share 2:', shares[1].toString('hex'));
|
|
143
|
+
// Store the shares securely
|
|
144
|
+
storeRecoveryShares('user1', shares);
|
|
145
|
+
// Combine the shares to reconstruct the recovery key
|
|
146
|
+
const reconstructedKey = combineRecoveryShares(shares);
|
|
147
|
+
console.log('Reconstructed Recovery Key:', reconstructedKey.toString('hex'));
|
|
148
|
+
})();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a message is a replay.
|
|
3
|
+
* @param userId - The user ID sending the message.
|
|
4
|
+
* @param nonce - The unique nonce for the message.
|
|
5
|
+
* @returns True if the message is a replay, false otherwise.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isReplay(userId: string, nonce: string): Promise<boolean>;
|
|
8
|
+
/**
|
|
9
|
+
* Reject messages older than the allowed timestamp.
|
|
10
|
+
* @param timestamp - The message timestamp.
|
|
11
|
+
* @returns True if the message is too old, false otherwise.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isTooOld(timestamp: number): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Validate a message for replay protection.
|
|
16
|
+
* @param userId - The user ID sending the message.
|
|
17
|
+
* @param nonce - The unique nonce for the message.
|
|
18
|
+
* @param timestamp - The message timestamp.
|
|
19
|
+
* @throws Error if the message is a replay or too old.
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateMessage(userId: string, nonce: string, timestamp: number): Promise<void>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createClient } from 'redis';
|
|
2
|
+
// Redis client setup
|
|
3
|
+
const redis = createClient({
|
|
4
|
+
url: process.env.REDIS_URL, // Ensure REDIS_URL is set in the environment
|
|
5
|
+
});
|
|
6
|
+
redis.connect();
|
|
7
|
+
const REPLAY_CACHE_PREFIX = 'replay:';
|
|
8
|
+
const MESSAGE_EXPIRY_SECONDS = 300; // 5 minutes
|
|
9
|
+
/**
|
|
10
|
+
* Check if a message is a replay.
|
|
11
|
+
* @param userId - The user ID sending the message.
|
|
12
|
+
* @param nonce - The unique nonce for the message.
|
|
13
|
+
* @returns True if the message is a replay, false otherwise.
|
|
14
|
+
*/
|
|
15
|
+
export async function isReplay(userId, nonce) {
|
|
16
|
+
const key = `${REPLAY_CACHE_PREFIX}${userId}:${nonce}`;
|
|
17
|
+
const exists = await redis.exists(key);
|
|
18
|
+
if (exists) {
|
|
19
|
+
return true; // Replay detected
|
|
20
|
+
}
|
|
21
|
+
// Store the nonce with an expiry
|
|
22
|
+
await redis.set(key, '1', {
|
|
23
|
+
EX: MESSAGE_EXPIRY_SECONDS,
|
|
24
|
+
});
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Reject messages older than the allowed timestamp.
|
|
29
|
+
* @param timestamp - The message timestamp.
|
|
30
|
+
* @returns True if the message is too old, false otherwise.
|
|
31
|
+
*/
|
|
32
|
+
export function isTooOld(timestamp) {
|
|
33
|
+
const now = Math.floor(Date.now() / 1000); // Current time in seconds
|
|
34
|
+
return now - timestamp > MESSAGE_EXPIRY_SECONDS;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Validate a message for replay protection.
|
|
38
|
+
* @param userId - The user ID sending the message.
|
|
39
|
+
* @param nonce - The unique nonce for the message.
|
|
40
|
+
* @param timestamp - The message timestamp.
|
|
41
|
+
* @throws Error if the message is a replay or too old.
|
|
42
|
+
*/
|
|
43
|
+
export async function validateMessage(userId, nonce, timestamp) {
|
|
44
|
+
if (isTooOld(timestamp)) {
|
|
45
|
+
throw new Error('Message rejected: too old');
|
|
46
|
+
}
|
|
47
|
+
if (await isReplay(userId, nonce)) {
|
|
48
|
+
throw new Error('Message rejected: replay detected');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { initializeCrypto, establishSession, encryptMessage, decryptMessage } from '../index';
|
|
2
|
+
import { validateMessage } from '../replay-protection';
|
|
3
|
+
import { generateFingerprint, verifyFingerprint } from '../tofu';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
5
|
+
describe('Ratchet Tests', () => {
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
await initializeCrypto();
|
|
8
|
+
});
|
|
9
|
+
test('Forward Secrecy: Old keys cannot decrypt', () => {
|
|
10
|
+
const session = establishSession({ publicKey: randomBytes(32), privateKey: randomBytes(32) }, { publicKey: randomBytes(32), privateKey: randomBytes(32) }, randomBytes(32), randomBytes(32), randomBytes(32), randomBytes(32));
|
|
11
|
+
const message1 = encryptMessage('Hello, World!', session);
|
|
12
|
+
const message2 = encryptMessage('Goodbye, World!', session);
|
|
13
|
+
// Simulate key rotation
|
|
14
|
+
session.chainKey = randomBytes(32);
|
|
15
|
+
expect(() => decryptMessage(message1.ciphertext, message1.header, session)).toThrow();
|
|
16
|
+
});
|
|
17
|
+
test('Replay Protection: Reject duplicate nonces', async () => {
|
|
18
|
+
const userId = 'user1';
|
|
19
|
+
const nonce = 'unique-nonce';
|
|
20
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
21
|
+
await validateMessage(userId, nonce, timestamp);
|
|
22
|
+
await expect(validateMessage(userId, nonce, timestamp)).rejects.toThrow('Message rejected: replay detected');
|
|
23
|
+
});
|
|
24
|
+
test('MITM Detection: Fingerprint mismatch', async () => {
|
|
25
|
+
const userId = 'user1';
|
|
26
|
+
const publicKey1 = randomBytes(32);
|
|
27
|
+
const publicKey2 = randomBytes(32);
|
|
28
|
+
const fingerprint1 = generateFingerprint(publicKey1);
|
|
29
|
+
const fingerprint2 = generateFingerprint(publicKey2);
|
|
30
|
+
await verifyFingerprint(userId, fingerprint1);
|
|
31
|
+
const result = await verifyFingerprint(userId, fingerprint2);
|
|
32
|
+
expect(result).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('2-Man Rule Tests', () => {
|
|
36
|
+
test('Recovery shares lifecycle', () => {
|
|
37
|
+
const userId = 'user1';
|
|
38
|
+
const recoveryKey = randomBytes(32);
|
|
39
|
+
const shares = generateRecoveryShares(recoveryKey);
|
|
40
|
+
storeRecoveryShares(userId, shares);
|
|
41
|
+
const retrievedShares = retrieveRecoveryShares(userId);
|
|
42
|
+
expect(retrievedShares.length).toBe(2);
|
|
43
|
+
expect(combineRecoveryShares(retrievedShares)).toEqual(recoveryKey);
|
|
44
|
+
revokeRecoveryShares(userId);
|
|
45
|
+
expect(() => retrieveRecoveryShares(userId)).toThrow('No recovery shares found for user');
|
|
46
|
+
});
|
|
47
|
+
test('Admin authentication', () => {
|
|
48
|
+
process.env.ADMIN_TOKEN = 'secure-token';
|
|
49
|
+
expect(authenticateAdmin('secure-token')).toBe(true);
|
|
50
|
+
expect(authenticateAdmin('invalid-token')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('Double Ratchet Edge Cases', () => {
|
|
54
|
+
test('Skipped keys DoS protection', () => {
|
|
55
|
+
const session = {
|
|
56
|
+
skippedMessageKeys: new Map(),
|
|
57
|
+
};
|
|
58
|
+
for (let i = 0; i < MAX_SKIPPED_KEYS; i++) {
|
|
59
|
+
addSkippedKey(session, { publicKey: randomBytes(32), nonce: randomBytes(12) }, randomBytes(32));
|
|
60
|
+
}
|
|
61
|
+
expect(() => addSkippedKey(session, { publicKey: randomBytes(32), nonce: randomBytes(12) }, randomBytes(32))).toThrow('Skipped keys limit exceeded');
|
|
62
|
+
});
|
|
63
|
+
test('Simultaneous send handling', () => {
|
|
64
|
+
const session = {
|
|
65
|
+
sendingChainKey: randomBytes(32),
|
|
66
|
+
receivingChainKey: randomBytes(32),
|
|
67
|
+
};
|
|
68
|
+
handleSimultaneousSend(session, true);
|
|
69
|
+
expect(session.sendingChainKey).not.toEqual(session.receivingChainKey);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('X3DH Edge Cases', () => {
|
|
73
|
+
test('OPK exhaustion', () => {
|
|
74
|
+
generateOPKPool();
|
|
75
|
+
for (let i = 0; i < OPK_POOL_SIZE; i++) {
|
|
76
|
+
consumeOPK();
|
|
77
|
+
}
|
|
78
|
+
expect(() => consumeOPK()).toThrow('OPK pool exhausted');
|
|
79
|
+
});
|
|
80
|
+
test('Partial handshake completion', () => {
|
|
81
|
+
const identityKeyPair = { publicKey: randomBytes(32), privateKey: randomBytes(32) };
|
|
82
|
+
const signedPreKeyPair = { publicKey: randomBytes(32), privateKey: randomBytes(32) };
|
|
83
|
+
const oneTimePreKey = randomBytes(32);
|
|
84
|
+
const recipientIdentityKey = randomBytes(32);
|
|
85
|
+
const recipientSignedPreKey = randomBytes(32);
|
|
86
|
+
const recipientOneTimePreKey = randomBytes(32);
|
|
87
|
+
const recipientSPKSignature = randomBytes(64);
|
|
88
|
+
expect(() => {
|
|
89
|
+
establishSession(identityKeyPair, signedPreKeyPair, oneTimePreKey, recipientIdentityKey, recipientSignedPreKey, recipientOneTimePreKey, recipientSPKSignature, '1.0', 'AES-GCM');
|
|
90
|
+
}).toThrow('Invalid SPK signature');
|
|
91
|
+
});
|
|
92
|
+
test('Abort ordering', () => {
|
|
93
|
+
const identityKeyPair = { publicKey: randomBytes(32), privateKey: randomBytes(32) };
|
|
94
|
+
const signedPreKeyPair = { publicKey: randomBytes(32), privateKey: randomBytes(32) };
|
|
95
|
+
const oneTimePreKey = randomBytes(32);
|
|
96
|
+
const recipientIdentityKey = randomBytes(32);
|
|
97
|
+
const recipientSignedPreKey = randomBytes(32);
|
|
98
|
+
const recipientOneTimePreKey = randomBytes(32);
|
|
99
|
+
const recipientSPKSignature = randomBytes(64);
|
|
100
|
+
try {
|
|
101
|
+
establishSession(identityKeyPair, signedPreKeyPair, oneTimePreKey, recipientIdentityKey, recipientSignedPreKey, recipientOneTimePreKey, recipientSPKSignature, '1.0', 'AES-GCM');
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
expect(error.message).toBe('Invalid SPK signature');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe('2-Man Rule Integrity', () => {
|
|
109
|
+
test('Tamper-evidence for recovery shares', () => {
|
|
110
|
+
const share = randomBytes(32);
|
|
111
|
+
const hash = generateShareHash(share);
|
|
112
|
+
expect(verifyShareIntegrity(share, hash)).toBe(true);
|
|
113
|
+
expect(verifyShareIntegrity(randomBytes(32), hash)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe('Post-Compromise Security (PCS)', () => {
|
|
117
|
+
test('PCS recovery after compromise', () => {
|
|
118
|
+
const session = establishSession({ publicKey: randomBytes(32), privateKey: randomBytes(32) }, { publicKey: randomBytes(32), privateKey: randomBytes(32) }, randomBytes(32), randomBytes(32), randomBytes(32), randomBytes(32));
|
|
119
|
+
const remotePublicKey = randomBytes(32);
|
|
120
|
+
// Simulate compromise
|
|
121
|
+
session.rootKey = randomBytes(32);
|
|
122
|
+
session.sendingChainKey = randomBytes(32);
|
|
123
|
+
session.receivingChainKey = randomBytes(32);
|
|
124
|
+
// Attacker can decrypt messages before recovery
|
|
125
|
+
const compromisedMessage = encryptMessage('Compromised!', session);
|
|
126
|
+
expect(() => decryptMessage(compromisedMessage.ciphertext, compromisedMessage.header, session)).not.toThrow();
|
|
127
|
+
// Perform forced DH ratchet
|
|
128
|
+
forceDHRatchet(session, remotePublicKey);
|
|
129
|
+
// Attacker cannot decrypt new messages
|
|
130
|
+
const recoveredMessage = encryptMessage('Recovered!', session);
|
|
131
|
+
expect(() => decryptMessage(compromisedMessage.ciphertext, compromisedMessage.header, session)).toThrow();
|
|
132
|
+
expect(() => decryptMessage(recoveredMessage.ciphertext, recoveredMessage.header, session)).not.toThrow();
|
|
133
|
+
});
|
|
134
|
+
test('PCS policy enforcement', () => {
|
|
135
|
+
const session = establishSession({ publicKey: randomBytes(32), privateKey: randomBytes(32) }, { publicKey: randomBytes(32), privateKey: randomBytes(32) }, randomBytes(32), randomBytes(32), randomBytes(32), randomBytes(32));
|
|
136
|
+
const remotePublicKey = randomBytes(32);
|
|
137
|
+
// Simulate message sending
|
|
138
|
+
for (let i = 0; i < 50; i++) {
|
|
139
|
+
incrementMessageCounter(session, remotePublicKey);
|
|
140
|
+
}
|
|
141
|
+
// Ensure DH ratchet was triggered
|
|
142
|
+
expect(messageCounter).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('PCS Security Proofs', () => {
|
|
146
|
+
test('rootKeyₜ ≠ rootKeyₜ₊₁ even with full attacker knowledge', () => {
|
|
147
|
+
const session = establishSession({ publicKey: randomBytes(32), privateKey: randomBytes(32) }, { publicKey: randomBytes(32), privateKey: randomBytes(32) }, randomBytes(32), randomBytes(32), randomBytes(32), randomBytes(32));
|
|
148
|
+
const remotePublicKey = randomBytes(32);
|
|
149
|
+
// Simulate attacker knowledge
|
|
150
|
+
const attackerRootKey = session.rootKey;
|
|
151
|
+
const attackerChainKey = session.sendingChainKey;
|
|
152
|
+
const attackerReceivingKey = session.receivingChainKey;
|
|
153
|
+
// Perform DH ratchet
|
|
154
|
+
receiveNewDHPublicKey(session, remotePublicKey);
|
|
155
|
+
// Assert that attacker cannot derive the new root key
|
|
156
|
+
expect(session.rootKey).not.toEqual(attackerRootKey);
|
|
157
|
+
expect(session.sendingChainKey).not.toEqual(attackerChainKey);
|
|
158
|
+
expect(session.receivingChainKey).not.toEqual(attackerReceivingKey);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a SHA-256 fingerprint for a given public key.
|
|
3
|
+
* @param publicKey - The public key to fingerprint.
|
|
4
|
+
* @returns The fingerprint as a hex string.
|
|
5
|
+
*/
|
|
6
|
+
export declare function generateFingerprint(publicKey: Uint8Array): string;
|
|
7
|
+
/**
|
|
8
|
+
* Store the fingerprint in the database.
|
|
9
|
+
* @param userId - The user ID associated with the fingerprint.
|
|
10
|
+
* @param fingerprint - The fingerprint to store.
|
|
11
|
+
*/
|
|
12
|
+
export declare function storeFingerprint(userId: string, fingerprint: string): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Verify the fingerprint against the stored value.
|
|
15
|
+
* @param userId - The user ID associated with the fingerprint.
|
|
16
|
+
* @param fingerprint - The fingerprint to verify.
|
|
17
|
+
* @returns True if the fingerprint matches, false otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export declare function verifyFingerprint(userId: string, fingerprint: string): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Honest TOFU Limitations
|
|
22
|
+
*
|
|
23
|
+
* 1. First-session MITM risk: The first connection assumes trust.
|
|
24
|
+
* 2. Fingerprint mismatches result in hard failure.
|
|
25
|
+
* 3. No automatic recovery from key substitution attacks.
|
|
26
|
+
*/
|
|
27
|
+
export declare function handleFingerprintMismatch(userId: string): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { Pool } from 'pg';
|
|
3
|
+
// PostgreSQL connection pool
|
|
4
|
+
const pool = new Pool({
|
|
5
|
+
connectionString: process.env.DATABASE_URL, // Ensure DATABASE_URL is set in the environment
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* Generate a SHA-256 fingerprint for a given public key.
|
|
9
|
+
* @param publicKey - The public key to fingerprint.
|
|
10
|
+
* @returns The fingerprint as a hex string.
|
|
11
|
+
*/
|
|
12
|
+
export function generateFingerprint(publicKey) {
|
|
13
|
+
const hash = createHash('sha256');
|
|
14
|
+
hash.update(publicKey);
|
|
15
|
+
return hash.digest('hex');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Store the fingerprint in the database.
|
|
19
|
+
* @param userId - The user ID associated with the fingerprint.
|
|
20
|
+
* @param fingerprint - The fingerprint to store.
|
|
21
|
+
*/
|
|
22
|
+
export async function storeFingerprint(userId, fingerprint) {
|
|
23
|
+
const client = await pool.connect();
|
|
24
|
+
try {
|
|
25
|
+
await client.query('INSERT INTO fingerprints (user_id, fingerprint) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET fingerprint = $2', [userId, fingerprint]);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
client.release();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Verify the fingerprint against the stored value.
|
|
33
|
+
* @param userId - The user ID associated with the fingerprint.
|
|
34
|
+
* @param fingerprint - The fingerprint to verify.
|
|
35
|
+
* @returns True if the fingerprint matches, false otherwise.
|
|
36
|
+
*/
|
|
37
|
+
export async function verifyFingerprint(userId, fingerprint) {
|
|
38
|
+
const client = await pool.connect();
|
|
39
|
+
try {
|
|
40
|
+
const result = await client.query('SELECT fingerprint FROM fingerprints WHERE user_id = $1', [userId]);
|
|
41
|
+
if (result.rows.length === 0) {
|
|
42
|
+
// First use: store the fingerprint
|
|
43
|
+
await storeFingerprint(userId, fingerprint);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return result.rows[0].fingerprint === fingerprint;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
client.release();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Honest TOFU Limitations
|
|
54
|
+
*
|
|
55
|
+
* 1. First-session MITM risk: The first connection assumes trust.
|
|
56
|
+
* 2. Fingerprint mismatches result in hard failure.
|
|
57
|
+
* 3. No automatic recovery from key substitution attacks.
|
|
58
|
+
*/
|
|
59
|
+
export function handleFingerprintMismatch(userId) {
|
|
60
|
+
console.error(`SECURITY ALERT: Fingerprint mismatch detected for user ${userId}`);
|
|
61
|
+
throw new Error('Fingerprint mismatch detected. Connection aborted.');
|
|
62
|
+
}
|