aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_rewrap_fanout.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 15:06:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 15:06:14 +0200
commit91297944791a36f30302ef8c86dd69ebeb177671 (patch)
tree568188114baf438059458f1bc87903f4894cec90 /packages/meshbay-hub/tests/test_rewrap_fanout.py
parenta55d40b74bda77dff6ec565abdd551607fc665d6 (diff)
downloadmeshbay-91297944791a36f30302ef8c86dd69ebeb177671.tar.gz
feat: bundles sealed per node under the passphrase and the hub's pepper
The session key is M = HKDF(Argon2(passphrase) || pepper, account id); each node's bundle key and the playlist key derive from it. Bundles are MBK3, bound to account and node; MBK1/MBK2 are refused by name, never replaced silently. Playlists move to key v2 and are re-sealed over unreadable node copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_rewrap_fanout.py')
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py61
1 files changed, 40 insertions, 21 deletions
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py
index b278d0c..79a5bf5 100644
--- a/packages/meshbay-hub/tests/test_rewrap_fanout.py
+++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py
@@ -46,12 +46,13 @@ eval(process.argv[2].split(require('path').delimiter)
.map((p) => fs.readFileSync(p, 'utf8')).join('\n'));
const T = window.MeshBayTransport;
-let deriveEncCalls = 0;
+// Keys are opaque tags here; what is checked is which key sealed what, for
+// which account on which node, under which pepper version.
window.MeshBayKeys = {
- deriveEncryptionKey: async (p) => { deriveEncCalls++; return { kind: 'enc', p }; },
- deriveEncryptionKeyV1: async (p) => ({ kind: 'encv1', p }),
- deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
- encryptBundleWithKey: async (_skEd, _skX, key) => 'wrapped:' + key.kind,
+ deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
+ nodeBundleKey: async (key, nodePk) => ({ kind: `${key.kind}@${nodePk}` }),
+ encryptBundle: async (_skEd, _skX, key, m) =>
+ `wrapped:${key.kind}|${m.userId}|${m.nodePk}|v${m.pepperVersion}`,
};
const b64 = (s) => Buffer.from(s).toString('base64');
@@ -65,8 +66,11 @@ const NODES = {
const stored = [];
const rewrapOnlySeen = [];
-T.prototype.connect = async function (nodeId) {
+const openedWith = [];
+T.prototype.connect = async function (nodeId, _t, _g, _gek, _sk, bundleKey) {
this._nodeId = nodeId;
+ this.nodePk = 'pk-' + nodeId; // what the handshake proves
+ openedWith.push(bundleKey && bundleKey.kind);
rewrapOnlySeen.push(this._rewrapOnly === true);
const s = NODES[nodeId] || {};
if (s.throws) throw new Error(s.throws);
@@ -110,38 +114,47 @@ global.fetch = async (url) => {
const names = (a) => a.map((x) => x.name).sort();
(async () => {
+ // Flow A — passphrase change: opened with the old key, sealed with the new.
const A = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- oldPassphrase: 'old', newPassphrase: 'new',
+ bundleKey: { kind: 'old', pepperVersion: 1 },
+ newBundleKey: { kind: 'new', pepperVersion: 1 },
});
const storeA = stored.splice(0);
+ const openedA = openedWith.splice(0);
+ // Flow B — reset: the session key of the new passphrase opens nothing, the
+ // recovery copy does, and both copies are sealed again.
const B = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- newPassphrase: 'new', recoveryKey: 'A RECOVERY MNEMONIC',
+ bundleKey: { kind: 'cur', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC',
});
const storeB = stored.splice(0);
- // Flow C — Profile backfill: keep the live passphrase key, just add the
- // recovery copy. No passphrase strings, so deriveEncryptionKey is not called.
- deriveEncCalls = 0;
+ // Flow C — Profile backfill: keep the live key, just add the recovery copy.
const C = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- bundleKey: { v2: { kind: 'bk' }, v1: { kind: 'bkv1' } },
+ bundleKey: { kind: 'bk', pepperVersion: 1 },
recoveryKey: 'A RECOVERY MNEMONIC',
});
const storeC = stored.splice(0);
+ let refusedWithoutKey = false;
+ try {
+ await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid' });
+ } catch { refusedWithoutKey = true; }
+
process.stdout.write(JSON.stringify({
a_updated: names(A.updated),
a_unreachable: names(A.unreachable),
a_failed: names(A.failed),
a_stored_nodes: storeA.map((s) => s.nodeId).sort(),
a_recovery_always_null: storeA.every((s) => s.rec === null),
- a_new_bundle_key_kind: A.newBundleKey && A.newBundleKey.v2 && A.newBundleKey.v2.kind,
+ a_stored: storeA.map((s) => s.enc),
+ a_opened_with: [...new Set(openedA)],
b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
- c_derive_enc_calls: deriveEncCalls,
+ refused_without_key: refusedWithoutKey,
// Every transport the fan-out builds is flagged rewrap-only, so a stored
// bundle it cannot open is reported, not silently replaced with a new one.
all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean),
@@ -189,24 +202,30 @@ def test_a_node_that_returns_no_identity_is_a_failure(result):
assert "f@ann" in result["a_failed"]
-def test_flow_a_writes_only_the_passphrase_copy(result):
+def test_flow_a_opens_with_the_old_key_and_seals_with_the_new_for_that_node(result):
assert result["a_recovery_always_null"] is True
- assert result["a_new_bundle_key_kind"] == "enc"
+ assert result["a_opened_with"] == ["old"]
+ assert result["a_stored"] == ["wrapped:new@pk-n-ok|uid|pk-n-ok|v1"]
def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result):
+ # The recovery copy owes nothing to the pepper: version 0.
assert result["b_stored"] == [
- {"node": "n-ok", "enc": "wrapped:enc", "rec": "wrapped:rec"},
+ {"node": "n-ok", "enc": "wrapped:cur@pk-n-ok|uid|pk-n-ok|v1",
+ "rec": "wrapped:rec|uid|pk-n-ok|v0"},
]
def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result):
- # bundleKey mode: the passphrase copy is re-wrapped with the same live key
- # (kind "bk"), the recovery copy is added, and no passphrase is derived.
assert result["c_stored"] == [
- {"node": "n-ok", "enc": "wrapped:bk", "rec": "wrapped:rec"},
+ {"node": "n-ok", "enc": "wrapped:bk@pk-n-ok|uid|pk-n-ok|v1",
+ "rec": "wrapped:rec|uid|pk-n-ok|v0"},
]
- assert result["c_derive_enc_calls"] == 0
+
+
+def test_there_is_no_passphrase_path_left(result):
+ """Keys only: a caller that has not derived one with the pepper is refused."""
+ assert result["refused_without_key"] is True
def test_every_fanout_transport_is_rewrap_only(result):