Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions apps/files/src/types/entity.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { describe, expect, it } from "vitest";
import {
type Entity,
hasDisplayName,
readName,
resolveDisplayName,
} from "./entity";
import { type Entity, hasDisplayName, readName, resolveDisplayName } from "./entity";

const bare = (properties: Record<string, unknown>): Entity =>
({
Expand Down
10 changes: 10 additions & 0 deletions packages/shell/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3841,6 +3841,16 @@ void app.whenReady().then(async () => {
console.warn(`[live-sync] refused a DEK rotation for ${entityId} from a non-Owner sender`);
return null;
}
// 10.3c — the identity inbox is shared by every device of this
// identity, so a wrap fanned out to a SIBLING is delivered here
// too. It is sealed to the sibling's X25519 key, so unwrapping
// it would throw once per entity per device. Compare the
// recipient the wrap names and stop: exact, silent, and cheap.
// Also defence in depth on the cross-user path, where a wrap
// addressed to another member is simply not ours to open.
if (wrap.recipientPubB64 !== bytesToBase64(session.deviceX25519.publicKey)) {
return null;
}
const { dek, type } = session.unwrapMemberWrapWithType(wrap, entityId);
try {
const repo = await getEntitiesRepoForActiveSession();
Expand Down
105 changes: 105 additions & 0 deletions packages/shell/src/main/sync/wrap-fanout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* 10.3c — the multi-device wrap fan-out producer.
*
* 10.3b built the RECEIVER and never a producer, so nothing in production ever
* wrapped an entity DEK for a paired device: two of one user's own devices
* have never synced a single entity. These tests pin the producer's rules,
* every one of which is a real trap the rung's design named.
*/

import { describe, expect, it, vi } from "vitest";
import { PLACEHOLDER_DEK_VERSION, fanOutEntityWrap, planWrapFanout } from "./wrap-fanout";

const SELF = "self-ed25519-pub";
const OTHER = "other-ed25519-pub";

function device(ed: string, x?: string) {
return x === undefined ? { deviceEd25519Pub: ed } : { deviceEd25519Pub: ed, deviceX25519Pub: x };
}

describe("planWrapFanout", () => {
it("never wraps for this device — it already holds the DEK", () => {
const plan = planWrapFanout([device(SELF, "self-x"), device(OTHER, "other-x")], SELF);
expect(plan.map((d) => d.deviceEd25519Pub)).toEqual([OTHER]);
});

it("skips a device with no X25519 key — there is nothing to seal to", () => {
const plan = planWrapFanout([device(SELF, "self-x"), device(OTHER)], SELF);
expect(plan).toEqual([]);
});

it("skips a device whose X25519 key is empty rather than absent", () => {
const plan = planWrapFanout([device(SELF, "self-x"), device(OTHER, "")], SELF);
expect(plan).toEqual([]);
});

it("returns nothing for a single-device identity", () => {
expect(planWrapFanout([device(SELF, "self-x")], SELF)).toEqual([]);
});
});

describe("fanOutEntityWrap", () => {
function deps(overrides: Partial<Parameters<typeof fanOutEntityWrap>[0]> = {}) {
const emitted: Array<{ entityId: string; route: string; recipient: string }> = [];
return {
emitted,
args: {
entityId: "ent_1",
dek: new Uint8Array(32).fill(7),
version: 3,
type: "brainstorm/Note/v1",
devices: [device(SELF, "self-x"), device(OTHER, "other-x")],
selfDeviceEd25519Pub: SELF,
identityRoute: "inbox:identity-pub",
wrapFor: (_dek: Uint8Array, recipientPubB64: string) => ({ recipientPubB64 }),
emit: async (entityId: string, wrap: { recipientPubB64: string }, route: string) => {
emitted.push({ entityId, route, recipient: wrap.recipientPubB64 });
},
...overrides,
} as Parameters<typeof fanOutEntityWrap>[0],
};
}

it("seals one wrap per sibling device and routes it to the identity inbox", async () => {
const { args, emitted } = deps();
const result = await fanOutEntityWrap(args);
expect(result.sent).toBe(1);
expect(emitted).toEqual([
{ entityId: "ent_1", route: "inbox:identity-pub", recipient: "other-x" },
]);
});

it("REFUSES to fan out a placeholder DEK — version 0 loses to every real wrap", async () => {
const { args, emitted } = deps({ version: PLACEHOLDER_DEK_VERSION });
const result = await fanOutEntityWrap(args);
expect(result.sent).toBe(0);
expect(result.refused).toBe("placeholder-dek");
expect(emitted).toEqual([]);
});

it("refuses any non-positive ordinal, not just the placeholder constant", async () => {
const { args } = deps({ version: -1 });
expect((await fanOutEntityWrap(args)).refused).toBe("placeholder-dek");
});

it("keeps going when one device's emit fails, and reports the failure", async () => {
const failing = vi.fn(async (_id: string, wrap: { recipientPubB64: string }) => {
if (wrap.recipientPubB64 === "b-x") throw new Error("relay down");
});
const { args } = deps({
devices: [device(SELF, "self-x"), device("a", "a-x"), device("b", "b-x"), device("c", "c-x")],
emit: failing,
});
const result = await fanOutEntityWrap(args);
expect(result.sent).toBe(2);
expect(result.failed).toEqual(["b"]);
});

it("is a no-op with no siblings rather than an error", async () => {
const { args, emitted } = deps({ devices: [device(SELF, "self-x")] });
const result = await fanOutEntityWrap(args);
expect(result.sent).toBe(0);
expect(result.refused).toBeUndefined();
expect(emitted).toEqual([]);
});
});
138 changes: 138 additions & 0 deletions packages/shell/src/main/sync/wrap-fanout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Stage 10.3c — the multi-device DEK wrap fan-out **producer**.
*
* 10.3b built the entire receive half and no producer at all, so nothing in
* production ever wrapped an entity DEK for a paired device: two of one user's
* own devices have never synced a single entity. This is the missing half.
*
* Pure + injected, like the rest of `main/sync/`: no crypto, no relay, no
* session. The caller supplies `wrapFor` (HPKE seal) and `emit` (envelope
* pipeline), so every rule below is testable without a vault.
*
* ── Addressing ──────────────────────────────────────────────────────────
* A sibling wrap rides the **identity** inbox (`inbox:<identityPub>`), which
* both of a user's devices already subscribe, and the receiver ignores any
* wrap whose `recipientPubB64` is not its own device key.
*
* The rung's original design chose a per-DEVICE inbox instead, on the premise
* that the relay already sees device keys as the frame `sender` and so learns
* nothing new. **That premise was wrong**: `PipelineContext.devicePub` is
* `session.identity.publicKey` — the sovereign USER key — so every device of
* one identity sends under the same `sender`, and the relay currently cannot
* tell them apart. A per-device channel would hand it a brand-new identifier
* revealing how many devices an identity has and which frames belong to which.
* On a blind relay that is a real regression, and it buys only the bandwidth
* of a sibling dropping a frame addressed to someone else.
*
* The drop is exact, not a decryption failure: `MemberWrapPayload` carries
* `recipientPubB64`, so a device compares one string and stops. See the guard
* at the `installWrap` receiver.
*
* ── Ordinals ────────────────────────────────────────────────────────────
* `installEntityDek` accepts a STRICTLY-NEWER version only, and a device that
* boots first mints placeholder DEKs at version 0 for rows it cannot read
* (`RETRO_WRAP_PLACEHOLDER_VERSION`). A real wrap therefore has to carry the
* real ordinal to outrank one. The trap runs the other way from what the rung
* anticipated: fanning out an entity that is ITSELF still on the placeholder
* would ship version 0, which every receiver rejects forever. So that is
* refused here rather than emitted.
*
* ── Revocation ──────────────────────────────────────────────────────────
* Every device this reaches keeps every DEK it is handed. That is precisely
* what makes rotate-on-revoke (`LAN-2b(d)` / ROT-3a) load-bearing rather than
* theoretical: without it, revoking a device does not take its access away.
* Callers pass `devices` from `DevicesStore.listActive()`, which already drops
* revoked devices, so a revoked device is never a fresh-wrap recipient.
*/

/** The version `retro-wrap-deks` stamps on a DEK minted for a row this device
* cannot read. Never fan one out — see the ordinal note above. */
export const PLACEHOLDER_DEK_VERSION = 0;

/** The subset of a `SignedAddDeviceRecord` the fan-out needs. */
export type FanoutDevice = {
deviceEd25519Pub: string;
/** Absent on a record written before device X25519 keys existed, and the
* only key a DEK can be sealed to. */
deviceX25519Pub?: string;
};

export type WrapFanoutResult = {
/** Wraps successfully handed to the relay. */
sent: number;
/** `deviceEd25519Pub` of every sibling whose emit threw. */
failed: string[];
/** Set when the whole fan-out was declined before any emit. */
refused?: "placeholder-dek";
};

/**
* The devices that should receive a fresh wrap: every active device except
* this one, that actually has a key to seal to.
*
* Self is excluded because it already holds the DEK — wrapping to yourself is
* the bug 10.3b's `installEntityWrap` shipped (it hard-coded the local device
* as the *only* recipient, which is why nothing ever reached a sibling).
*/
export function planWrapFanout(
devices: readonly FanoutDevice[],
selfDeviceEd25519Pub: string,
): FanoutDevice[] {
return devices.filter(
(d) =>
d.deviceEd25519Pub !== selfDeviceEd25519Pub &&
typeof d.deviceX25519Pub === "string" &&
d.deviceX25519Pub.length > 0,
);
}

export type FanOutEntityWrapArgs<TWrap> = {
entityId: string;
dek: Uint8Array;
/** The entity's REAL DEK ordinal. Refused when not strictly positive. */
version: number;
type?: string;
devices: readonly FanoutDevice[];
selfDeviceEd25519Pub: string;
/** `inbox:<identityPub>` — both of this identity's devices subscribe it. */
identityRoute: string;
wrapFor(
dek: Uint8Array,
recipientPubB64: string,
entityId: string,
type?: string,
version?: number,
): TWrap;
emit(entityId: string, wrap: TWrap, route: string): Promise<void>;
};

/**
* Seal this entity's DEK for every sibling device and hand each wrap to the
* relay. Never throws: one unreachable device must not abort the rest, and a
* backfill that dies on its first failure would strand every later entity.
*/
export async function fanOutEntityWrap<TWrap>(
args: FanOutEntityWrapArgs<TWrap>,
): Promise<WrapFanoutResult> {
if (!Number.isInteger(args.version) || args.version <= PLACEHOLDER_DEK_VERSION) {
// Shipping this would be worse than shipping nothing: the receiver's
// anti-rollback rule rejects it permanently, so the entity would look
// fanned-out while staying unreadable on every sibling.
return { sent: 0, failed: [], refused: "placeholder-dek" };
}
const recipients = planWrapFanout(args.devices, args.selfDeviceEd25519Pub);
const failed: string[] = [];
let sent = 0;
for (const device of recipients) {
const recipientPubB64 = device.deviceX25519Pub;
if (!recipientPubB64) continue; // narrowed by planWrapFanout; belt and braces
try {
const wrap = args.wrapFor(args.dek, recipientPubB64, args.entityId, args.type, args.version);
await args.emit(args.entityId, wrap, args.identityRoute);
sent += 1;
} catch {
failed.push(device.deviceEd25519Pub);
}
}
return { sent, failed };
}
Loading