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
This commit is contained in:
1 parent
3784fa7b99
commit
71675b1cc0
16 files changed
+711
-14
No files matched your search
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "0003-crypto-library",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "0003-crypto-library",
|
||||
"dependencies": {
|
||||
"@signalapp/libsignal-client": "0.105.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@signalapp/libsignal-client": {
|
||||
"version": "0.105.0",
|
||||
"resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.105.0.tgz",
|
||||
"integrity": "sha512-6H7aQ6/grE6A/wG3nAm+c7aMWhj472/wtbTnNzb9z2KEPZ933PwYGi93zy6Pm63Mxd7SiOSfoa4gSTr++F44UQ==",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"type-fest": "^4.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "0003-crypto-library",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.20.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@signalapp/libsignal-client": "0.105.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// 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); });
|
||||
@@ -0,0 +1,41 @@
|
||||
// Focused check 2: unsupported format recorded as blocker, not success.
|
||||
// Verifies the honest boundary of what @signalapp/libsignal-client provides for
|
||||
// a Web (browser/worker) deployment and for the OMEMO/OMEMO2 wire format.
|
||||
// This script deliberately does NOT claim success for anything it cannot show.
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgDir = join(here, '..', 'node_modules', '@signalapp', 'libsignal-client');
|
||||
|
||||
function walkWasm(dir, acc = []) {
|
||||
for (const e of readdirSync(dir)) {
|
||||
const p = join(dir, e);
|
||||
if (statSync(p).isDirectory()) walkWasm(p, acc);
|
||||
else if (e.endsWith('.wasm')) acc.push(p);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const wasmFiles = existsSync(pkgDir) ? walkWasm(pkgDir) : null;
|
||||
const prebuildDir = join(pkgDir, 'prebuilds');
|
||||
const prebuilds = existsSync(prebuildDir) ? readdirSync(prebuildDir) : [];
|
||||
|
||||
console.log('pkgPresent:', existsSync(pkgDir));
|
||||
console.log('wasmFiles:', wasmFiles ? wasmFiles.length : 'n/a');
|
||||
console.log('prebuilds:', prebuilds.join(', '));
|
||||
|
||||
if (!wasmFiles || wasmFiles.length === 0) {
|
||||
// This is an unsupported backend for a browser worker, not a success.
|
||||
const msg =
|
||||
'UNSUPPORTED_BLOCKER: no .wasm asset ships in @signalapp/libsignal-client; ' +
|
||||
'the distribution is a Node native addon (prebuilds/*/*.node). A browser ' +
|
||||
'worker/WASM deployment is NOT established by this npm package and must be ' +
|
||||
'treated as a blocker until a verified browser/WASM distribution is found.';
|
||||
console.log(msg);
|
||||
process.exit(2);
|
||||
} else {
|
||||
console.log('wasm present (unexpected here)');
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in new issue
Block a user