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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,34 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Fixed

- **A key package the application marks synced keeps its record, so its
private key is still destroyed when it expires.**
`mls_mark_key_package_synced` deleted the record and left the init key in
the MLS provider. Keeping the key was right, because a Welcome built from
the uploaded copy has to open, but the record is the only thing that carries
expiry, so a published package nobody claimed kept its key for the life of
the install. The mark now sets `synced` on the record instead. The package is
withdrawn at expiry and its key destroyed a week later like any other, and
it is never handed to a peer, because a stranger may already hold it.
`mls_get_pending_key_packages` now lists only packages the application may
publish: it used to include packages the engine had pushed to a peer or held
in its own Nostr slots, which the documented upload loop would then have
marked, stranding each old key as the engine minted a successor. The SDK adds
none of its own packages to the list, so an application that publishes
packages mints them with `mls_generate_key_package` first. Marking an
unknown or already used id does nothing, and marking takes the MLS manager's
write lock, so a concurrent push can no longer erase it. A source guard refuses any new
record-only delete of a key package
([ADR 0012](docs/adr/0012-one-key-package-per-peer.md#a-package-the-application-publishes-keeps-its-record),
[#367](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/367)).

- **React Native key packages carry their timestamps and synced flag.** The
wrappers read `createdAt` and `isSynced` from a native record that has only
ever sent `createdAtMs`, `expiresAtMs` and `synced`, so both were always
`undefined` in JavaScript. All three key package methods now go through one
mapper, and `MlsKeyPackage` gains `expiresAt`, the moment a key server
should drop its copy.

- **`import offline_protocol_sdk` works on Windows.** `pyproject.toml` has
never installed `bless` on Windows, where it has no backend, and the
package imported it on the way in, so the import failed on every Windows
Expand Down
1 change: 1 addition & 0 deletions bindings/react-native/js-ci-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,4 @@ Two traps, both of which produce a test that passes while proving nothing:
| `forward-priority.test.js` | The priority argument `forwardMessage` hands to native (`src/index.ts`): always a number and never `null`, because React Native refuses a null number argument before the Swift method runs and the promise then never settles (#417), and `MessagePriority.Low` is 0 so the default must be resolved with `??` rather than `||`. |
| `rich-send-app-id.test.js` | The per-send `appId` on `sendMessage` and `sendMedia` (`src/index.ts`): an appId-only call must take the rich native method, because only it carries `app_id`, and the plain path would send under the configured id without a word; a rich call without one sends `null`. |
| `relay-config.test.js` | The relay and DORS config payloads this layer hands to native: the whole `relay` section crossing at create time (not just `relayPriority`), the legacy `low`/`medium`/`high` spelling mapping to the engine vocabulary, and a runtime update naming only the fields it was given — which is what makes the native-side merge a partial update rather than a full overwrite. Its other half is the Rust guard `react_native_bridges_merge_dors_updates_from_the_live_config`. |
| `key-package-mapping.test.js` | The JS shape of a key package record (`src/index.ts`, `toMlsKeyPackage`): `createdAt`, `expiresAt` and `isSynced` read from the `createdAtMs`, `expiresAtMs` and `synced` both native bridges send. The wrappers read other names, so every caller got `undefined` for both, and neither the typecheck nor a text guard can see a key that is sent but never read. |
158 changes: 158 additions & 0 deletions bindings/react-native/js-ci-harness/key-package-mapping.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* Behavioral tests for the JS shape of a key package record (`src/index.ts`,
* `toMlsKeyPackage`).
*
* Both native bridges send `createdAtMs`, `expiresAtMs` and `synced`. The
* wrappers used to read `createdAt` and `isSynced`, so every caller received
* `undefined` for both. Nothing failed: the typecheck sees an `any`, and the
* Rust text guards cannot tell a key that is present from one that is read.
*
* See README.md for why the package has no other JS test setup.
*/
'use strict';

const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const Module = require('node:module');
const os = require('node:os');
const path = require('node:path');

const PACKAGE_DIR = path.resolve(__dirname, '..');

/** Compiles `src/` to a scratch dir. See one-shot-hold.test.js for why. */
function compileSdk() {
const tsc = path.join(PACKAGE_DIR, 'node_modules', 'typescript', 'bin', 'tsc');
if (!fs.existsSync(tsc)) {
throw new Error(`TypeScript not found at ${tsc} — run \`npm ci\` in ${PACKAGE_DIR} first.`);
}
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'op-rn-kp-'));
execFileSync(
process.execPath,
[tsc, '--outDir', outDir, '--declaration', 'false', '--declarationMap', 'false'],
{ cwd: PACKAGE_DIR, stdio: 'inherit' }
);
return outDir;
}

let nativeOverrides = {};

const nativeModule = new Proxy(
{},
{
get(_target, method) {
if (typeof method !== 'string') return undefined;
return (...args) => {
const override = nativeOverrides[method];
return override ? override(...args) : Promise.resolve();
};
},
}
);

class StubNativeEventEmitter {
addListener() {
return { remove: () => {} };
}
}

const realLoad = Module._load;
Module._load = function loadWithReactNativeStub(request) {
if (request === 'react-native') {
return {
NativeModules: { OfflineProtocolModule: nativeModule },
NativeEventEmitter: StubNativeEventEmitter,
};
}
return realLoad.apply(this, arguments);
};

const realConsole = { log: console.log, warn: console.warn, error: console.error };

function captureConsole() {
console.log = () => {};
console.warn = () => {};
console.error = () => {};
}

function releaseConsole() {
Object.assign(console, realConsole);
}

const tests = [];
const test = (name, fn) => tests.push({ name, fn });

/** A record exactly as both native bridges build it. */
function nativeRecord(overrides = {}) {
return {
packageId: 'pkg-1',
userId: 'off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqa',
keyPackageData: [1, 2, 3],
createdAtMs: 1_700_000_000_000,
expiresAtMs: 1_702_592_000_000,
synced: true,
...overrides,
};
}

let OfflineProtocol;

function newSdk() {
return new OfflineProtocol({ appId: 'harness', profile: 'harness-profile' });
}

for (const method of ['mlsGenerateKeyPackage', 'mlsGetOrCreateKeyPackage']) {
test(`${method} reads the timestamps and the synced flag the bridges send`, async () => {
nativeOverrides[method] = () => Promise.resolve(nativeRecord());
const pkg = await newSdk()[method]();

assert.equal(pkg.createdAt, 1_700_000_000_000);
assert.equal(pkg.expiresAt, 1_702_592_000_000);
assert.equal(pkg.isSynced, true);
assert.equal(pkg.packageId, 'pkg-1');
assert.deepEqual(pkg.keyPackageData, [1, 2, 3]);
});
}

test('mlsGetPendingKeyPackages maps every record the same way', async () => {
nativeOverrides.mlsGetPendingKeyPackages = () =>
Promise.resolve([nativeRecord(), nativeRecord({ packageId: 'pkg-2', synced: false })]);
const pending = await newSdk().mlsGetPendingKeyPackages();

assert.equal(pending.length, 2);
assert.equal(pending[0].createdAt, 1_700_000_000_000);
assert.equal(pending[0].expiresAt, 1_702_592_000_000);
assert.equal(pending[1].packageId, 'pkg-2');
assert.equal(pending[1].isSynced, false, 'false must stay false, not become undefined');
});

(async () => {
const outDir = compileSdk();
try {
({ OfflineProtocol } = require(path.join(outDir, 'index.js')));

let failed = 0;
for (const { name, fn } of tests) {
nativeOverrides = { isMlsInitialized: () => Promise.resolve(true) };
captureConsole();
try {
await fn();
releaseConsole();
realConsole.log(` ✓ ${name}`);
} catch (error) {
failed += 1;
releaseConsole();
realConsole.log(` ✗ ${name}\n ${error.message}`);
}
}

realConsole.log(
failed === 0 ? `\n${tests.length} passed.` : `\n${failed} of ${tests.length} FAILED.`
);
process.exitCode = failed === 0 ? 0 : 1;
} finally {
releaseConsole();
fs.rmSync(outDir, { recursive: true, force: true });
}
})();
2 changes: 1 addition & 1 deletion bindings/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"scripts": {
"prepare": "tsc",
"build": "tsc",
"test:js": "node js-ci-harness/one-shot-hold.test.js && node js-ci-harness/local-address.test.js && node js-ci-harness/forward-priority.test.js && node js-ci-harness/rich-send-app-id.test.js && node js-ci-harness/relay-config.test.js && node js-ci-harness/data-config.test.js && node js-ci-harness/security-config.test.js && node js-ci-harness/telemetry-config.test.js && node js-ci-harness/custody-config.test.js",
"test:js": "node js-ci-harness/one-shot-hold.test.js && node js-ci-harness/local-address.test.js && node js-ci-harness/forward-priority.test.js && node js-ci-harness/rich-send-app-id.test.js && node js-ci-harness/relay-config.test.js && node js-ci-harness/data-config.test.js && node js-ci-harness/security-config.test.js && node js-ci-harness/telemetry-config.test.js && node js-ci-harness/custody-config.test.js && node js-ci-harness/key-package-mapping.test.js",
"build:ios": "bash scripts/build-ios.sh",
"build:android": "bash scripts/build-android.sh",
"build:all": "bash scripts/build-all.sh",
Expand Down
62 changes: 38 additions & 24 deletions bindings/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,25 @@ function sanitize<T extends object>(value: T | undefined | null): T | undefined
return Object.fromEntries(cleanedEntries) as T;
}

/**
* Maps a native key package record to the JS shape.
*
* Both native bridges send `createdAtMs`, `expiresAtMs` and `synced`. Reading
* `createdAt` and `isSynced` instead, which these wrappers did, gave every
* caller `undefined` for both, with no error anywhere. The plain names are
* still read first, the same tolerance the session and group mappers use.
*/
function toMlsKeyPackage(raw: any): MlsKeyPackage {
return {
packageId: raw.packageId,
userId: raw.userId,
keyPackageData: raw.keyPackageData,
createdAt: raw.createdAt ?? raw.createdAtMs ?? 0,
expiresAt: raw.expiresAt ?? raw.expiresAtMs ?? 0,
isSynced: raw.isSynced ?? raw.synced ?? false,
};
}

/**
* Main Offline Protocol class
*
Expand Down Expand Up @@ -2846,13 +2865,7 @@ export class OfflineProtocol {
*/
async mlsGenerateKeyPackage(): Promise<MlsKeyPackage> {
const result = await OfflineProtocolNativeModule.mlsGenerateKeyPackage();
return {
packageId: result.packageId,
userId: result.userId,
keyPackageData: result.keyPackageData,
createdAt: result.createdAt,
isSynced: result.isSynced,
};
return toMlsKeyPackage(result);
}

/**
Expand All @@ -2863,34 +2876,35 @@ export class OfflineProtocol {
*/
async mlsGetOrCreateKeyPackage(): Promise<MlsKeyPackage> {
const result = await OfflineProtocolNativeModule.mlsGetOrCreateKeyPackage();
return {
packageId: result.packageId,
userId: result.userId,
keyPackageData: result.keyPackageData,
createdAt: result.createdAt,
isSynced: result.isSynced,
};
return toMlsKeyPackage(result);
}

/**
* Gets pending key packages that haven't been synced yet.
* Lists the key packages this app may publish itself, for example to its
* own key server.
*
* Only packages nobody has spoken for are listed: never one the SDK has
* already handed to a peer or one standing in its own publication slots,
* and never one already marked synced. The SDK adds none of its own, so
* the list is empty until the app mints packages with
* `mlsGenerateKeyPackage`.
*
* @returns Array of pending key packages
* @returns Array of key packages free to publish
*/
async mlsGetPendingKeyPackages(): Promise<MlsKeyPackage[]> {
const results =
await OfflineProtocolNativeModule.mlsGetPendingKeyPackages();
return results.map((r: any) => ({
packageId: r.packageId,
userId: r.userId,
keyPackageData: r.keyPackageData,
createdAt: r.createdAt,
isSynced: r.isSynced,
}));
return results.map(toMlsKeyPackage);
}

/**
* Marks a key package as synced.
* Records that the app has published a key package itself.
*
* The package stays on the device so a Welcome built from the published
* copy still opens. It is no longer listed as pending and is never handed
* to a peer. It expires with the lifetime it was minted with, and its
* private key is destroyed after that, whether or not anybody used it.
* Marking an unknown, expired or already used package does nothing.
*
* @param packageId - Key package ID to mark
* @throws Error if operation fails
Expand Down
13 changes: 11 additions & 2 deletions bindings/react-native/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3092,9 +3092,18 @@ export interface MlsKeyPackage {
userId: string;
/** Raw key package data (bytes) */
keyPackageData: number[];
/** Timestamp when this package was created */
/** When this package was created, in milliseconds since the epoch */
createdAt: number;
/** Whether this package has been synced to a server */
/**
* When this package expires, in milliseconds since the epoch. A key server
* holding a copy should drop it by then: the device withdraws it at this
* moment and destroys its private key a week later.
*/
expiresAt: number;
/**
* Whether the app has marked this package published with
* `mlsMarkKeyPackageSynced`. A synced package is never handed to a peer.
*/
isSynced: boolean;
}

Expand Down
Loading
Loading