mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
179 lines
7.4 KiB
JavaScript
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");
|
|
});
|