Files

179 lines
7.4 KiB
JavaScript

// End-to-end: openhuman ↔ tiny.place messaging, driven through the real
// openhuman core JSON-RPC surface against a real tiny.place backend.
//
// Two independent `openhuman-core` processes (Alice, Bob), each with its own
// freshly-imported wallet identity, exercise the complete DM lifecycle exactly
// as the desktop app would drive it over `core_rpc_relay`:
//
// • send a contact request (openhuman.tinyplace_contacts_request)
// • see it arrive on the other side (openhuman.tinyplace_contacts_requests)
// • accept the request (openhuman.tinyplace_contacts_accept)
// • confirm the mutual contact (openhuman.tinyplace_contacts_list)
// • send Signal-encrypted DMs (openhuman.tinyplace_signal_send_message)
// • receive + decrypt + acknowledge (messages_list / signal_decrypt_message / messages_acknowledge)
//
// It also proves the two security invariants that make this a real messaging
// system: DMs are refused between non-contacts, and the relay only ever stores
// opaque ciphertext.
//
// Requires: a reachable tiny.place backend (TINYPLACE_API_BASE_URL, default
// http://localhost:18080) and a built `openhuman-core` binary. `run.sh` wires
// both up; see README.md.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import net from "node:net";
import { launchAgent, receiveMessage, DEFAULT_BACKEND, DEFAULT_CORE_BIN } from "./lib/core.mjs";
import { existsSync } from "node:fs";
function freePort() {
return new Promise((res, rej) => {
const srv = net.createServer();
srv.on("error", rej);
srv.listen(0, "127.0.0.1", () => {
const { port } = srv.address();
srv.close(() => res(port));
});
});
}
async function backendReachable() {
try {
const r = await fetch(`${DEFAULT_BACKEND}/healthz`);
return r.ok;
} catch {
return false;
}
}
let alice;
let bob;
before(async () => {
assert.ok(
existsSync(DEFAULT_CORE_BIN),
`openhuman-core binary not found at ${DEFAULT_CORE_BIN}. Build it: cargo build --bin openhuman-core (or run run.sh).`,
);
assert.ok(
await backendReachable(),
`tiny.place backend not reachable at ${DEFAULT_BACKEND}/healthz. Start it first (see run.sh / README.md).`,
);
const [pa, pb] = await Promise.all([freePort(), freePort()]);
[alice, bob] = await Promise.all([
launchAgent("alice", { port: pa }),
launchAgent("bob", { port: pb }),
]);
});
after(() => {
alice?.stop();
bob?.stop();
});
test("each core boots a distinct, message-ready tiny.place identity", async () => {
assert.match(alice.cryptoId, /^[1-9A-HJ-NP-Za-km-z]{32,44}$/, "alice cryptoId is base58");
assert.match(bob.cryptoId, /^[1-9A-HJ-NP-Za-km-z]{32,44}$/, "bob cryptoId is base58");
assert.notEqual(alice.cryptoId, bob.cryptoId, "the two cores have different identities");
for (const core of [alice, bob]) {
const status = await core.rpc("openhuman.tinyplace_signal_key_status", {});
assert.equal(status.agentId, core.cryptoId);
assert.equal(status.hasActiveSignedPreKey, true, `${core.name} published a signed pre-key`);
assert.ok(status.localPreKeyCount > 0, `${core.name} holds local one-time pre-keys`);
assert.equal(status.encryptionKeyPublished, true, `${core.name} advertised its encryption key`);
}
});
test("a DM is refused between agents who are not contacts", async () => {
// Fresh, unrelated identity → not a contact of Bob → the relay must refuse.
const stranger = await launchAgent("stranger", { port: await freePort() });
try {
await assert.rejects(
() =>
stranger.rpc("openhuman.tinyplace_signal_send_message", {
recipient: bob.cryptoId,
plaintext: "you don't know me",
}),
/not_a_contact|403|contact/i,
"sending a DM without an accepted contact relationship is rejected",
);
} finally {
stranger.stop();
}
});
test("Alice sends a contact request and Bob sees it pending", async () => {
await alice.rpc("openhuman.tinyplace_contacts_request", { agentId: bob.cryptoId });
const incoming = await bob.rpc("openhuman.tinyplace_contacts_requests", {});
const ids = (incoming.incoming ?? []).map((r) => r.agentId ?? r.cryptoId);
assert.ok(ids.includes(alice.cryptoId), "Alice's request is in Bob's incoming requests");
const status = await bob.rpc("openhuman.tinyplace_contacts_status", { agentId: alice.cryptoId });
assert.equal(status.status, "pending", "relationship is pending before acceptance");
});
test("Bob accepts and both sides see a mutual accepted contact", async () => {
await bob.rpc("openhuman.tinyplace_contacts_accept", { agentId: alice.cryptoId });
const aliceContacts = await alice.rpc("openhuman.tinyplace_contacts_list", {});
const aliceIds = (aliceContacts.contacts ?? []).map((c) => c.agentId ?? c.cryptoId);
assert.ok(aliceIds.includes(bob.cryptoId), "Bob is in Alice's contact list");
const bobStatus = await bob.rpc("openhuman.tinyplace_contacts_status", { agentId: alice.cryptoId });
assert.equal(bobStatus.status, "accepted", "relationship is accepted");
});
test("Alice → Bob: first DM (X3DH) is delivered as ciphertext and decrypts", async () => {
const plaintext = "hello bob — first encrypted DM from alice";
const sent = await alice.rpc("openhuman.tinyplace_signal_send_message", {
recipient: bob.cryptoId,
plaintext,
});
assert.equal(sent.encrypted, true, "send reports the message was encrypted");
assert.ok(sent.messageId, "send returns a messageId");
// Inspect the raw relayed envelope before decrypting: the relay must only
// ever hold opaque ciphertext, never the plaintext.
let sawCiphertext = false;
for (let i = 0; i < 15 && !sawCiphertext; i++) {
const list = await bob.rpc("openhuman.tinyplace_messages_list", { limit: 50 });
const envelopes = Array.isArray(list) ? list : list?.messages ?? [];
const env = envelopes.find((e) => e.id === sent.messageId);
if (env) {
assert.notEqual(env.body, plaintext, "relay envelope body is not the plaintext");
assert.ok(!String(env.body ?? "").includes(plaintext), "ciphertext does not contain the plaintext");
assert.match(env.type, /PREKEY_BUNDLE|CIPHERTEXT/, "envelope carries a Signal message type");
sawCiphertext = true;
} else {
await new Promise((r) => setTimeout(r, 400));
}
}
assert.ok(sawCiphertext, "Bob's relay mailbox received the ciphertext envelope");
const received = await receiveMessage(bob, { fromCryptoId: alice.cryptoId });
assert.equal(received, plaintext, "Bob decrypts Alice's first message");
});
test("Bob → Alice: ratchet reply decrypts, proving a bidirectional session", async () => {
const plaintext = "hi alice — ratchet reply from bob";
const sent = await bob.rpc("openhuman.tinyplace_signal_send_message", {
recipient: alice.cryptoId,
plaintext,
});
assert.equal(sent.encrypted, true);
const received = await receiveMessage(alice, { fromCryptoId: bob.cryptoId });
assert.equal(received, plaintext, "Alice decrypts Bob's reply");
});
test("a follow-up DM in the established session still decrypts", async () => {
const plaintext = "and one more, to prove the session persists";
await alice.rpc("openhuman.tinyplace_signal_send_message", {
recipient: bob.cryptoId,
plaintext,
});
const received = await receiveMessage(bob, { fromCryptoId: alice.cryptoId });
assert.equal(received, plaintext, "Bob decrypts a subsequent in-session message");
});