Skip to content

feat: add a wake gateway client module - #160

Draft
coreyphillips wants to merge 5 commits into
masterfrom
feat/wake-client
Draft

coreyphillips wants to merge 5 commits into
masterfrom
feat/wake-client

Conversation

@coreyphillips

@coreyphillips coreyphillips commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

feat: add a wake gateway client module

Summary

Adds src/modules/wake, the device side of the wake push gateway, the service that replaces bitkit-notification-server. It covers what an app needs to receive wakes through the gateway:

  • generate the device's encryption key pair, device secret and install id;
  • prepare a registration, sign it with the node (ln:) and pubky (pk:) identities, and register;
  • find and decrypt the wake in any delivered push, legacy (v0) or v1;
  • acknowledge wakes, report presence, change topics and unregister.

The module is a port of the gateway's wake-proto crate, which bitkit-core cannot depend on. It has no database state and no cfg-gated exports. register_device and test_notification are unchanged, so nothing changes for the apps until they call the new functions.

API

fn wake_generate_keypair() -> Result<WakeKeyPair, WakeError>
fn wake_generate_device_secret() -> Result<WakeDeviceSecret, WakeError>
fn wake_generate_install_id() -> String
fn wake_prepare_registration(audience, app, install_id, platform, environment, push_token,
    encryption_public_key, secret_sha256, identities, topics, timestamp: Option<u64>)
    -> Result<WakeRegistrationRequest, WakeError>
fn wake_sign_pubky_proof(secret_key_hex, message) -> Result<String, WakeError>
fn wake_decrypt_push(secret_key_hex, push_json) -> Result<WakeEnvelope, WakeError>
fn wake_decrypt_v0(secret_key_hex, cipher, iv, tag, public_key) -> Result<WakeEnvelope, WakeError>
fn wake_decrypt_v1(secret_key_hex, container_json) -> Result<WakeEnvelope, WakeError>
async fn wake_server_info(gateway_url) -> Result<WakeServerInfo, WakeError>
async fn wake_register(gateway_url, request, proofs) -> Result<WakeRegistration, WakeError>
async fn wake_ack(gateway_url, device_secret, wake_id, outcome) -> Result<(), WakeError>
async fn wake_set_presence(gateway_url, device_secret, ttl_secs) -> Result<u64, WakeError>
async fn wake_clear_presence(gateway_url, device_secret) -> Result<(), WakeError>
async fn wake_set_topics(gateway_url, device_secret, topics) -> Result<Vec<String>, WakeError>
async fn wake_unregister(gateway_url, device_secret) -> Result<(), WakeError>
async fn wake_list_topics(gateway_url) -> Result<Vec<WakeTopic>, WakeError>

The host signs WakeRegistrationRequest.message: ln: through ldk-node signMessage, pk: through wake_sign_pubky_proof, which refuses anything that is not a wake registration message so the pubky key cannot be used here to sign other data. wake_prepare_registration checks every field the way the gateway does, so a request the gateway would reject as invalid_request fails before anything is signed, and wake_register refuses a request whose fields no longer match its message.

The HTTP calls are pure build_* and parse_* functions around a thin reqwest executor (15 s timeout, no redirects). Every non-2xx response maps to WakeError::GatewayRejected { status, code, detail } (not message, which Kotlin exceptions already define) from the gateway's {"error","message"} body, with code unknown for any other body. Async exports use the existing ensure_runtime().spawn(...) pattern.

Crypto and wire compatibility

  • ECDH through bitcoin::secp256k1::ecdh::shared_secret_point, compressed to the 33-byte point (0x02 | y & 1) || x.
  • key = SHA256(SHA256(S33 || label)), computed as two plain sha256::Hash::hash calls, not with the sha256d type, whose display is byte-reversed.
  • AES-256-GCM through aes-gcm 0.10.3, no AAD, 16-byte tag; the IV must be 12 or 16 bytes.
  • v0 is the envelope deployed apps already decrypt (label bitkit-notifications, 16-byte IV). v1 uses label wake-v1 and a 12-byte IV. A container v other than 1 is UnsupportedEnvelope.
  • wake_decrypt_push accepts the four delivered shapes (APNs aps.alert.payload, flat FCM legacy data, APNs wake object, FCM wake JSON string) and returns fallback = true for a wake_fallback marker without decrypting.
  • The producer payload comes back as raw JSON, byte for byte (serde_json gains raw_value).

Dependency changes: aes-gcm = "0.10.3" (already in the lockfile through trezor-connect-rs) and the raw_value feature on serde_json. The only Cargo.lock change is the new direct dependency edge.

Vectors

src/modules/wake/test_vectors/*.json are copied byte for byte from the wake repository (crates/wake-proto/vectors, commit 782f66510c505b0331e6299e96c1233d3c263484, recorded in the module README). The tests assert them exactly:

  • the v0 server envelope (323 bytes), rebuilt byte for byte from its inputs;
  • the v0 client vector from bitkit-ios and bitkit-android, including S33 028ce542...80ee3 and key 3a9d552c...33b609b;
  • the v1 envelope, rebuilt byte for byte;
  • the registration preimage, its SHA-256, the pubky proof, and the ln proof reproduced by a local stand-in for ldk-node signMessage;
  • every case in pushes.json (all APNs and FCM shapes plus both fallbacks) through the push parser and wake_decrypt_push, including the FCM data-map-only view.

Negative cases cover a tampered tag or ciphertext, a wrong key, the wrong label, an 11-byte IV, unknown container and plaintext versions, unpadded base64, an off-curve ephemeral key, misplaced containers and every push shape the parser cannot place.

What is not included

  • No iOS or Android app changes.
  • Grants and peer sends (/v1/grants, /v1/peer/wakes) are deferred.

Wire format

The v1 wire format is experimental until the apps adopt it. The legacy (v0) envelope is unchanged and stable.

Tests run

  • cargo fmt --check
  • cargo clippy --all-targets: no new warnings (the counts match master: 29 lib, 12 example, 81 lib test)
  • cargo test wake: 39 passed, 1 ignored (the live gateway test)
  • cargo test (whole crate): 576 passed, 27 ignored, 11 failed. The 11 failures are the Blocktank tests that call https://api.stag.blocktank.to, which timed out from the machine this ran on; no other suite failed
  • The Swift interface files were regenerated from the host library the way build_ios.sh does; the diff is only the wake functions and types.
  • WAKE_GATEWAY_URL=http://127.0.0.1:9010 cargo test wake -- --ignored: live_gateway_round_trip passed against the wake repo's docker dev stack (gateway commit 782f665, public listener 127.0.0.1:9010). It covers wake_server_info, a registration signed with an ln: and a pk: proof, presence set and clear, wake_list_topics, wake_set_topics (an unknown topic is dropped), and wake_unregister, after which the device secret is refused with 401. Acks and decryption are not part of the live test; they rely on the vectors and the offline unit tests (build_ack, the push parser and wake_decrypt_push).

Draft

Draft because the gateway is not deployed yet, and its repository (a standalone Rust service that replaces bitkit-notification-server) is not published yet. The live round trip has passed against a local gateway stack (see Tests run).

Adds src/modules/wake, the device side of the wake push gateway, as a
port of the gateway's wake-proto crate:

- key pair, device secret and install id generation;
- wake_prepare_registration builds the 12-line message every identity
  signs and checks each field the way the gateway does;
  wake_sign_pubky_proof signs it with the pubky key and refuses any
  other message;
- wake_decrypt_push finds the wake in any delivered APNs or FCM shape and
  decrypts legacy (v0) and v1 envelopes; a wake_fallback marker is
  returned without decrypting;
- register, ack, presence, topics, unregister, info and topic listing,
  as pure build and parse functions around a thin reqwest executor that
  does not follow redirects.

The AES key is SHA256(SHA256(S33 || label)) over the compressed ECDH
point, computed as two plain sha256 passes rather than the sha256d type,
whose display is byte-reversed. IVs must be 12 or 16 bytes.

The tests assert the wake-proto golden vectors byte for byte (copied
from wake commit 6037343), including rebuilding the v0 and v1 envelopes
and the ln proof, and run every pushes.json case through the parser.

Adds aes-gcm 0.10.3, already in the lockfile, and the serde_json
raw_value feature so payloads come back as the producer sent them.
register_device and test_notification are unchanged.
Generated the way build_ios.sh does: cargo build --release, then
uniffi-bindgen generate --library target/release/libbitkitcore.dylib
--language swift. Before the wake change the same steps reproduced the
committed files exactly, so the diff is only the wake functions and
types (additions only). module.modulemap is unchanged. Package.swift and
the version are untouched.
Kotlin exceptions already have a message property, so a field of that
name in an error variant breaks the Android bindings build.
Resolves the Cargo.toml and module list conflicts with the USDT work and
regenerates the Swift interface from the merged library.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant