Files
wt 71675b1cc0 S00-004: Record blocked task and remediation handoff
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
2026-10-15 15:32:48 +07:00

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); });