1. Server Discovery
Before the transport layer below can open a connection, the client has to know where the server is and trust its certificate. Both happen once per profile, before registration. (For the simpler Settings app version of this flow, see First-Time Setup.)
Server Address
Each SGN server has a server address, a
domain string of at most 16 characters (e.g.
skyglow.es). This address is stored on the client
during registration and is part of every device token it
generates (see
Device Token Management).
One lookup finds everything.
At startup the client resolves the server's address, port, and
HTTP base from a single DNS TXT record, by prepending
_sgn. to the server address.
The client asks
_sgn.<server_address> e.g. _sgn.skyglow.es
The record answers
"tcp_addr=143.47.32.233 tcp_port=7373 http_addr=https://sgn.example.com"
| Key | Description | Required |
|---|---|---|
tcp_addr |
IPv4 or IPv6 address of the TCP protocol server | Yes |
tcp_port |
Port number of the TCP protocol server (TLS) | Yes |
http_addr |
Base URL of the HTTP API (push submission + cert auto-fetch) | Recommended |
tcp_port is the TLS listener,
not the HTTP API port. The daemon only reads
tcp_addr and tcp_port.
http_addr is used by the prefs UI for certificate
auto-fetch, and without it the user
imports the PEM by hand. Extra keys are always safe to
publish.
DNS Caching
Clients cache resolved DNS records locally in SQLite and reuse them on the next startup. The cache TTL is 1 hour (3600 seconds). An entry older than that is still used right away, with a refresh running in the background, so a DNS outage never blocks a reconnect.
A background refresh runs when:
- a lookup is served from an entry older than the TTL,
-
authentication completes successfully
(
S_AUTH_OK), or - a connection attempt fails.
A connect failure caused by a missing or mismatched server certificate also purges the whole DNS cache, forcing a live lookup on the next attempt.
Certificate Provisioning
The daemon's TLS layer pins one self-signed certificate (see TLS Connection), so the client has to have that certificate before the first TCP connection can succeed. Provisioning is done by the prefs UI, never the daemon, and is separate from device registration. There are two ways to do it.
Auto-Fetch (Recommended)
-
Resolves
_sgn.<server_address>TXT and reads thehttp_addrvalue. -
Issues
GET <http_addr>/snd/server_cert.pem. TLS chain validation is skipped for this request, so the parsed Subject, Issuer, and SHA-256 fingerprint are shown in a confirmation dialog for the user to check against the fingerprint the operator publishes out of band. - On confirmation, writes the PEM to disk and records its path in the profile plist. Every subsequent daemon connection uses strict pinning against that file.
Server-side requirement: a
GET /snd/server_cert.pem route returning
text/plain or
application/x-pem-file whose body is the exact
PEM the TLS listener presents.
Manual Import
The user picks a PEM file with a file picker. The bundle copies it to the same location and updates the profile plist, so the end state is identical to auto-fetch. Useful when the server has no HTTP component, or when the operator hands out certificates out of band.
In both paths the certificate is bound to a specific profile slot, so different profiles may pin different servers.
2. Transport Layer
A single persistent TLS connection carries every frame in both directions.
TLS Connection
All TCP communication runs over a TLS connection. The server uses a self-signed X.509 certificate, and the client obtains it out of band (see Certificate Provisioning) and uses certificate pinning, so only that pinned certificate is trusted.
- Protocol: TLS 1.2+ (SSLv2, SSLv3, TLS 1.0, and TLS 1.1 are explicitly disabled)
- Certificate validation: Pinned server certificate only; the system CA store is not consulted
-
Pinning mechanism: OpenSSL verification is
turned off (
SSL_VERIFY_NONE) and replaced by a direct comparison. The leaf certificate the server presents is DER-encoded and must be byte-for-byte identical to the pinned PEM - Connection model: Single persistent long-lived connection; client reconnects with exponential backoff on failure
- TCP_NODELAY: Enabled on the socket
- Socket timeouts: 10 seconds for both send and receive
-
SIGPIPE: Must be ignored
(
SIG_IGN) to prevent process termination
Frame Format
All messages in both directions use identical binary framing:
| Byte 0 | Byte 1 | Byte 2 | Byte 3 | Bytes 4-7 | Bytes 8+ |
|---|---|---|---|---|---|
| Magic | Version | Type | Reserved | Payload Length | Payload |
| 0x53 | 0x02 | u8 | 0x00 | big-endian u32 | N bytes |
| Field | Size | Description |
|---|---|---|
| Magic | 1 byte | Always 0x53 (ASCII "S") |
| Version | 1 byte | Protocol version, currently 0x02 |
| Type | 1 byte | Message type identifier (Message Types) |
| Reserved | 1 byte |
Must be 0x00. Non-zero values cause a
protocol error.
|
| Payload Length | 4 bytes | Unsigned 32-bit integer, big-endian (network order) |
| Payload | N bytes | Type-specific binary data. Max 4096 bytes. |
Header size: 8 bytes fixed. Maximum payload:
4096 bytes (SGP_MAX_PAYLOAD_LEN).
The one exception to this framing is the optional legacy
compatibility hello a v1 server may send as the first frame of
a connection. See
Legacy (v1) Hello.
Byte Order
All multi-byte integers in payloads are encoded in
big-endian (network byte order). This applies
to the 4-byte payload length in the header, all
int64_t timestamps and sequence numbers (8
bytes), all uint32_t version numbers and data
lengths (4 bytes), and all uint16_t string
lengths (2 bytes).
3. Message Types
Each frame's type byte identifies one of these messages.
Server -> Client (0x1_)
| Type | Name | Description |
|---|---|---|
0x10 |
S_HELLO | Server greeting after TLS handshake |
0x11 |
S_CHALLENGE | Authentication challenge nonce |
0x12 |
S_AUTH_OK | Authentication successful |
0x13 |
S_NOTIFY | Incoming push notification |
0x14 |
S_DISCONNECT | Server is closing the connection |
0x16 |
S_PONG | Response to client keep-alive ping |
0x17 |
S_POLL_DONE | All offline messages delivered |
0x18 |
S_REGISTER_OK | First-time registration succeeded |
0x19 |
S_REGISTER_FAIL | First-time registration failed |
0x1A |
S_PING | Server-initiated keep-alive ping |
0x1B |
S_TIME_SYNC | Clock synchronization message |
Client -> Server (0x2_)
| Type | Name | Description |
|---|---|---|
0x20 |
C_LOGIN | Login handshake initiation |
0x21 |
C_LOGIN_RESP | Response to authentication challenge |
0x22 |
C_POLL | Request offline notifications |
0x23 |
C_ACK | Acknowledge receipt of a notification |
0x24 |
C_DISCONNECT | Client is closing the connection |
0x27 |
C_PING | Client-initiated keep-alive ping |
0x28 |
C_REGISTER | First-time registration request |
0x29 |
C_REGISTER_RESP | Response to registration challenge |
0x2A |
C_PONG | Response to server keep-alive ping |
0x2B |
C_FILTER | Active routing-key registration set (chunked) |
One client frame falls outside the 0x2_ block:
C_UPGRADE (0x00), sent only in reply
to a legacy v1 compatibility hello.
Legacy (v1) Compatibility Hello
A v1 server opens a connection with a property list frame instead of an SGP frame. Clients accept this once, only as the first frame of a connection, so an operator can serve both protocol versions from one listener.
| Bytes 0-3 | Bytes 4+ |
|---|---|
| Length | Property list |
| big-endian u32 | 'bplist00...' (Length bytes) |
The client recognizes it by a first byte that is not the SGP
magic
0x53, and requires
12 <= Length <= 4096 with a body opening
on the 8-byte bplist00 magic. Anything else is
a protocol error. The plist body itself is
not parsed, just read and discarded.
The client then replies with C_UPGRADE and
waits for the server to restart the handshake with an
ordinary S_HELLO:
53 02 00 00 00 00 00 00 Magic=0x53, Version=0x02, Type=C_UPGRADE(0x00), Reserved=0x00, PayloadLen=0
The connection remains in the PreHello phase
(Phase Gating) across this
exchange, and a second non-SGP frame is rejected. Servers
that never emit the legacy hello can ignore this section
entirely.
4. Device Registration
Before a device can authenticate it has to register with the server to get an identity. This happens once.
The server assigns the device address. The
client never picks its own identifier. It sends only a freshly
generated RSA keypair and waits for the server to return an
address in S_REGISTER_OK. That keeps addresses
unique across the namespace, and lets the server encode
routing information into the address itself (e.g.
<hex>@<server-id>).
C_REGISTER (0x28) Payload
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 2 | pubkey_len | Length of DER public key (BE u16) |
| 2 | pubkey_len | public_key |
RSA-2048 public key, DER (i2d_RSA_PUBKEY)
|
| 2+PL | 8 | timestamp | Unix time, skew-corrected (BE i64) |
| 10+PL | 4 | version | Protocol version 0x02 (BE u32) |
The client generates an RSA-2048 keypair: the public key is sent to the server, the private key is stored locally and never transmitted. The device address field is absent, since the server assigns it.
S_REGISTER_OK (0x18) Payload
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | server_version | Server's protocol version (BE u32) |
| 4 | 2 | addr_len | Length of assigned address (BE u16, max 255) |
| 6 | addr_len | address | Server-assigned device address (UTF-8) |
The address is opaque to the client. The reference server
issues <32 hex chars>@<server-id>,
but clients MUST NOT parse this format. Max length
255 bytes.
Address charset: clients MUST reject any
address outside [A-Za-z0-9._@-].
S_REGISTER_FAIL (0x19) Payload
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | code | Rejection reason code (u8) |
| 1 | 2 | reason_len | Length of reason string (BE u16) |
| 3 | reason_len | reason | Human-readable reason (UTF-8) |
Optional Registration Client Certificate
Registration can be gated by mutual TLS. A profile may be provisioned out of band with a PEM bundle (client certificate + private key). When present, the daemon presents it as its TLS client certificate on the registration connection.
Used only while unregistered, and authorizes exactly one
successful registration. Committing
S_REGISTER_OK deletes the PEM and zeros the
in-memory copy in the same step. Independent of the RSA
identity used for login (Authentication Flow).
5. Authentication
Authentication is an RSA-PSS challenge-response. The client holds an RSA-2048 private key, and the server holds the matching public key it got during registration.
S_HELLO (0x10) / C_LOGIN (0x20)
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | server_version (BE u32) |
S_HELLO is sent immediately after the TLS handshake completes.
| Offset | Size | Field |
|---|---|---|
| 0 | 2 | addr_len (BE u16) |
| 2 | addr_len | address (UTF-8) |
| 2+AL | 8 | timestamp (BE i64) |
| 10+AL | 4 | version (BE u32) |
S_CHALLENGE / Response Payloads
| Offset | Size | Field |
|---|---|---|
| 0 | 32 | nonce (server-generated) |
Shared by both the login and registration flows.
| Offset | Size | Field |
|---|---|---|
| 0 | 8 | timestamp (BE i64) |
| 8 | 2 | sig_len (BE u16) |
| 10 | sig_len | signature |
RSA-PSS Signature Scheme
The client signs a buffer that depends on the flow:
| Flow | Signed material |
|---|---|
| Login (C_LOGIN_RESP) |
nonce || address_utf8 || timestamp_be64
|
| Registration (C_REGISTER_RESP) |
nonce || timestamp_be64 (no address yet)
|
-
Compute
digest = SHA-256(signed_material) - Apply RSA-PSS padding with SHA-256 as both the hash and MGF1 hash, salt length = digest length (32 bytes)
-
Sign with
RSA_private_encrypt(padded_message, RSA_NO_PADDING)
For login, the server verifies using the public key stored
under the claimed address. For registration, it verifies
with the public key just received in the preceding
C_REGISTER frame, since the address is not yet
bound.
S_AUTH_OK & Clock Skew
S_AUTH_OK (0x12) has an empty payload (0 bytes)
and confirms the client has authenticated.
The server may only send S_TIME_SYNC (0x1B) after
authentication completes. It carries an 8-byte big-endian Unix
timestamp. The client computes
offset = server_time - local_time and applies it
to later login and register timestamps. Offsets beyond
+/-172 800 seconds (2 days) are rejected as a
protocol error.
Protocol Phase Gating
To guard against unsolicited server frames, the client runs a strict phase machine and rejects any message outside its allowed phase.
| Phase | Allowed server frames |
|---|---|
| PreHello | S_HELLO |
| HelloReceived | (none; client sends C_LOGIN or C_REGISTER next) |
| ChallengeWait | S_CHALLENGE, S_REGISTER_FAIL |
| AuthWait | S_AUTH_OK, S_REGISTER_OK, S_REGISTER_FAIL |
| Authenticated | S_NOTIFY, S_PING, S_PONG, S_POLL_DONE, S_TIME_SYNC |
S_DISCONNECT is accepted in any phase.
S_PING/S_PONG only in
Authenticated. Any frame outside its allowed
phase triggers SGP_ERR_PROTO and a clean teardown
(soft error -> backoff + reconnect). Servers MUST NOT rely
on sending frames out of phase for any side-effect.
6. Notification Delivery
How pushes reach the device over the live connection, and how they are acknowledged and re-polled.
S_NOTIFY (0x13) Payload Layout
routing_key |
msg_id |
seq |
expires_at |
flags |
content_type |
data_len |
data |
[iv] |
|---|---|---|---|---|---|---|---|---|
| 32 bytes | 16 bytes | 8 B | 8 B | 1 B | 1 B | 4 B (BE) | data_len B | 12 B |
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 32 | routing_key | SHA-256 hash of the token secret K |
| 32 | 16 | msg_id | Unique notification ID (raw 16 bytes, UUID) |
| 48 | 8 | seq | Server-assigned per-device sequence number (BE i64) |
| 56 | 8 | expires_at | Expiration timestamp (BE i64), 0 = no expiry |
| 64 | 1 | flags |
Bit 0 is_encrypted (AES-256-GCM); Bit 1
is_compressed (raw DEFLATE)
|
| 65 | 1 | content_type | Payload format identifier (Content Types) |
| 66 | 4 | data_len | Length of the data field in bytes (BE u32) |
| 70 | data_len | data | Notification payload (plaintext or ciphertext+tag) |
| 70+DL | 12 | iv |
AES-GCM IV (only present when
is_encrypted = 1)
|
Minimum payload size: 70 bytes (empty data, unencrypted).
Notification Processing
-
Look up
routing_keyto find the bundle ID and E2EE key. -
If
is_encrypted: decrypt with AES-256-GCM using the stored key andiv; the last 16 bytes ofdataare the GCM tag; AAD is empty. -
If
is_compressed: inflate as raw DEFLATE (windowBits = -15), capped at 65536 bytes (SGP_MAX_INFLATED_LEN) as a decompression-bomb guard. -
Parse the resulting bytes per
content_type. - Deliver the canonical payload to the target application.
- Send a
C_ACKmessage.
Ordering when both flags are set: the server compresses first, then encrypts, and the client does the inverse: decrypt, then inflate.
C_ACK (0x23) Payload
| Offset | Size | Field |
|---|---|---|
| 0 | 16 | msg_id |
| 16 | 1 | status |
| Code | Meaning |
|---|---|
| 0 | Delivered successfully |
| 1 | Decryption failure |
| 2 | Deserialization failure |
| 3 | Expired before delivery |
Sent right away if connected, otherwise persisted to SQLite and flushed once the connection is back.
C_POLL (0x22) Payload
| Offset | Size | Field |
|---|---|---|
| 0 | 8 | last_seq (BE i64) |
Requests re-delivery of any notification with a sequence
number greater than last_seq. Typically sent
immediately after authentication.
S_POLL_DONE (0x17) Payload
Empty payload (0 bytes). Signals that the server has finished delivering all queued offline messages.
7. Payload Formats
What lives inside a notification payload.
content_type,
Content Types), payload
compression (is_compressed), structured-TLV
encoding, and the optional registration client certificate are
backward-compatible additions: a peer that
predates them sends content_type = 0x00, leaves
the compression flag clear, and presents no client
certificate.
Content Types
The content_type byte selects how the payload
bytes (after decryption/decompression) are decoded. Every
content type describes the same object, an
APNS-style userInfo dictionary ({ "aps": { ... }, <custom keys> }), and all decode to one canonical dictionary before
delivery.
| Value | Format | Notes |
|---|---|---|
0x00 |
Flat TLV | Compact title/body/sound/custom_data only. |
0x01 |
JSON | UTF-8 JSON object. |
0x02 |
Property list | Binary (bplist0) or XML plist. |
0x03 |
Structured TLV | Typed, recursive binary, full fidelity. Default. |
content_type
contradicts them. This is a sanity check, not the security
boundary. Because the GCM AAD is empty, a sender that needs
content_type integrity must bind it out of
band.
Flat TLV (0x00)
| Type | Length | Value |
|---|---|---|
| 1 B | 2 B (BE) | Length bytes |
The Type/Length/Value unit repeats until the payload is exhausted.
| Type | Key | Value Type | Description |
|---|---|---|---|
0x01 |
title | UTF-8 | Notification title |
0x02 |
body | UTF-8 | Notification body text |
0x03 |
sound | UTF-8 | Sound name |
0x04 |
custom_data | Raw bytes | Application-specific data |
These map to aps.alert.title,
aps.alert.body, aps.sound, and a
top-level custom_data respectively.
JSON (0x01) and Property List (0x02)
The payload is either a UTF-8 JSON object or an Apple property
list (binary bplist0 or XML). Either one is
deserialized and canonicalized straight into the
userInfo dictionary. Values a property list
cannot hold (e.g. JSON null) are stripped so the
result survives the binary-plist IPC hop to SpringBoard.
Nesting is capped at 32 levels.
Structured TLV (0x03)
A typed, recursive binary encoding of the same object model JSON/plist describe, tuned for size: only variable-length types carry a length, and every length/integer is a varint, so small values cost 1-2 bytes.
Varint is unsigned LEB128: base-128,
little-endian groups; each byte holds 7 value bits, high bit
0x80 = "another byte follows". At most 10 bytes;
overflow rejected; encoders emit the minimal byte count.
Every VALUE is type: uint8, then
[length: varint] (only for MAP, ARRAY, STRING,
DATA), then payload:
| Type | Name | Length? | Payload |
|---|---|---|---|
0x01 |
MAP | yes |
Entries packed until length exhausted. Entry =
keyLen:varint, key:UTF-8, value:VALUE.
Duplicate keys: last wins.
|
0x02 |
ARRAY | yes | VALUEs packed until length exhausted. |
0x03 |
STRING | yes | UTF-8 bytes (not NUL-terminated); must be valid UTF-8. |
0x04 |
INT | no | One zig-zag varint of a signed int64. |
0x05 |
DOUBLE | no | Exactly 8 bytes, IEEE-754 binary64, big-endian. NaN/Inf rejected. |
0x06 |
BOOL | no |
Exactly 1 byte: 0x00 false,
0x01 true.
|
0x07 |
NULL | no | No payload. |
0x08 |
DATA | yes | Raw bytes (TLV-native; no base64 needed). |
The top-level VALUE must be a MAP. Container lengths must frame their children exactly. Nesting is capped at 32 levels.
8. Device Tokens
Each app that wants notifications needs a device token. It is generated on the client, registered with the server, and handed to the app. The app passes it to its backend service, which uses it (through the SGN HTTP API) to send notifications.
Token Generation Algorithm
1. K = SecureRandom(16) // 16 cryptographically random bytes
2. routing_key = SHA-256(K) // 32 bytes
3. salt = UTF8(server_address) + "Hello from the Skyglow Notifications developers!"
e2ee_key = HKDF-SHA256(
key_material = K,
salt = salt,
info = <empty>,
output_length = 32
) // 32 bytes
4. padded_addr = PadRight(UTF8(server_address), 16, 0x00)
device_token = padded_addr || K // 32 bytes total
| What | Stored Locally | Sent to Server | Given to App |
|---|---|---|---|
| K | Indirectly | No | Indirectly |
| routing_key | Yes | Yes | No |
| e2ee_key | Yes | No | No |
| device_token | Yes | No | Yes |
C_FILTER (0x2B) Payload
C_FILTER carries the device's complete
(tag, routing_key, bundle_id) registration set.
The server treats the multi-chunk transmission as a single
atomic full-replace for both the routing filter and the bundle
binding table.
Sent right after S_AUTH_OK on every connection,
and after any local change to the registration set (add, mute,
unmute, delete). When the entries do not fit in one frame they
are chunked, and the server accumulates until
has_more = 0, then atomically replaces its state.
If the connection drops mid-transmission, the server MUST
discard the partial buffer.
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | flags | Bit 0: has_more |
| 1 | 2 | count | Entries in this chunk (BE u16) |
| 3 | var. | entries | count entries |
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | tag | 0x01 enabled, 0x02 ignored |
| 1 | 32 | routing_key | SHA-256(K) |
| 33 | 2 | bid_len | Bundle ID length (BE u16) |
| 35 | bid_len | bundle_id | Bundle identifier (UTF-8) |
Tag 0x02 (ignored) is how the
toggle-off mute works. The daemon silently ACKs SUCCESS
without dispatching, a defensive drop, not a delete. The
client packs entries greedily and starts a new chunk when the
next one would exceed SGP_MAX_PAYLOAD_LEN (4096).
An empty registration set is a single frame with
flags=0, count=0 and no entries.
9. Connection Lifecycle
Keeping the connection alive, closing it cleanly, and getting it back.
Keep-Alive Mechanism
The protocol supports
bidirectional keep-alive pings, all with an
8-byte BE i64 seq payload:
C_PING (0x27), S_PONG (0x16) echoes
it, S_PING (0x1A), and
C_PONG (0x2A) echoes it.
The client uses an adaptive keep-alive algorithm that searches for the longest interval the network path will hold open, in four stages:
- Initial growth, where the interval climbs in ~300 s steps (+/-20 s jitter) until a ping fails
- Refined growth, where after a failure it falls back to the last good interval and creeps up in ~120 s steps
-
Steady state, settled at a sustainable
interval, re-probing after
max(24 x interval, 3600 s) - Backoff, where the interval halves on each failure until pings succeed again
Interval bounds: 600 s minimum, max
3600 s on Wi-Fi or
1680 s on WWAN; learned per network type and
restored on the next connection. Entirely client-side. Pong
timeout:
15 seconds
(SGP_PONG_TIMEOUT_SEC).
Disconnect Messages
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | reason |
| 1 | 4 | retry_after (BE u32, optional) |
| Code | Name | Class |
|---|---|---|
0x00 |
NORMAL | soft |
0x01 |
AUTH_FAIL | hard |
0x02 |
PROTOCOL | soft |
0x03 |
SERVER_ERR | soft |
0x04 |
REPLACED | soft |
0x05 |
VERSION_MISMATCH | hard |
Soft errors trigger the reconnect backoff
below, honoring retry_after when it is longer
than the local delay (clamped to 600 s).
Hard errors are terminal, and the client
stops reconnecting until a manual configuration change.
Reconnection Strategy
Exponential backoff with jitter, retrying at the maximum interval indefinitely once reached:
initial_delay = 2s, max_delay = 600s, max_jitter = 5s
failures = 0
loop:
connect_and_authenticate()
on failure:
failures += 1
delay = min(initial_delay * 2^(failures-1)
+ rand(0..max_jitter), max_delay)
if retry_after > delay: delay = min(retry_after, max_delay)
sleep(delay)
Network changes, configuration reloads, and system wakes reset the current backoff and trigger an immediate attempt.
S_TIME_SYNC (0x1B) Payload
| Offset | Size | Field |
|---|---|---|
| 0 | 8 | server_time (BE i64) |
The client computes
offset = server_time - local_time
and applies it to all login/registration timestamps, which
handles devices with unreliable NTP (e.g. iOS 3-5 era
hardware).
10. Security & Appendices
How notification payloads are protected end to end, plus reference layouts and complete wire examples.
Key Derivation
Both the sender and receiver independently derive the same key from the shared secret K (see Device Token Management):
salt = UTF8(server_address) + "Hello from the Skyglow Notifications developers!"
e2ee_key = HKDF-SHA256(
key_material = K, // 16 bytes, extracted from device_token[16:32]
salt = salt,
info = <empty>,
output_length = 32
)
The sender extracts K from the device token (bytes 16-31) and
the server address from bytes 0-15 (trimming trailing
0x00 bytes).
Encryption (Sender Side)
iv = SecureRandom(12)
ciphertext, tag = AES-256-GCM-Encrypt(
key = e2ee_key,
iv = iv,
plaintext = TLV_serialize(payload),
aad = <none>
)
// data = ciphertext || tag (tag is 16 bytes)
// iv = iv (12 bytes, after data)
// flags = 0x01 (is_encrypted = true)
Decryption (Client Side)
ciphertext = data[0 .. len-16]
tag = data[len-16 .. len]
plaintext = AES-256-GCM-Decrypt(
key = e2ee_key, // by routing_key
iv = iv,
ciphertext = ciphertext,
tag = tag,
aad = <none>
)
If decryption or tag verification fails, the client
acknowledges with status code 1.
- TLS with certificate pinning prevents man-in-the-middle attacks. The client trusts only the specific certificate provisioned into the profile (Certificate Provisioning), obtained before and independently of registration.
- RSA-PSS challenge-response authentication authenticates the client to the server. The server is authenticated separately by the pinned certificate; the channel is mutually authenticated at the TLS layer only when a registration client certificate is in use.
- Timestamp validation on challenges, combined with unique nonces, prevents replay attacks.
- End-to-end encryption ensures the server operator cannot read notification payloads, since the server only sees opaque routing keys and ciphertext.
- Routing key is a one-way hash of the secret K. The server never learns K and cannot derive the E2EE key.
- Device token structure embeds the server address, enabling clients to route tokens to the correct server in a multi-server (federated) deployment.
- SIGPIPE handling means clients MUST ignore SIGPIPE to prevent process termination when the server drops the connection unexpectedly.
- Key material zeroing means the client zeros all private key material in memory before freeing, using volatile writes to prevent compiler dead-store elimination.
- Clock skew correction via S_TIME_SYNC prevents authentication failures on devices with drifted system clocks.
Appendix A: Device Token Binary Layout
| Byte Offset | Length | Content |
|---|---|---|
| 0 | 16 |
Server address (UTF-8, right-padded with
0x00)
|
| 16 | 16 | K (cryptographic random secret) |
| Total | 32 | bytes |
The token is opaque to the receiving application. A sending
service: reads bytes 0-15 and trims trailing
0x00 for the server address; resolves that
address via DNS TXT (see
DNS TXT Resolution); reads bytes
16-31 for secret K; derives
routing_key = SHA-256(K); and derives
e2ee_key via HKDF-SHA256 if sending encrypted
payloads.
Appendix B: Wire Examples
53 02 20 00 00 00 00 32 Magic=0x53, Version=0x02, Type=C_LOGIN(0x20), Reserved=0x00, PayloadLen=50
00 24 addr_len = 36 61 62 63 64 65 66 ... (36 B) address = "abcdefgh-1234-5678-9abc-def012345678" 00 00 01 8E 2A 3B 4C 5D timestamp (BE i64) 00 00 00 02 version = 2 (BE u32)
[32 bytes routing_key] [16 bytes msg_id] [8 bytes seq (BE i64)] [8 bytes expires_at (BE i64)] 01 flags: is_encrypted = 1 00 content_type 00 00 00 40 data_len = 64 (BE u32) [64 bytes: ciphertext(48) || GCM tag(16)] [12 bytes: IV]
[16 bytes msg_id] 00 status = 0 (success)