Skip to content

API bindings for E2EE notifications#2176

Merged
chrisbobbe merged 9 commits intozulip:mainfrom
gnprice:pr-e2een-api
Mar 2, 2026
Merged

API bindings for E2EE notifications#2176
chrisbobbe merged 9 commits intozulip:mainfrom
gnprice:pr-e2een-api

Conversation

@gnprice
Copy link
Copy Markdown
Member

@gnprice gnprice commented Feb 24, 2026

After the revisions we made to the API for E2EE notifications #1764 in the past few weeks (zulip/zulip#37731; #api design > E2EE - key rotation @ 💬 and topics that branched from there), I believe it's now stable.

So that makes this a good time to review and merge the API bindings on the client side. These commits, like #2160, are taken from my draft branch for #1764.

@gnprice gnprice added the maintainer review PR ready for review by Zulip maintainers label Feb 24, 2026
@chrisbobbe chrisbobbe self-requested a review February 25, 2026 01:53
@chrisbobbe chrisbobbe self-assigned this Feb 25, 2026
@gnprice
Copy link
Copy Markdown
Member Author

gnprice commented Feb 25, 2026

The bindings on DeviceUpdateEvent weren't quite right, as I learned when I tested this code on a live server today for the first time. I've pushed a revision that fixes the way JsonNullable is used there, and adds tests, plus a commit expanding the docs on JsonNullable.

Copy link
Copy Markdown
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exciting!! Small comments below.

Comment on lines +34 to +35
final baseJson = {'id': 1, 'type': 'device', 'op': 'update',
'device_id': 3 };
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this could all fit on one line?

Comment on lines +20 to +22
// The doc still says `push_account_id` here, but that's an oversight:
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/E2EE.20-.20key.20rotation/near/2384969
// (This is a part of the API docs that isn't checked by tests.)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this was fixed in zulip/zulip#38124, which was merged.

Comment on lines +29 to +30
EncryptedFcmMessage({
required this.pushKeyId, required this.encryptedData});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can this fit on one line?

'push_key': RawParameter(key.pushKey),
},
if (token != null) ...{
'token_kind': RawParameter(token.tokenKind.name),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude flagged the use of enum .name here 🙂 what do you think? Quoting from Claude:

  • One small thing: token_kind is passed as RawParameter(token.tokenKind.name) (line 26). The .name property gives the
    Dart enum name ('fcm' or 'apns'), which happens to match the API values. This works but it's worth noting it relies on
    the enum member names matching the API values exactly — if someone renamed the enum member, it would silently break.
    The PushRegistration.toJson() path uses the generated _$PushTokenKindEnumMap which is more robust. This is a very
    minor fragility, and the same pattern appears elsewhere in the codebase, so this is consistent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it's not really a coincidence that the names in the code match the names in the API. But sure, we can do the more explicit pattern. I guess that's what we do on UpdateMessageFlagsOp, and there aren't many other enums in lib/api/route/.

/// Generate a suitable value to pass as `pushKeyId` to [registerPushDevice].
static int generatePushKeyId() {
final rand = Random.secure();
return rand.nextInt(1 << 32);
Copy link
Copy Markdown
Collaborator

@chrisbobbe chrisbobbe Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude says:

generatePushKeyId(): Uses Random.secure().nextInt(1 << 32) for a 32-bit unsigned random int. Note: 1 << 32 works on
native (64-bit) but would be 1 on web (32-bit shifts). Not a concern since the app targets Android/iOS only, but could
be a gotcha if web were ever added.

Is it true that 1 << 32 could silently give 1? That sounds problematic if it happened in the app; are there any realistic conditions that could cause that, or is it right to imply that it would only ever happen on web?

Then I guess in the absence of that problem, there's still this in the dartdoc of Random.nextInt:

  /// Implementation note: The default implementation supports [max] values
  /// between 1 and (1<<32) inclusive.

Is the "default implementation" actually what gets used?

…Ah, reading the dartdoc on the whole Random class, I think "default implementation" is the antonym of the "secure" implementation:

/// The default implementation supplies a stream of pseudo-random bits that are
/// not suitable for cryptographic purposes.
///
/// Use the [Random.secure] constructor for cryptographic purposes.

which we're using here, and yeah, one would hope and expect the "secure" implementation to support 1<<32 for a max value. 🙂

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flutter web isn't fundamentally a good fit for Zulip, so we don't really need to worry about the Flutter app ever supporting web.


It looks like Claude is mistaken about the behavior on web in any case. Opening a browser console, it's true that in JavaScript 1 << 32 apparently evaluates to 1. But opening https://dartpad.dev/ in order to run Dart code on web:

void main() {
  print(1 << 32);
}

the result is 0, not 1. And then if you try to pass that to Random.nextInt:

import 'dart:math';

void main() {
  print(Random.secure().nextInt(1 << 32));
}

it throws an error:

Uncaught Error, error: Error: RangeError: max must be in range 0 < max ≤ 2^32, was 0

So even if this Dart code did for some reason end up getting run on web, it would promptly give an error.

@gnprice gnprice marked this pull request as draft March 2, 2026 01:47
@gnprice gnprice marked this pull request as ready for review March 2, 2026 01:47
gnprice added 6 commits March 1, 2026 19:41
This keeps eg.account and eg.initialSnapshot using recent versions
by default.

This change has no effect on any existing logic, as seen by scanning
the output of the following search:

  $ git grep 'zulipFeatureLevel [<>]' lib

All the thresholds we currently condition on are from before 382.
This is only the second time we're using JsonNullable, and the
first time we happen to be using it directly in`readValue`.
It took me a bit to work out just how to use it here;
I'll add more docs on JsonNullable in the next commit.
Based on the experience of using it for the second time,
for DeviceUpdateEvent, in the preceding commit.
…vice

This logic is more about Zulip's API than it is about the platform's
support for notifications.  As we implement the more complex
token-registration model needed for E2EE notifications, we'll be
adding further details from the Zulip API.  So this is the better
home for that logic.
@gnprice
Copy link
Copy Markdown
Member Author

gnprice commented Mar 2, 2026

Thanks for the review! Revision pushed.

@chrisbobbe
Copy link
Copy Markdown
Collaborator

Thanks! LGTM, merging.

@chrisbobbe chrisbobbe merged commit 57cd8c1 into zulip:main Mar 2, 2026
2 checks passed
@gnprice gnprice deleted the pr-e2een-api branch March 3, 2026 00:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer review PR ready for review by Zulip maintainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants