Changes: Preserve incomplete work and blocker metadata for S00-004. This checkpoint does not mark implementation accepted. Validation: Session ended with status blocked. Blocker: Agent timed out; process terminated before escalation Task: S00-004 Agent: auto-crypto-8bf3dcf711b9 Role: crypto Action: claim Status: blocked Task title: Проверить browser OMEMO backend
236 lines
9.1 KiB
JavaScript
236 lines
9.1 KiB
JavaScript
// Focused known-answer smoke for an audited OMEMO session/ratchet engine.
|
|
// Uses Signal libsignal-client (@signalapp/libsignal-client), NOT a hand-written
|
|
// ratchet. Store semantics mirror Signal's InMemorySignalProtocolStore test
|
|
// stores. Verifies real end-to-end session encryption and session-record
|
|
// serialization (persistence vector) without authoring any cryptographic
|
|
// primitive. Runs on the Node distribution (prebuilt native addon).
|
|
import { createRequire } from 'node:module';
|
|
import assert from 'node:assert';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const lib = require('@signalapp/libsignal-client');
|
|
|
|
const {
|
|
Aci,
|
|
ProtocolAddress,
|
|
PrivateKey,
|
|
PreKeyRecord,
|
|
SignedPreKeyRecord,
|
|
KyberPreKeyRecord,
|
|
KEMKeyPair,
|
|
PreKeyBundle,
|
|
SessionRecord,
|
|
IdentityChange,
|
|
CiphertextMessageType,
|
|
processPreKeyBundle,
|
|
signalEncrypt,
|
|
signalDecrypt,
|
|
signalDecryptPreKey,
|
|
CiphertextMessage,
|
|
IdentityKeyPair,
|
|
ServerCertificate,
|
|
SenderCertificate,
|
|
ContentHint,
|
|
SignalMessage: SignalLibSignalMessage,
|
|
PreKeySignalMessage: SignalLibPreKey,
|
|
sealedSenderEncryptMessage,
|
|
sealedSenderDecryptMessage,
|
|
initLogger,
|
|
LogLevel,
|
|
} = lib;
|
|
|
|
const now = new Date();
|
|
const aliceAci = Aci.fromUuid('9d0652a3-dcc3-4d11-975f-74d61598733f');
|
|
const bobAci = Aci.fromUuid('796abedb-ca4e-4f18-8803-1fde5b921f9f');
|
|
const aliceAddr = ProtocolAddress.new(aliceAci, 1);
|
|
const bobAddr = ProtocolAddress.new(bobAci, 3);
|
|
|
|
function inMemorySessionStore() {
|
|
const state = new Map();
|
|
return {
|
|
async saveSession(name, record) {
|
|
state.set(`${name.name()}::${name.deviceId()}`, record.serialize());
|
|
},
|
|
async getSession(name) {
|
|
const s = state.get(`${name.name()}::${name.deviceId()}`);
|
|
return s ? SessionRecord.deserialize(s) : null;
|
|
},
|
|
async getExistingSessions(addresses) {
|
|
return addresses.map((a) => {
|
|
const s = state.get(`${a.name()}::${a.deviceId()}`);
|
|
if (!s) throw new Error('no session for ' + a.toString());
|
|
return SessionRecord.deserialize(s);
|
|
});
|
|
},
|
|
async _storedSessions() {
|
|
return Array.from(state.values());
|
|
},
|
|
};
|
|
}
|
|
|
|
function inMemoryIdentityKeyStore() {
|
|
const idKeys = new Map();
|
|
const identityKey = PrivateKey.generate();
|
|
return {
|
|
async getIdentityKey() { return identityKey; },
|
|
async getIdentityKeyPair() {
|
|
return new IdentityKeyPair(identityKey.getPublicKey(), identityKey);
|
|
},
|
|
async getLocalRegistrationId() { return 5; },
|
|
async isTrustedIdentity(name, key) {
|
|
const current = idKeys.get(`${name.name()}::${name.deviceId()}`);
|
|
return current ? current.equals(key) : true;
|
|
},
|
|
async saveIdentity(name, key) {
|
|
const current = idKeys.get(`${name.name()}::${name.deviceId()}`);
|
|
idKeys.set(`${name.name()}::${name.deviceId()}`, key);
|
|
const changed = !(current && current.equals(key));
|
|
return changed ? IdentityChange.ReplacedExisting : IdentityChange.NewOrUnchanged;
|
|
},
|
|
async getIdentity(name) {
|
|
return idKeys.get(`${name.name()}::${name.deviceId()}`) ?? null;
|
|
},
|
|
};
|
|
}
|
|
|
|
function inMemoryPreKeyStore() {
|
|
const state = new Map();
|
|
return {
|
|
async savePreKey(id, record) { state.set(id, record.serialize()); },
|
|
async getPreKey(id) {
|
|
const r = state.get(id);
|
|
if (!r) throw new Error('pre-key ' + id + ' not found');
|
|
return PreKeyRecord.deserialize(r);
|
|
},
|
|
async removePreKey(id) { state.delete(id); },
|
|
};
|
|
}
|
|
|
|
function inMemorySignedPreKeyStore() {
|
|
const state = new Map();
|
|
return {
|
|
async saveSignedPreKey(id, record) { state.set(id, record.serialize()); },
|
|
async getSignedPreKey(id) {
|
|
const r = state.get(id);
|
|
if (!r) throw new Error('signed pre-key ' + id + ' not found');
|
|
return SignedPreKeyRecord.deserialize(r);
|
|
},
|
|
};
|
|
}
|
|
|
|
function inMemoryKyberPreKeyStore() {
|
|
const state = new Map();
|
|
const used = new Set();
|
|
return {
|
|
async saveKyberPreKey(id, record) { state.set(id, record.serialize()); },
|
|
async getKyberPreKey(id) {
|
|
const r = state.get(id);
|
|
if (!r) throw new Error('kyber pre-key ' + id + ' not found');
|
|
return KyberPreKeyRecord.deserialize(r);
|
|
},
|
|
async markKyberPreKeyUsed(id) { used.add(id); },
|
|
async hasKyberPreKeyBeenUsed(id) { return used.has(id); },
|
|
};
|
|
}
|
|
|
|
async function rawDecryptPreKey(preKeyMessage, addr, localAddr, sessionStore, identityStore, preK, sPreK, kyber) {
|
|
return signalDecryptPreKey(preKeyMessage, addr, localAddr, sessionStore, identityStore, preK, sPreK, kyber);
|
|
}
|
|
|
|
async function main() {
|
|
initLogger(LogLevel.Trace, (level, target, file, line, message) => {
|
|
if (level >= LogLevel.Warn || /decrypt|pre.?key|session|cipher/i.test(message)) {
|
|
console.log(`[log:${level}]`, message);
|
|
}
|
|
});
|
|
const aliceStore = inMemorySessionStore();
|
|
const bobStore = inMemorySessionStore();
|
|
const aliceKeys = inMemoryIdentityKeyStore();
|
|
const bobKeys = inMemoryIdentityKeyStore();
|
|
const bPreK = inMemoryPreKeyStore();
|
|
const bSPreK = inMemorySignedPreKeyStore();
|
|
const bKyberStore = inMemoryKyberPreKeyStore();
|
|
|
|
const bPreKey = PrivateKey.generate();
|
|
const bSPreKey = PrivateKey.generate();
|
|
const bIdentityKey = await bobKeys.getIdentityKey();
|
|
const bPreKeyId = 31337;
|
|
const bSignedPreKeyId = 22;
|
|
const bSignedPreKeySig = bIdentityKey.sign(bSPreKey.getPublicKey().serialize());
|
|
const bKyberPrekeyId = 777;
|
|
const bKyberKeyPair = await KEMKeyPair.generate();
|
|
const bKyberPrekeySignature = bIdentityKey.sign(bKyberKeyPair.getPublicKey().serialize());
|
|
|
|
const bPreKeyBundle = PreKeyBundle.new(
|
|
5, bAddrDevice(), bPreKeyId, bPreKey.getPublicKey(),
|
|
bSignedPreKeyId, bSPreKey.getPublicKey(), bSignedPreKeySig,
|
|
bIdentityKey.getPublicKey(),
|
|
bKyberPrekeyId, bKyberKeyPair.getPublicKey(), bKyberPrekeySignature,
|
|
);
|
|
|
|
await bPreK.savePreKey(bPreKeyId, PreKeyRecord.new(bPreKeyId, bPreKey.getPublicKey(), bPreKey));
|
|
await bSPreK.saveSignedPreKey(bSignedPreKeyId, SignedPreKeyRecord.new(bSignedPreKeyId, 42, bSPreKey.getPublicKey(), bSPreKey, bSignedPreKeySig));
|
|
await bKyberStore.saveKyberPreKey(bKyberPrekeyId, KyberPreKeyRecord.new(bKyberPrekeyId, 42, bKyberKeyPair, bKyberPrekeySignature));
|
|
|
|
// Alice (local) processes Bob's bundle to prepare a session to Bob.
|
|
await processPreKeyBundle(bPreKeyBundle, bobAddr, aliceAddr, aliceStore, aliceKeys, now);
|
|
|
|
// --- Primary canonical known-answer: sealed-sender establishes the session. ---
|
|
const aIdentityKey = await aliceKeys.getIdentityKey();
|
|
const trustRoot = PrivateKey.generate();
|
|
const serverKey = PrivateKey.generate();
|
|
const serverCert = ServerCertificate.new(1, serverKey.getPublicKey(), trustRoot);
|
|
const senderCert = SenderCertificate.new(
|
|
aliceAci.toString(), null, aliceAddr.deviceId(),
|
|
aIdentityKey.getPublicKey(), now.getTime() + 100000, serverCert, serverKey,
|
|
);
|
|
|
|
const aPlaintext = Buffer.from('known vector: OMEMO session smoke', 'utf8');
|
|
const aSealed = await sealedSenderEncryptMessage(aPlaintext, bobAddr, senderCert, aliceStore, aliceKeys);
|
|
const bResult = await sealedSenderDecryptMessage(
|
|
aSealed, trustRoot.getPublicKey(), now.getTime(),
|
|
null, bobAci.toString(), bobAddr.deviceId(),
|
|
bobStore, bobKeys, bPreK, bSPreK, bKyberStore,
|
|
);
|
|
assert.deepStrictEqual(
|
|
new Uint8Array(bResult.message()),
|
|
new Uint8Array(aPlaintext),
|
|
'sealed-sender session known plaintext round-trips (session established)',
|
|
);
|
|
|
|
// A must now hold a session to Bob.
|
|
const aliceSession = await aliceStore.getSession(bobAddr);
|
|
assert.ok(aliceSession instanceof SessionRecord, 'alice holds a session record to bob after encryption');
|
|
const aliceSessionBytes = aliceSession.serialize();
|
|
assert.ok(aliceSessionBytes.length > 0, 'alice session record serializes');
|
|
|
|
// Note (documented, not a failure): in this libsignal release a separate raw
|
|
// `signalEncrypt` call toward Bob re-initiates a PreKey flow (native log:
|
|
// "Building PreKeyWhisperMessage ... with preKeyId") rather than continuing a
|
|
// Whisper chain. That is a session-management integration detail for the
|
|
// OMEMO adapter layer, not a home-written ratchet. We rely on the verified
|
|
// sealed-sender session path for the known-answer result above.
|
|
|
|
// Persistence vector: Bob established and stores a session record; it
|
|
// serializes into non-empty bytes and reloads byte-for-byte.
|
|
const stored = await bobStore._storedSessions();
|
|
assert.ok(stored.length === 1, 'bob keeps exactly one established session record');
|
|
const storedBytes = new Uint8Array(stored[0]);
|
|
assert.ok(storedBytes.length > 0, 'session record serializes to non-empty bytes');
|
|
const reloaded = SessionRecord.deserialize(storedBytes);
|
|
assert.ok(reloaded instanceof SessionRecord, 'session record deserializes');
|
|
assert.strictEqual(
|
|
Buffer.from(reloaded.serialize()).equals(Buffer.from(storedBytes)),
|
|
true,
|
|
'session round-trips byte-for-byte',
|
|
);
|
|
|
|
console.log('KAS_OK known plaintext:', Buffer.from(bResult.message()).toString('utf8'));
|
|
console.log('KAS_OK established via sealed-sender PreKey session');
|
|
console.log('KAS_OK bob stored session bytes:', storedBytes.length);
|
|
console.log('SMOKE_PASS');
|
|
}
|
|
|
|
function bAddrDevice() { return 3; }
|
|
|
|
main().catch((e) => { console.error('SMOKE_FAIL', (e && e.stack) || e); process.exit(1); }); |