Skip to content

Part 6 — sharing a graph

Membership is your Directory’s answer. Once isGroupMember('acme', 'alex') is true, Alex’s devices can open g:acme, and the next sync from any key holder grants them.

// on your server
await sql`INSERT INTO memberships (team_id, user_id) VALUES ('acme', 'alex')`;
// on Alex's device
await engine.openGraph('g:acme'); // 'waiting' → 'ok' after a holder syncs

With somebody who is not a member yet — invites

Section titled “With somebody who is not a member yet — invites”

The invite string is <doorCode>.<keySecret>. Only the door code reaches your server; the keyring rides along sealed under PBKDF2 of the half that never does.

Alex's deviceyour serverSam's devicethe full string travels by whatever channelthe two humans already trustsealKeyringForInvite('g:acme') → {salt, sealedKeyring, keySecret}create invite {doorCode, graphId, salt, sealedKeyring}doorCodecomposeInvite(doorCode, keySecret)splitInvite(full) → {doorCode, keySecret}redeem doorCodeadd alex to the group{salt, sealedKeyring}acceptInviteKeyring('g:acme', {salt, sealedKeyring, keySecret})
// Sam
const stash = await engine.sealKeyringForInvite('g:acme');
if (!stash) throw new Error('no key for that graph on this device');
const { salt, sealedKeyring, keySecret } = stash;
const doorCode = await myApi.createInvite({ graphId: 'g:acme', salt, sealedKeyring });
const inviteString = composeInvite(doorCode, keySecret); // from @skm/core
// Alex
const { doorCode, keySecret } = splitInvite(inviteString);
const { salt, sealedKeyring } = await myApi.redeemInvite(doorCode);
if (keySecret) {
await engine.acceptInviteKeyring('g:acme', { salt, sealedKeyring, keySecret });
}

acceptInviteKeyring returns false on a wrong secret or a malformed keyring — it never throws, so a mistyped invite is a message, not a crash.

splitInvite on a bare door code returns keySecret: null. That is the “joined without the key” path: the member is in the group, so an existing device will grant them normally on its next sync.

Losing the whole invite string to an attacker is a real compromise. Losing your server’s invite table is not: it holds the door code and a blob the server cannot open.

await engine.openGraph('d:alex', { create: true });
await engine.put('d:alex', { kind: 'msg', data: { body: 'hey' } });

Alex opens d:sam and sees it. Both sides create with {create: true} — the race is resolved and both end up on one key.

The relay learns that Sam and Alex have a channel; it never learns a word of it. Hiding the existence of a conversation needs sealed sender or a mixnet, neither of which skm claims.