diff --git a/Dawa.sln b/Dawa.sln index 63d18dc..09833fb 100644 --- a/Dawa.sln +++ b/Dawa.sln @@ -1,32 +1,82 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 - Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawa", "src\Dawa\Dawa.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawa.Console", "samples\Dawa.Console\Dawa.Console.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawa.Demo", "samples\Dawa.Demo\Dawa.Demo.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}" EndProject - +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawa.Tests", "tests\Dawa.Tests\Dawa.Tests.csproj", "{E6966028-2FC7-4734-9FDA-77BD0F825B7F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU - {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|x64.Build.0 = Debug|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|x86.Build.0 = Debug|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|Any CPU.Build.0 = Release|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|x64.ActiveCfg = Release|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|x64.Build.0 = Release|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|x86.ActiveCfg = Release|Any CPU + {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E6966028-2FC7-4734-9FDA-77BD0F825B7F} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection EndGlobal diff --git a/src/Dawa/Auth/AuthState.cs b/src/Dawa/Auth/AuthState.cs index 309db40..016db57 100644 --- a/src/Dawa/Auth/AuthState.cs +++ b/src/Dawa/Auth/AuthState.cs @@ -53,6 +53,13 @@ public sealed class AuthState [JsonPropertyName("lastAccountSyncTimestamp")] public long LastAccountSyncTimestamp { get; set; } + /// + /// ADVSignedDeviceIdentity protobuf bytes (set during QR pairing). + /// Used for device-identity node when sending pkmsg. + /// + [JsonPropertyName("account")] + public byte[]? Account { get; set; } + /// True if this is a fresh (unauthenticated) state. [JsonIgnore] public bool IsFresh => Me == null; @@ -96,9 +103,13 @@ public static AuthState CreateNew() private static byte[] SignPreKey(byte[] identityPriv, byte[] preKeyPub) { // XEdDSA: uses the Curve25519 identity private key directly as an Ed25519 scalar. - // This matches Baileys / Signal's curve25519-js curve25519_sign() behaviour. + // WhatsApp requires signing the 33-byte KEY_BUNDLE_TYPE form: [0x05 || pubKey]. + // Baileys signedPreKey: curve.sign(identityKey.private, Buffer.concat([Buffer.from([5]), signedPreKey.public])) // Returns a 64-byte signature (R ‖ s). - return XEdDSA.Sign(identityPriv, preKeyPub); + var message = new byte[33]; + message[0] = 0x05; // KEY_BUNDLE_TYPE + preKeyPub.CopyTo(message, 1); + return XEdDSA.Sign(identityPriv, message); } } diff --git a/src/Dawa/Auth/SessionStore.cs b/src/Dawa/Auth/SessionStore.cs index 1193bb2..41ac5e1 100644 --- a/src/Dawa/Auth/SessionStore.cs +++ b/src/Dawa/Auth/SessionStore.cs @@ -32,7 +32,12 @@ public async Task LoadAsync(CancellationToken ct = default) var state = JsonSerializer.Deserialize(json, _jsonOpts); if (state != null) return state; } - return AuthState.CreateNew(); + // No saved session — generate fresh keys and save immediately. + // This ensures reconnects during QR phase (before pairing) reuse the same keys + // that were encoded in the QR, so the phone can pair successfully. + var fresh = AuthState.CreateNew(); + await SaveAsync(fresh, ct); + return fresh; } /// Saves the current auth state to disk. diff --git a/src/Dawa/Binary/BinaryNodeDecoder.cs b/src/Dawa/Binary/BinaryNodeDecoder.cs index da0bdea..a65c034 100644 --- a/src/Dawa/Binary/BinaryNodeDecoder.cs +++ b/src/Dawa/Binary/BinaryNodeDecoder.cs @@ -87,10 +87,22 @@ private static byte[] ReadBinary(ref BinaryReader reader) return reader.ReadBytes(length); } + // Sentinel returned when ReadString encounters StreamEnd (byte 0x02). + internal const string StreamEndSentinel = "\x02"; + private static string ReadString(ref BinaryReader reader) { var b = reader.ReadByte(); + // StreamEnd byte — signals server closed the stream. + if (b == WATags.StreamEnd) + return StreamEndSentinel; + + // Single-byte tokens: byte value IS the index into SingleByteTokens (Baileys convention). + if (b >= 1 && b < WATags.DictionaryBase) + return WATags.GetSingleByteToken(b) ?? $"[TOKEN{b}]"; + + // Double-byte tokens: next byte is the index into the chosen dictionary. if (b >= WATags.DictionaryBase && b <= WATags.DictionaryBase + 3) { int dictIndex = b - WATags.DictionaryBase; @@ -123,6 +135,25 @@ private static string ReadString(ref BinaryReader reader) var server = ReadString(ref reader); return $"{user}@{server}"; } + case WATags.AdJid: + { + // AD_JID (0xF7): domainType byte + device byte + user string + // Baileys: readAdJid() in decode.js + var domainType = reader.ReadByte(); + var device = reader.ReadByte(); + var user = ReadString(ref reader); + var server = (domainType == 0 || domainType == 128) ? "s.whatsapp.net" : "lid"; + return device == 0 ? $"{user}@{server}" : $"{user}:{device}@{server}"; + } + case WATags.InteropJid: + case WATags.FbJid: + { + // Consume bytes to stay in sync, return placeholder + var domainType = reader.ReadByte(); + var device = reader.ReadByte(); + var user = ReadString(ref reader); + return $"{user}@fb"; + } case WATags.Nibble8: { var size = reader.ReadByte(); diff --git a/src/Dawa/Binary/BinaryNodeEncoder.cs b/src/Dawa/Binary/BinaryNodeEncoder.cs index dc11a84..72d794e 100644 --- a/src/Dawa/Binary/BinaryNodeEncoder.cs +++ b/src/Dawa/Binary/BinaryNodeEncoder.cs @@ -95,20 +95,42 @@ private static void WriteString(Stream s, string value) return; } - // Try dictionary lookup first - if (WATags.TryGetToken(value, out var dictByte, out var idxByte)) + // Try single-byte token first (1 byte) + if (WATags.TryGetSingleByteToken(value, out var singleByte)) + { + s.WriteByte(singleByte); + return; + } + + // Try double-byte token (2 bytes: DictionaryBase+dict, index) + if (WATags.TryGetDoubleByteToken(value, out var dictByte, out var idxByte)) { s.WriteByte(dictByte); s.WriteByte(idxByte); return; } - // Check if it's a JID (user@server) + // Check if it's a JID (user@server or @server with empty user) var atIdx = value.IndexOf('@'); - if (atIdx > 0) + if (atIdx >= 0) { var user = value[..atIdx]; var server = value[(atIdx + 1)..]; + + // Check if this is an AD-JID (multi-device) with format "user:device@server" + var colonIdx = user.IndexOf(':'); + if (colonIdx >= 0 && int.TryParse(user[(colonIdx + 1)..], out var deviceId)) + { + var baseUser = user[..colonIdx]; + // AD_JID format: tag(247) + domainType + device + writeString(user) + byte domainType = server == "lid" ? (byte)1 : (byte)0; + s.WriteByte(WATags.AdJid); + s.WriteByte(domainType); + s.WriteByte((byte)deviceId); + WriteString(s, baseUser); + return; + } + s.WriteByte(WATags.JidPair); WriteString(s, user); WriteString(s, server); diff --git a/src/Dawa/Binary/WATags.cs b/src/Dawa/Binary/WATags.cs index 0b2cc80..3ef1c99 100644 --- a/src/Dawa/Binary/WATags.cs +++ b/src/Dawa/Binary/WATags.cs @@ -2,14 +2,17 @@ namespace Dawa.Binary; /// /// WhatsApp binary encoding constants and dictionaries. -/// These tag bytes and string dictionaries are used to compress common strings. +/// Sourced from @whiskeysockets/baileys SINGLE_BYTE_TOKENS and DOUBLE_BYTE_TOKENS. /// public static class WATags { // Special byte tags public const byte ListEmpty = 0; public const byte StreamEnd = 2; - public const byte DictionaryBase = 236; // 0xEC (DICTIONARY_0 through _3) + public const byte DictionaryBase = 236; // 0xEC (DICTIONARY_0 through _3 follow as second byte) + public const byte InteropJid = 245; // 0xF5 + public const byte FbJid = 246; // 0xF6 + public const byte AdJid = 247; // 0xF7 — multi-device JID: domainType + device + user public const byte List8 = 248; public const byte List16 = 249; public const byte JidPair = 250; @@ -19,138 +22,252 @@ public static class WATags public const byte Binary32 = 254; public const byte Nibble8 = 255; - // Dictionaries — ordered lists of common WhatsApp strings. - // Index in array = compressed token byte (after base offset). + // Single-byte tokens: index = byte value (1–235). + // Matches Baileys SINGLE_BYTE_TOKENS exactly. + public static readonly string?[] SingleByteTokens = + [ + null, "xmlstreamstart", "xmlstreamend", "s.whatsapp.net", "type", "participant", "from", "receipt", + "id", "notification", "disappearing_mode", "status", "jid", "broadcast", "user", "devices", + "device_hash", "to", "offline", "message", "result", "class", "xmlns", "duration", + "notify", "iq", "t", "ack", "g.us", "enc", "urn:xmpp:whatsapp:push", "presence", + "config_value", "picture", "verified_name", "config_code", "key-index-list", "contact", "mediatype", "routing_info", + "edge_routing", "get", "read", "urn:xmpp:ping", "fallback_hostname", "0", "chatstate", "business_hours_config", + "unavailable", "download_buckets", "skmsg", "verified_level", "composing", "handshake", "device-list", "media", + "text", "fallback_ip4", "media_conn", "device", "creation", "location", "config", "item", + "fallback_ip6", "count", "w:profile:picture", "image", "business", "2", "hostname", "call-creator", + "display_name", "relaylatency", "platform", "abprops", "success", "msg", "offline_preview", "prop", + "key-index", "v", "day_of_week", "pkmsg", "version", "1", "ping", "w:p", + "download", "video", "set", "specific_hours", "props", "primary", "unknown", "hash", + "commerce_experience", "last", "subscribe", "max_buckets", "call", "profile", "member_since_text", "close_time", + "call-id", "sticker", "mode", "participants", "value", "query", "profile_options", "open_time", + "code", "list", "host", "ts", "contacts", "upload", "lid", "preview", + "update", "usync", "w:stats", "delivery", "auth_ttl", "context", "fail", "cart_enabled", + "appdata", "category", "atn", "direct_connection", "decrypt-fail", "relay_id", "mmg-fallback.whatsapp.net", "target", + "available", "name", "last_id", "mmg.whatsapp.net", "categories", "401", "is_new", "index", + "tctoken", "ip4", "token_id", "latency", "recipient", "edit", "ip6", "add", + "thumbnail-document", "26", "paused", "true", "identity", "stream:error", "key", "sidelist", + "background", "audio", "3", "thumbnail-image", "biz-cover-photo", "cat", "gcm", "thumbnail-video", + "error", "auth", "deny", "serial", "in", "registration", "thumbnail-link", "remove", + "00", "gif", "thumbnail-gif", "tag", "capability", "multicast", "item-not-found", "description", + "business_hours", "config_expo_key", "md-app-state", "expiration", "fallback", "ttl", "300", "md-msg-hist", + "device_orientation", "out", "w:m", "open_24h", "side_list", "token", "inactive", "01", + "document", "te2", "played", "encrypt", "msgr", "hide", "direct_path", "12", + "state", "not-authorized", "url", "terminate", "signature", "status-revoke-delay", "02", "te", + "linked_accounts", "trusted_contact", "timezone", "ptt", "kyc-id", "privacy_token", "readreceipts", "appointment_only", + "address", "expected_ts", "privacy", "7", "android", "interactive", "device-identity", "enabled", + "attribute_padding", "1080", "03", "screen_height", + ]; + + // Double-byte tokens: AllDictionaries[d][i] where byte = DictionaryBase+d, then next byte = i. + // Matches Baileys DOUBLE_BYTE_TOKENS[0..3]. + // NOTE: AllDictionaries is declared AFTER Dictionary0..3 to ensure correct static init order. + public static readonly string[] Dictionary0 = [ - "xmlstreamstart", "account", "action", "add", "after", "age", "all", "allow", - "apple", "audio", "auth", "author", "available", "bad-protocol", "bad-request", - "before", "biz", "body", "broadcast", "cancel", "category", "challenge", "chat", - "clean", "code", "composing", "config", "conflict", "contacts", "count", "create", - "creation", "default", "delay", "delete", "delivery", "denied", "device", "devices", - "digest", "dirty", "disable", "duplicate", "elapsed", "enable", "encoding", "error", - "expiration", "expired", "failure", "false", "favorites", "feature", "features", - "field", "filter", "format", "from", "full", "get", "group", "groups", "groups_v2", - "hash", "id", "image", "in", "index", "info", "interactive", "iq", "item", - "item-not-found", "items", "jid", "kind", "last", "latitude", "lc", "leave", - "level", "list", "live", "lg", "longitude", "media", "message", "method", - "mime-type", "missing", "modify", "name", "not-authorized", "notification", - "notify", "out", "owner", "participant", "paused", "picture", "ping", "platform", - "presence", "preview", "proceed", "prop", "properties", "protocol", "public", - "push", "query", "raw", "read", "receipt", "received", "recipient", "recording", - "relay", "remove", "request", "response", "result", "retry", "s.whatsapp.net", - "seconds", "server", "server-error", "server_id", "set", "show", "sid", - "signature", "size", "star", "state", "status", "stream:error", "stream:features", - "subject", "success", "subscribe", "t", "text", "timeout", "to", "true", "type", - "unarchive", "unavailable", "unsubscribe", "update", "uri", "user", "value", - "vcard", "versions", "video", "w", "w:g2", "w:p", "w:p:r", "w:profile:picture", - "xml", "xmlns", "xmlns:stream", "~" + "read-self", "active", "fbns", "protocol", "reaction", "screen_width", "heartbeat", "deviceid", + "2:47DEQpj8", "uploadfieldstat", "voip_settings", "retry", "priority", "longitude", "conflict", "false", + "ig_professional", "replaced", "preaccept", "cover_photo", "uncompressed", "encopt", "ppic", "04", + "passive", "status-revoke-drop", "keygen", "540", "offer", "rate", "opus", "latitude", + "w:gp2", "ver", "4", "business_profile", "medium", "sender", "prev_v_id", "email", + "website", "invited", "sign_credential", "05", "transport", "skey", "reason", "peer_abtest_bucket", + "America/Sao_Paulo", "appid", "refresh", "100", "06", "404", "101", "104", + "107", "102", "109", "103", "member_add_mode", "105", "transaction-id", "110", + "106", "outgoing", "108", "111", "tokens", "followers", "ig_handle", "self_pid", + "tue", "dec", "thu", "joinable", "peer_pid", "mon", "features", "wed", + "peer_device_presence", "pn", "delete", "07", "fri", "audio_duration", "admin", "connected", + "delta", "rcat", "disable", "collection", "08", "480", "sat", "phash", + "all", "invite", "accept", "critical_unblock_low", "group_update", "signed_credential", "blinded_credential", "eph_setting", + "net", "09", "background_location", "refresh_id", "Asia/Kolkata", "privacy_mode_ts", "account_sync", "voip_payload_type", + "service_areas", "acs_public_key", "v_id", "0a", "fallback_class", "relay", "actual_actors", "metadata", + "w:biz", "5", "connected-limit", "notice", "0b", "host_storage", "fb_page", "subject", + "privatestats", "invis", "groupadd", "010", "note.m4r", "uuid", "0c", "8000", + "sun", "372", "1020", "stage", "1200", "720", "canonical", "fb", + "011", "video_duration", "0d", "1140", "superadmin", "012", "Opening.m4r", "keystore_attestation", + "dleq_proof", "013", "timestamp", "ab_key", "w:sync:app:state", "0e", "vertical", "600", + "p_v_id", "6", "likes", "014", "500", "1260", "creator", "0f", + "rte", "destination", "group", "group_info", "syncd_anti_tampering_fatal_exception_enabled", "015", "dl_bw", "Asia/Jakarta", + "vp8/h.264", "online", "1320", "fb:multiway", "10", "timeout", "016", "nse_retry", + "urn:xmpp:whatsapp:dirty", "017", "a_v_id", "web_shops_chat_header_button_enabled", "nse_call", "inactive-upgrade", "none", "web", + "groups", "2250", "mms_hot_content_timespan_in_seconds", "contact_blacklist", "nse_read", "suspended_group_deletion_notification", "binary_version", "018", + "https://www.whatsapp.com/otp/copy/", "reg_push", "shops_hide_catalog_attachment_entrypoint", "server_sync", ".", "ephemeral_messages_allowed_values", "019", "mms_vcache_aggregation_enabled", + "iphone", "America/Argentina/Buenos_Aires", "01a", "mms_vcard_autodownload_size_kb", "nse_ver", "shops_header_dropdown_menu_item", "dhash", "catalog_status", + "communities_mvp_new_iqs_serverprop", "blocklist", "default", "11", "ephemeral_messages_enabled", "01b", "original_dimensions", "8", + "mms4_media_retry_notification_encryption_enabled", "mms4_server_error_receipt_encryption_enabled", "original_image_url", "sync", "multiway", "420", "companion_enc_static", "shops_profile_drawer_entrypoint", + "01c", "vcard_as_document_size_kb", "status_video_max_duration", "request_image_url", "01d", "regular_high", "s_t", "abt", + "share_ext_min_preliminary_image_quality", "01e", "32", "syncd_key_rotation_enabled", "data_namespace", "md_downgrade_read_receipts2", "patch", "polltype", + "ephemeral_messages_setting", "userrate", "15", "partial_pjpeg_bw_threshold", "played-self", "catalog_exists", "01f", "mute_v2", ]; public static readonly string[] Dictionary1 = [ - "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", - "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", - "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", - "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", - "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", - "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", - "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", - "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", - "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", - "116", "117", "118", "119", "120", "121", "122", "123", "124", "125", "126", - "127", "128", "129", "130", "131", "132", "133", "134", "135", "136", "137", - "138", "139", "140", "141", "142", "143", "144", "145", "146", "147", "148", - "149", "150", "151", "152", "153", "154", "155", "156", "157", "158", "159", - "160", "161", "162", "163", "164", "165", "166", "167", "168", "169", "170", - "171", "172", "173", "174", "175", "176", "177", "178", "179", "180", "181", - "182", "183", "184", "185", "186", "187", "188", "189", "190", "191", "192", - "193", "194", "195", "196", "197", "198", "199", "200", "201", "202", "203", - "204", "205", "206", "207", "208", "209", "210", "211", "212", "213", "214", - "215", "216", "217", "218", "219", "220", "221", "222", "223", "224", "225", - "226", "227", "228", "229", "230", "231", "232", "233", "234", "235", "236", - "237", "238", "239", "240", "241", "242", "243", "244", "245", "246", "247", - "248", "249", "250", "251", "252", "253", "254", "255" + "reject", "dirty", "announcement", "020", "13", "9", "status_video_max_bitrate", "fb:thrift_iq", + "offline_batch", "022", "full", "ctwa_first_business_reply_logging", "h.264", "smax_id", "group_description_length", "https://www.whatsapp.com/otp/code", + "status_image_max_edge", "smb_upsell_business_profile_enabled", "021", "web_upgrade_to_md_modal", "14", "023", "s_o", "smaller_video_thumbs_status_enabled", + "media_max_autodownload", "960", "blocking_status", "peer_msg", "joinable_group_call_client_version", "group_call_video_maximization_enabled", "return_snapshot", "high", + "America/Mexico_City", "entry_point_block_logging_enabled", "pop", "024", "1050", "16", "1380", "one_tap_calling_in_group_chat_size", + "regular_low", "inline_joinable_education_enabled", "hq_image_max_edge", "locked", "America/Bogota", "smb_biztools_deeplink_enabled", "status_image_quality", "1088", + "025", "payments_upi_intent_transaction_limit", "voip", "w:g2", "027", "md_pin_chat_enabled", "026", "multi_scan_pjpeg_download_enabled", + "shops_product_grid", "transaction_id", "ctwa_context_enabled", "20", "fna", "hq_image_quality", "alt_jpeg_doc_detection_quality", "group_call_max_participants", + "pkey", "America/Belem", "image_max_kbytes", "web_cart_v1_1_order_message_changes_enabled", "ctwa_context_enterprise_enabled", "urn:xmpp:whatsapp:account", "840", "Asia/Kuala_Lumpur", + "max_participants", "video_remux_after_repair_enabled", "stella_addressbook_restriction_type", "660", "900", "780", "context_menu_ios13_enabled", "mute-state", + "ref", "payments_request_messages", "029", "frskmsg", "vcard_max_size_kb", "sample_buffer_gif_player_enabled", "match_last_seen", "510", + "4983", "video_max_bitrate", "028", "w:comms:chat", "17", "frequently_forwarded_max", "groups_privacy_blacklist", "Asia/Karachi", + "02a", "web_download_document_thumb_mms_enabled", "02b", "hist_sync", "biz_block_reasons_version", "1024", "18", "web_is_direct_connection_for_plm_transparent", + "view_once_write", "file_max_size", "paid_convo_id", "online_privacy_setting", "video_max_edge", "view_once_read", "enhanced_storage_management", "multi_scan_pjpeg_encoding_enabled", + "ctwa_context_forward_enabled", "video_transcode_downgrade_enable", "template_doc_mime_types", "hq_image_bw_threshold", "30", "body", "u_aud_limit_sil_restarts_ctrl", "other", + "participating", "w:biz:directory", "1110", "vp8", "4018", "meta", "doc_detection_image_max_edge", "image_quality", + "1170", "02c", "smb_upsell_chat_banner_enabled", "key_expiry_time_second", "pid", "stella_interop_enabled", "19", "linked_device_max_count", + "md_device_sync_enabled", "02d", "02e", "360", "enhanced_block_enabled", "ephemeral_icon_in_forwarding", "paid_convo_status", "gif_provider", + "project_name", "server-error", "canonical_url_validation_enabled", "wallpapers_v2", "syncd_clear_chat_delete_chat_enabled", "medianotify", "02f", "shops_required_tos_version", + "vote", "reset_skey_on_id_change", "030", "image_max_edge", "multicast_limit_global", "ul_bw", "21", "25", + "5000", "poll", "570", "22", "031", "1280", "WhatsApp", "032", + "bloks_shops_enabled", "50", "upload_host_switching_enabled", "web_ctwa_context_compose_enabled", "ptt_forwarded_features_enabled", "unblocked", "partial_pjpeg_enabled", "fbid:devices", + "height", "ephemeral_group_query_ts", "group_join_permissions", "order", "033", "alt_jpeg_status_quality", "migrate", "popular-bank", + "win_uwp_deprecation_killswitch_enabled", "web_download_status_thumb_mms_enabled", "blocking", "url_text", "035", "web_forwarding_limit_to_groups", "1600", "val", + "1000", "syncd_msg_date_enabled", "bank-ref-id", "max_subject", "payments_web_enabled", "web_upload_document_thumb_mms_enabled", "size", "request", + "ephemeral", "24", "receipt_agg", "ptt_remember_play_position", "sampling_weight", "enc_rekey", "mute_always", "037", + "034", "23", "036", "action", "click_to_chat_qr_enabled", "width", "disabled", "038", + "md_blocklist_v2", "played_self_enabled", "web_buttons_message_enabled", "flow_id", "clear", "450", "fbid:thread", "bloks_session_state", + "America/Lima", "attachment_picker_refresh", "download_host_switching_enabled", "1792", "u_aud_limit_sil_restarts_test2", "custom_urls", "device_fanout", "optimistic_upload", + "2000", "key_cipher_suite", "web_smb_upsell_in_biz_profile_enabled", "e", "039", "siri_post_status_shortcut", "pair-device", "lg", + "lc", "stream_attribution_url", "model", "mspjpeg_phash_gen", "catalog_send_all", "new_multi_vcards_ui", "share_biz_vcard_enabled", "-", + "clean", "200", "md_blocklist_v2_server", "03b", "03a", "web_md_migration_experience", "ptt_conversation_waveform", "u_aud_limit_sil_restarts_test1", ]; public static readonly string[] Dictionary2 = [ - "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", - "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "A", "B", "C", "D", - "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", - "T", "U", "V", "W", "X", "Y", "Z", "200", "201", "202", "203", "204", "205", - "206", "207", "208", "209", "210", "211", "212", "213", "214", "215", "216", - "217", "218", "219", "220", "221", "222", "223", "224", "225", "226", "227", - "228", "229", "230", "231", "232", "233", "234", "235", "236", "237", "238", - "239", "240", "241", "242", "243", "244", "245", "246", "247", "248", "249", - "250", "251", "252", "253", "254", "255", "ac", "ae", "ag", "ah", "ai", "aj", - "am", "an", "ao", "ap", "aq", "ar", "as", "at", "au", "av", "aw", "ax", "ay", - "az", "ba", "bb", "bc", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bk", "bl", - "bm", "bn", "bo", "bp", "bq", "br", "bs", "bt", "bu", "bv", "bw", "bx", "by", - "bz", "ca", "cb", "cc", "cd", "ce", "cf", "cg", "ch", "ci", "cj", "ck", "cl", - "cm", "cn", "co", "cp", "cq", "cr", "cs", "ct", "cu", "cv", "cw", "cx", "cy", - "cz", "da", "db", "dc", "dd", "de", "df", "dg", "dh", "di", "dj", "dk", "dl", - "dm", "dn", "do", "dp", "dq", "dr", "ds", "dt", "du", "dv", "dw", "dx", "dy", - "dz", "ea", "eb", "ec", "ed", "ee", "ef", "eg", "eh", "ei", "ej", "ek", "el", - "em", "en", "eo", "ep", "eq", "er", "es", "et", "eu", "ev", "ew", "ex", "ey", - "ez", "fa", "fb", "fc", "fd", "fe", "ff", "fg", "fh", "fi", "fj", "fk", "fl", - "fm", "fn", "fo", "fp", "fq", "fr", "fs", "ft", "fu", "fv", "fw", "fx", "fy", - "fz" + "64", "ptt_playback_speed_enabled", "web_product_list_message_enabled", "paid_convo_ts", "27", "manufacturer", "psp-routing", "grp_uii_cleanup", + "ptt_draft_enabled", "03c", "business_initiated", "web_catalog_products_onoff", "web_upload_link_thumb_mms_enabled", "03e", "mediaretry", "35", + "hfm_string_changes", "28", "America/Fortaleza", "max_keys", "md_mhfs_days", "streaming_upload_chunk_size", "5541", "040", + "03d", "2675", "03f", "...", "512", "mute", "48", "041", + "alt_jpeg_quality", "60", "042", "md_smb_quick_reply", "5183", "c", "1343", "40", + "1230", "043", "044", "mms_cat_v1_forward_hot_override_enabled", "user_notice", "ptt_waveform_send", "047", "Asia/Calcutta", + "250", "md_privacy_v2", "31", "29", "128", "md_messaging_enabled", "046", "crypto", + "690", "045", "enc_iv", "75", "failure", "ptt_oot_playback", "AIzaSyDR5yfaG7OG8sMTUj8kfQEb8T9pN8BM6Lk", "w", + "048", "2201", "web_large_files_ui", "Asia/Makassar", "812", "status_collapse_muted", "1334", "257", + "2HP4dm", "049", "patches", "1290", "43cY6T", "America/Caracas", "web_sticker_maker", "campaign", + "ptt_pausable_enabled", "33", "42", "attestation", "biz", "04b", "query_linked", "s", + "125", "04a", "810", "availability", "1411", "responsiveness_v2_m1", "catalog_not_created", "34", + "America/Santiago", "1465", "enc_p", "04d", "status_info", "04f", "key_version", "..", + "04c", "04e", "md_group_notification", "1598", "1215", "web_cart_enabled", "37", "630", + "1920", "2394", "-1", "vcard", "38", "elapsed", "36", "828", + "peer", "pricing_category", "1245", "invalid", "stella_ios_enabled", "2687", "45", "1528", + "39", "u_is_redial_audio_1104_ctrl", "1025", "1455", "58", "2524", "2603", "054", + "bsp_system_message_enabled", "web_pip_redesign", "051", "verify_apps", "1974", "1272", "1322", "1755", + "052", "70", "050", "1063", "1135", "1361", "80", "1096", + "1828", "1851", "1251", "1921", "key_config_id", "1254", "1566", "1252", + "2525", "critical_block", "1669", "max_available", "w:auth:backup:token", "product", "2530", "870", + "1022", "participant_uuid", "web_cart_on_off", "1255", "1432", "1867", "41", "1415", + "1440", "240", "1204", "1608", "1690", "1846", "1483", "1687", + "1749", "69", "url_number", "053", "1325", "1040", "365", "59", + "Asia/Riyadh", "1177", "test_recommended", "057", "1612", "43", "1061", "1518", + "1635", "055", "1034", "1375", "750", "1430", "event_code", "1682", + "503", "55", "865", "78", "1309", "1365", "44", "America/Guayaquil", + "535", "LIMITED", "1377", "1613", "1420", "1599", "1822", "05a", + "1681", "password", "1111", "1214", "1376", "1478", "47", "1082", + "4282", "Europe/Istanbul", "1307", "46", "058", "1124", "256", "rate-overlimit", + "retail", "u_a_socket_err_fix_succ_test", "1292", "1370", "1388", "520", "861", "psa", + "regular", "1181", "1766", "05b", "1183", "1213", "1304", "1537", ]; public static readonly string[] Dictionary3 = [ - "aba", "aca", "aga", "aha", "aia", "aja", "aka", "ala", "ama", "ana", "aoa", - "apa", "aqa", "ara", "asa", "ata", "aua", "ava", "awa", "axa", "aya", "aza", - "bba", "bca", "bda", "bea", "bfa", "bga", "bha", "bia", "bja", "bka", "bla", - "bma", "bna", "boa", "bpa", "bqa", "bra", "bsa", "bta", "bua", "bva", "bwa", - "bxa", "bya", "bza", "cca", "cda", "cea", "cfa", "cga", "cha", "cia", "cja", - "cka", "cla", "cma", "cna", "coa", "cpa", "cqa", "cra", "csa", "cta", "cua", - "cva", "cwa", "cxa", "cya", "cza", "dda", "dea", "dfa", "dga", "dha", "dia", - "dja", "dka", "dla", "dma", "dna", "doa", "dpa", "dqa", "dra", "dsa", "dta", - "dua", "dva", "dwa", "dxa", "dya", "dza", "eea", "efa", "ega", "eha", "eia", - "eja", "eka", "ela", "ema", "ena", "eoa", "epa", "eqa", "era", "esa", "eta", - "eua", "eva", "ewa", "exa", "eya", "eza", "ffa", "fga", "fha", "fia", "fja", - "fka", "fla", "fma", "fna", "foa", "fpa", "fqa", "fra", "fsa", "fta", "fua", - "fva", "fwa", "fxa", "fya", "fza", "gga", "gha", "gia", "gja", "gka", "gla", - "gma", "gna", "goa", "gpa", "gqa", "gra", "gsa", "gta", "gua", "gva", "gwa", - "gxa", "gya", "gza", "hha", "hia", "hja", "hka", "hla", "hma", "hna", "hoa", - "hpa", "hqa", "hra", "hsa", "hta", "hua", "hva", "hwa", "hxa", "hya", "hza", - "iia", "ija", "ika", "ila", "ima", "ina", "ioa", "ipa", "iqa", "ira", "isa", - "ita", "iua", "iva", "iwa", "ixa", "iya", "iza", "jja", "jka", "jla", "jma", - "jna", "joa", "jpa", "jqa", "jra", "jsa", "jta", "jua", "jva", "jwa", "jxa", - "jya", "jza", "kka", "kla", "kma", "kna", "koa", "kpa", "kqa", "kra", "ksa", - "kta", "kua", "kva", "kwa", "kxa", "kya", "kza", "lla", "lma", "lna", "loa", - "lpa", "lqa", "lra", "lsa", "lta", "lua", "lva", "lwa", "lxa", "lya", "lza" + "1724", "profile_picture", "1071", "1314", "1605", "407", "990", "1710", + "746", "pricing_model", "056", "059", "061", "1119", "6027", "65", + "877", "1607", "05d", "917", "seen", "1516", "49", "470", + "973", "1037", "1350", "1394", "1480", "1796", "keys", "794", + "1536", "1594", "2378", "1333", "1524", "1825", "116", "309", + "52", "808", "827", "909", "495", "1660", "361", "957", + "google", "1357", "1565", "1967", "996", "1775", "586", "736", + "1052", "1670", "bank", "177", "1416", "2194", "2222", "1454", + "1839", "1275", "53", "997", "1629", "6028", "smba", "1378", + "1410", "05c", "1849", "727", "create", "1559", "536", "1106", + "1310", "1944", "670", "1297", "1316", "1762", "en", "1148", + "1295", "1551", "1853", "1890", "1208", "1784", "7200", "05f", + "178", "1283", "1332", "381", "643", "1056", "1238", "2024", + "2387", "179", "981", "1547", "1705", "05e", "290", "903", + "1069", "1285", "2436", "062", "251", "560", "582", "719", + "56", "1700", "2321", "325", "448", "613", "777", "791", + "51", "488", "902", "Asia/Almaty", "is_hidden", "1398", "1527", "1893", + "1999", "2367", "2642", "237", "busy", "065", "067", "233", + "590", "993", "1511", "54", "723", "860", "363", "487", + "522", "605", "995", "1321", "1691", "1865", "2447", "2462", + "NON_TRANSACTIONAL", "433", "871", "432", "1004", "1207", "2032", "2050", + "2379", "2446", "279", "636", "703", "904", "248", "370", + "691", "700", "1068", "1655", "2334", "060", "063", "364", + "533", "534", "567", "1191", "1210", "1473", "1827", "069", + "701", "2531", "514", "prev_dhash", "064", "496", "790", "1046", + "1139", "1505", "1521", "1108", "207", "544", "637", "final", + "1173", "1293", "1694", "1939", "1951", "1993", "2353", "2515", + "504", "601", "857", "modify", "spam_request", "p_121_aa_1101_test4", "866", "1427", + "1502", "1638", "1744", "2153", "068", "382", "725", "1704", + "1864", "1990", "2003", "Asia/Dubai", "508", "531", "1387", "1474", + "1632", "2307", "2386", "819", "2014", "066", "387", "1468", + "1706", "2186", "2261", "471", "728", "1147", "1372", "1961", ]; + // AllDictionaries declared after the individual dicts to ensure correct static field init order. public static readonly string[][] AllDictionaries = [Dictionary0, Dictionary1, Dictionary2, Dictionary3]; - // Build reverse lookup for encoding - private static readonly Dictionary _reverseMap = BuildReverseMap(); + // ─── Reverse lookups for encoding ───────────────────────────────────────── - private static Dictionary BuildReverseMap() + private static readonly Dictionary _singleByteReverseMap = BuildSingleByteReverseMap(); + private static readonly Dictionary _doubleByteReverseMap = BuildDoubleByteReverseMap(); + + private static Dictionary BuildSingleByteReverseMap() { - var map = new Dictionary(); + var map = new Dictionary(StringComparer.Ordinal); + for (int i = 1; i < SingleByteTokens.Length; i++) + if (SingleByteTokens[i] != null) + map.TryAdd(SingleByteTokens[i]!, (byte)i); + return map; + } + + private static Dictionary BuildDoubleByteReverseMap() + { + var map = new Dictionary(StringComparer.Ordinal); for (int d = 0; d < AllDictionaries.Length; d++) for (int i = 0; i < AllDictionaries[d].Length; i++) - map.TryAdd(AllDictionaries[d][i], (d, i)); + if (AllDictionaries[d][i] != null) + map.TryAdd(AllDictionaries[d][i], (d, i)); return map; } - public static bool TryGetToken(string value, out byte dictionaryByte, out byte indexByte) + // ─── Encoding helpers ───────────────────────────────────────────────────── + + /// + /// Try to encode as a single-byte token (byte value 1–235). + /// Returns true and the byte to write. + /// + public static bool TryGetSingleByteToken(string value, out byte tokenByte) + => _singleByteReverseMap.TryGetValue(value, out tokenByte); + + /// + /// Try to encode as a double-byte token (DictionaryBase + dictIndex, tokenIndex). + /// Returns true and the two bytes to write. + /// + public static bool TryGetDoubleByteToken(string value, out byte dictionaryByte, out byte indexByte) { - if (_reverseMap.TryGetValue(value, out var pos)) + if (_doubleByteReverseMap.TryGetValue(value, out var pos)) { dictionaryByte = (byte)(DictionaryBase + pos.dict); indexByte = (byte)pos.idx; return true; } - dictionaryByte = 0; - indexByte = 0; + dictionaryByte = indexByte = 0; return false; } + // ─── Decoding helpers ───────────────────────────────────────────────────── + + /// Decode a single-byte token. Returns null if is out of range. + public static string? GetSingleByteToken(int b) + { + if (b < 0 || b >= SingleByteTokens.Length) return null; + return SingleByteTokens[b]; + } + + /// Decode a double-byte token. Returns null if indices are out of range. public static string? GetToken(int dictionaryIndex, int tokenIndex) { if (dictionaryIndex < 0 || dictionaryIndex >= AllDictionaries.Length) return null; diff --git a/src/Dawa/Crypto/AesGcmHelper.cs b/src/Dawa/Crypto/AesGcmHelper.cs index c9ddd8e..c00b39e 100644 --- a/src/Dawa/Crypto/AesGcmHelper.cs +++ b/src/Dawa/Crypto/AesGcmHelper.cs @@ -114,11 +114,15 @@ public static byte[] DecryptRaw(byte[] key, byte[] nonce, byte[] ciphertextAndTa private static byte[] CounterToNonce(ulong counter) { - // 12-byte nonce: 4 zero bytes + 8-byte big-endian counter + // Noise spec: 12-byte nonce, counter as 4-byte big-endian uint32 at offset 8. + // Baileys noise.ts: nonce.writeUInt32BE(counter, 8) + // Bytes 0-7 = zero, bytes 8-11 = counter (big-endian uint32) var nonce = new byte[NonceSize]; - var counterBytes = BitConverter.GetBytes(counter); - if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes); - counterBytes.CopyTo(nonce, 4); + var c = (uint)counter; + nonce[8] = (byte)(c >> 24); + nonce[9] = (byte)(c >> 16); + nonce[10] = (byte)(c >> 8); + nonce[11] = (byte)(c); return nonce; } } diff --git a/src/Dawa/Crypto/MediaCrypto.cs b/src/Dawa/Crypto/MediaCrypto.cs new file mode 100644 index 0000000..49a4b14 --- /dev/null +++ b/src/Dawa/Crypto/MediaCrypto.cs @@ -0,0 +1,96 @@ +using System.Security.Cryptography; +using System.Text; +using Dawa.Signal; + +namespace Dawa.Crypto; + +/// +/// WhatsApp media encryption — derives keys from a random media key using HKDF, +/// then encrypts with AES-CBC and appends a 10-byte HMAC-SHA256 MAC. +/// +public sealed class MediaEncryptResult +{ + public byte[] EncryptedBytes { get; init; } = []; // ciphertext || 10-byte mac + public byte[] MediaKey { get; init; } = []; // 32-byte random key (sent in proto) + public byte[] FileSha256 { get; init; } = []; // SHA-256 of original plaintext + public byte[] FileEncSha256 { get; init; } = []; // SHA-256 of EncryptedBytes + public long FileLength { get; init; } // original byte count + public string DirectPath { get; init; } = ""; // set after CDN upload + public string Url { get; init; } = ""; // set after CDN upload + public long MediaKeyTimestamp { get; init; } +} + +public static class MediaCrypto +{ + private static string HkdfInfo(string mediaType) => mediaType switch + { + "image" => "WhatsApp Image Keys", + "video" => "WhatsApp Video Keys", + "audio" => "WhatsApp Audio Keys", + "document" => "WhatsApp Document Keys", + "md-msg-hist" => "WhatsApp History Keys", + _ => "WhatsApp Image Keys", + }; + + /// + /// Encrypts for WhatsApp media upload. + /// is one of: "image", "video", "audio", "document". + /// + public static MediaEncryptResult Encrypt(byte[] plaintext, string mediaType) + { + // 1. Random 32-byte media key + var mediaKey = RandomNumberGenerator.GetBytes(32); + + // 2. HKDF-expand: 112 bytes (IV=16, AES key=32, MAC key=32, ref=32) + var info = Encoding.UTF8.GetBytes(HkdfInfo(mediaType)); + var derived = DawaHKDF.DeriveKey(mediaKey, new byte[32], info, 112); + var iv = derived[0..16]; + var cipherKey = derived[16..48]; + var macKey = derived[48..80]; + + // 3. AES-CBC encrypt + var enc = MessageCipher.AesCbcEncrypt(cipherKey, iv, plaintext); + + // 4. HMAC-SHA256(macKey, IV || enc) — first 10 bytes only + var macInput = new byte[iv.Length + enc.Length]; + iv.CopyTo(macInput, 0); + enc.CopyTo(macInput, iv.Length); + var mac = HMACSHA256.HashData(macKey, macInput)[..10]; + + // 5. Final encrypted blob = enc || mac (Baileys format — NO IV). + // The IV is NOT uploaded to the CDN; receivers derive it from mediaKey via HKDF. + // The CDN stores only enc+mac and verifies SHA256(enc+mac) == token in URL. + var encFile = new byte[enc.Length + mac.Length]; + enc.CopyTo(encFile, 0); + mac.CopyTo(encFile, enc.Length); + + return new MediaEncryptResult + { + EncryptedBytes = encFile, + MediaKey = mediaKey, + FileSha256 = SHA256.HashData(plaintext), + FileEncSha256 = SHA256.HashData(encFile), + FileLength = plaintext.Length, + MediaKeyTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + }; + } + + /// + /// Decrypts a WhatsApp CDN blob (enc || 10-byte mac) back to plaintext. + /// Used for downloading media and history sync blobs. + /// e.g. "image", "md-msg-hist". + /// + public static byte[] Decrypt(byte[] encWithMac, byte[] mediaKey, string mediaType) + { + // Derive the same keys the sender used + var info = Encoding.UTF8.GetBytes(HkdfInfo(mediaType)); + var derived = DawaHKDF.DeriveKey(mediaKey, new byte[32], info, 112); + var iv = derived[0..16]; + var cipherKey = derived[16..48]; + + // Strip the 10-byte MAC at the end — we trust the server validated it + var enc = encWithMac[..^10]; + + return MessageCipher.AesCbcDecrypt(cipherKey, iv, enc); + } +} diff --git a/src/Dawa/Crypto/XEdDSA.cs b/src/Dawa/Crypto/XEdDSA.cs index 4a33472..d71562d 100644 --- a/src/Dawa/Crypto/XEdDSA.cs +++ b/src/Dawa/Crypto/XEdDSA.cs @@ -42,6 +42,53 @@ static XEdDSA() // ─── Public API ─────────────────────────────────────────────────────── + /// + /// Verifies an XEdDSA signature. + /// is the 32-byte Curve25519 (Montgomery x) public key. + /// + public static bool Verify(byte[] curve25519PublicKey, byte[] message, byte[] signature) + { + if (signature.Length != 64) return false; + + // 1. Convert Curve25519 Montgomery x → Edwards y: y = (u-1)/(u+1) mod p + var u = LoadLE(curve25519PublicKey); + var y = Fp((u - 1) * FpInv(u + 1)); + + // 2. Recover Edwards x from y (positive/even root) + var y2 = Fp(y * y); + var x2 = Fp((y2 - 1) * FpInv(Fp(D * y2 + 1))); + var ax = FpSqrt(x2); + if (!ax.IsEven) ax = P - ax; + var A = (ax, y); + var Ap = PackPoint(A); + + // 3. Parse R (packed point) and s from signature + var Rp = signature[0..32]; + var sBuf = signature[32..64]; + var s = LoadLE(sBuf); + if (s >= L) return false; + + // 4. Unpack R + var Rbuf = (byte[])Rp.Clone(); + var Rsign = (Rbuf[31] & 0x80) != 0; + Rbuf[31] &= 0x7F; + var Ry = LoadLE(Rbuf); + var Ry2 = Fp(Ry * Ry); + var Rx2 = Fp((Ry2 - 1) * FpInv(Fp(D * Ry2 + 1))); + var Rx = FpSqrt(Rx2); + if (Rsign != !Rx.IsEven) Rx = P - Rx; + var R = (Rx, Ry); + + // 5. h = SHA-512(R ‖ A ‖ message) mod l + var hHash = SHA512.HashData([.. Rp, .. Ap, .. message]); + var h = Fl(LoadLE64(hHash)); + + // 6. s·G == R + h·A + var lhs = PointMult(G, s); + var rhs = PointAdd(R, PointMult(A, h)); + return lhs.X == rhs.X && lhs.Y == rhs.Y; + } + /// /// Signs using XEdDSA with the given 32-byte /// Curve25519 private key. Returns a 64-byte signature (R ‖ s). @@ -49,31 +96,41 @@ static XEdDSA() public static byte[] Sign(byte[] privateKey, byte[] message) { // 1. Clamp Curve25519 private key → Ed25519 scalar a - var a = (byte[])privateKey.Clone(); - a[0] &= 248; a[31] &= 127; a[31] |= 64; - var aInt = LoadLE(a); + var aClamped = (byte[])privateKey.Clone(); + aClamped[0] &= 248; aClamped[31] &= 127; aClamped[31] |= 64; + var aInt = LoadLE(aClamped); // 2. A = a × G (Ed25519 public key derived from the scalar) - var A = PointMult(G, aInt); - var Ap = PackPoint(A); + var A = PointMult(G, aInt); - // 3. r = SHA-512(A ‖ message) mod l - var rHash = SHA512.HashData([.. Ap, .. message]); + // 3. XEdDSA sign convention: A must have a non-negative (even) x-coordinate. + // Verifiers recover A from the Curve25519 key using always the positive x root. + // If our A.x is negative (odd), negate the scalar so A becomes -A (same y, flipped x). + if (!A.X.IsEven) + { + aInt = Fl(L - aInt); // negate scalar mod l + A = (Fp(P - A.X), A.Y); // flip x sign (same point, opposite x sign) + } + var Ap = PackPoint(A); // bit 255 of Ap is now always 0 + + // 4. r = SHA-512(a ‖ message ‖ Z) mod l (Signal XEdDSA nonce, using final scalar) + var aBytes = ToLE32(aInt); + var Z = System.Security.Cryptography.RandomNumberGenerator.GetBytes(64); + var rHash = SHA512.HashData([.. aBytes, .. message, .. Z]); var r = Fl(LoadLE64(rHash)); - var rBytes = ToLE32(r); - // 4. R = r × G + // 5. R = r × G var R = PointMult(G, r); var Rp = PackPoint(R); - // 5. h = SHA-512(R ‖ A ‖ message) mod l + // 6. h = SHA-512(R ‖ A ‖ message) mod l var hHash = SHA512.HashData([.. Rp, .. Ap, .. message]); var h = Fl(LoadLE64(hHash)); - // 6. s = (r + h·a) mod l + // 7. s = (r + h·a) mod l var s = Fl(r + h * aInt); - // 7. Signature = R (32 bytes) ‖ s (32 bytes) + // 8. Signature = R (32 bytes) ‖ s (32 bytes) var sig = new byte[64]; Rp.CopyTo(sig, 0); ToLE32(s).CopyTo(sig, 32); diff --git a/src/Dawa/Dawa.csproj b/src/Dawa/Dawa.csproj index 79ed12d..cb517c1 100644 --- a/src/Dawa/Dawa.csproj +++ b/src/Dawa/Dawa.csproj @@ -20,10 +20,6 @@ true - - - - @@ -31,4 +27,9 @@ + + + + + diff --git a/src/Dawa/Messages/IncomingMessage.cs b/src/Dawa/Messages/IncomingMessage.cs index 5e161d7..599b7e7 100644 --- a/src/Dawa/Messages/IncomingMessage.cs +++ b/src/Dawa/Messages/IncomingMessage.cs @@ -1,5 +1,30 @@ namespace Dawa.Messages; +/// Message type classification. +public enum MessageType +{ + Unknown, + Text, + Image, + Video, + Audio, + Document, + Sticker, + Reaction, + Location, + Contact, + Protocol, // ProtocolMessage (history sync, key distribution, etc.) +} + +/// Delivery/read status of an outgoing message. +public enum MessageStatus +{ + Sent, // Sent to WhatsApp servers + Delivered, // Delivered to recipient device + Read, // Recipient read the message + Played, // Recipient played the voice note +} + /// /// Represents a received WhatsApp message. /// @@ -8,7 +33,7 @@ public sealed class IncomingMessage /// Unique message ID assigned by WhatsApp. public string Id { get; init; } = ""; - /// Sender JID (e.g. "31612345678@s.whatsapp.net"). + /// Sender JID (e.g. "31612345678@s.whatsapp.net" or LID). public string From { get; init; } = ""; /// The conversation/chat JID. @@ -17,7 +42,10 @@ public sealed class IncomingMessage /// For group messages: the participant who sent the message. public string? Participant { get; init; } - /// Text content of the message, if it's a text message. + /// Classified message type. + public MessageType Type { get; init; } = MessageType.Unknown; + + /// Text content (for Text messages, or caption for media). public string? Text { get; init; } /// True if this message was sent by ourselves. @@ -26,9 +54,67 @@ public sealed class IncomingMessage /// Unix timestamp (seconds) when the message was sent. public long Timestamp { get; init; } - /// UTC timestamp of the message. + /// The push name (display name) of the sender, if provided. + public string? PushName { get; init; } + + /// True if this message has been revoked (deleted for everyone). + public bool IsRevoked { get; init; } + + // ── Media fields ────────────────────────────────────────────────────────── + + /// Direct CDN URL to download the encrypted media. + public string? MediaUrl { get; init; } + + /// MIME type (e.g. "image/jpeg", "audio/ogg; codecs=opus"). + public string? MimeType { get; init; } + + /// File name (primarily for documents). + public string? FileName { get; init; } + + /// File size in bytes. + public long? FileSize { get; init; } + + /// Duration in seconds (audio/video). + public uint? Duration { get; init; } + + /// Width in pixels (image/video/sticker). + public uint? Width { get; init; } + + /// Height in pixels (image/video/sticker). + public uint? Height { get; init; } + + /// Base64-encoded AES key to decrypt the media download. + public string? MediaKey { get; init; } + + /// Base64-encoded SHA256 of the encrypted media blob. + public string? MediaSha256Enc { get; init; } + + // ── Reaction fields ─────────────────────────────────────────────────────── + + /// Reaction emoji (e.g. "👍"). Empty string = reaction removed. + public string? ReactionEmoji { get; init; } + + /// ID of the message being reacted to. + public string? ReactionTargetId { get; init; } + + // ── Quoted/reply context ────────────────────────────────────────────────── + + /// ID of the message this is a reply to. + public string? QuotedMessageId { get; init; } + + /// JID of the sender of the quoted message. + public string? QuotedFrom { get; init; } + + /// Text preview of the quoted message. + public string? QuotedText { get; init; } + + /// Type of the quoted message. + public MessageType QuotedType { get; init; } = MessageType.Unknown; + + // ── UTC helper ──────────────────────────────────────────────────────────── + public DateTimeOffset SentAt => DateTimeOffset.FromUnixTimeSeconds(Timestamp); public override string ToString() => - $"[{SentAt:HH:mm:ss}] {(FromMe ? "ME" : From)}: {Text ?? ""}"; + $"[{SentAt:HH:mm:ss}] {(FromMe ? "ME" : From)} [{Type}]: {Text ?? MediaUrl ?? ReactionEmoji ?? ""}"; } diff --git a/src/Dawa/Noise/NoiseProcessor.cs b/src/Dawa/Noise/NoiseProcessor.cs index 96e5f57..510ad6a 100644 --- a/src/Dawa/Noise/NoiseProcessor.cs +++ b/src/Dawa/Noise/NoiseProcessor.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -6,6 +7,7 @@ using Dawa.Crypto; using Dawa.Messages; using Dawa.Proto; +using Dawa.Signal; using Dawa.Transport; using Microsoft.Extensions.Logging; @@ -38,9 +40,39 @@ public sealed class NoiseProcessor : IAsyncDisposable private readonly byte[] _ephemeralPriv; private readonly byte[] _ephemeralPub; + // Signal Protocol session store + private readonly SignalKeyStore _signalStore; + + // Pending IQ tracking for request/response correlation + private readonly Dictionary> _pendingIqs = new(); + + // Only fire Authenticated + upload pre-keys once per connection (not on every periodic success token) + private bool _sessionAuthenticated; + private CancellationTokenSource? _keepAliveCts; + + // When WhatsApp sends edge_routing, we store the bytes for use on next reconnect. + // We do NOT break the receive loop — only a stream:error or socket close causes a reconnect. + public byte[]? PendingRoutingInfo { get; private set; } + + // Shared HttpClient for CDN media uploads + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(60) }; + + // Track the last on-demand history request so we can auto-resend when the phone sends a retry receipt + private (string ChatJid, string? OldestMsgId, bool OldestMsgFromMe, long OldestMsgTimestampMs, int Count)? _lastPdoRequest; + private readonly HashSet _sentPdoMsgIds = new(); + + // Index into auth.PreKeys for retry receipts — incremented per receipt so each message + // gets a unique one-time pre-key. Without this all retry receipts would advertise the + // same pre-key; only the first re-encrypted message would decrypt (the key gets consumed). + private int _retryPreKeyIndex = 0; + public event EventHandler? QRCodeGenerated; public event EventHandler? Authenticated; public event EventHandler? MessageReceived; + /// Fired for each message loaded from history sync (past messages). + public event EventHandler? HistoryMessageReceived; + /// Fired once after a history-sync blob has been fully processed (use to flush persisted store). + public event EventHandler? HistorySyncCompleted; public NoiseProcessor(FrameSocket socket, AuthState auth, WhatsAppClientOptions options, ILogger logger) { @@ -50,6 +82,49 @@ public NoiseProcessor(FrameSocket socket, AuthState auth, WhatsAppClientOptions _logger = logger; (_ephemeralPriv, _ephemeralPub) = Curve25519Helper.GenerateKeyPair(); + _signalStore = new SignalKeyStore(options.SessionDirectory); + + // Load persisted LID/push-name caches from previous sessions + LoadCacheFromDisk(); + } + + private string CacheFilePath => Path.Combine(_options.SessionDirectory, "contact-cache.json"); + + private void LoadCacheFromDisk() + { + try + { + if (!File.Exists(CacheFilePath)) return; + var json = File.ReadAllText(CacheFilePath); + var data = System.Text.Json.JsonSerializer.Deserialize(json); + if (data == null) return; + foreach (var kv in data.LidToPhone) _lidToPhone[kv.Key] = kv.Value; + foreach (var kv in data.PushNames) _pushNames[kv.Key] = kv.Value; + _logger.LogInformation("Loaded contact cache: {Lids} LID mappings, {Names} push names", + _lidToPhone.Count, _pushNames.Count); + } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to load contact cache"); } + } + + private void SaveCacheToDisk() + { + try + { + var data = new ContactCacheFile + { + LidToPhone = new Dictionary(_lidToPhone), + PushNames = new Dictionary(_pushNames), + }; + var json = System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = false }); + File.WriteAllText(CacheFilePath, json); + } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to save contact cache"); } + } + + private sealed class ContactCacheFile + { + public Dictionary LidToPhone { get; set; } = new(); + public Dictionary PushNames { get; set; } = new(); } // ─── Handshake ─────────────────────────────────────────────────────────── @@ -114,6 +189,13 @@ public async Task PerformHandshakeAsync(CancellationToken ct) var clientPayload = BuildClientPayload(); var encPayload = noise.EncryptWithAssociatedData(clientPayload); + _logger.LogInformation("Noise: ClientFinish encStaticPub({Len})={Hex}", + encStaticPub.Length, BitConverter.ToString(encStaticPub)); + _logger.LogInformation("Noise: ClientFinish encPayload({Len})={Hex}", + encPayload.Length, BitConverter.ToString(encPayload)); + _logger.LogInformation("Noise: ClientPayload raw({Len})={Hex}", + clientPayload.Length, BitConverter.ToString(clientPayload)); + var clientFinish = new ClientFinish { Static = encStaticPub, @@ -160,6 +242,12 @@ private Task HandlePostHandshakeAsync(CancellationToken ct) /// /// Continuously reads and processes incoming frames. Call this on a background task. /// + // Timestamp (ticks) of the last frame received from the server. The keepalive loop + // watches this to detect a dead/half-open connection (no data for too long) and tear + // the socket down so the receive loop unblocks and reconnection can run. Without this a + // silently-dropped socket parks ReceiveAsync forever (root cause of the 85-min outage). + private long _lastRecvTicks = DateTime.UtcNow.Ticks; + public async Task ReceiveLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested && _socket.IsConnected) @@ -169,10 +257,39 @@ public async Task ReceiveLoopAsync(CancellationToken ct) var frame = await _socket.ReceiveFrameAsync(ct); if (frame == null) break; + System.Threading.Interlocked.Exchange(ref _lastRecvTicks, DateTime.UtcNow.Ticks); + + _logger.LogInformation("Noise: Received raw frame ({Len} bytes), first bytes={Hex}", + frame.Length, BitConverter.ToString(frame, 0, Math.Min(32, frame.Length))); + var decrypted = DecryptFrame(frame); - var node = BinaryNodeDecoder.Decode(decrypted); - _logger.LogDebug("Received node: {Tag}", node.Tag); + // Baileys protocol: first byte is a flags byte. + // Bit 1 (value 2) = payload is zlib raw-deflate compressed. + // Always strip this byte before decoding the binary node. + var nodeData = StripFlagsAndDecompress(decrypted); + _logger.LogInformation("Noise: Decrypted frame ({Len} bytes), first bytes={Hex}", + decrypted.Length, BitConverter.ToString(decrypted, 0, Math.Min(32, decrypted.Length))); + + var node = BinaryNodeDecoder.Decode(nodeData); + // Server sent StreamEnd — graceful close, stop receive loop. + if (node.Tag == BinaryNodeDecoder.StreamEndSentinel) + { + _logger.LogInformation("Server sent stream-end, closing."); + break; + } + + var nodeStr = node.ToString(); + _logger.LogInformation("Received node: {Node}", nodeStr); + // Write to file log (bypasses IIS stdout 8KB cap) + try + { + var logDir = Path.Combine(_options.SessionDirectory, "..", "nodelog"); + Directory.CreateDirectory(logDir); + var logFile = Path.Combine(logDir, "nodes.log"); + File.AppendAllText(logFile, $"[{DateTime.UtcNow:HH:mm:ss}] {nodeStr}\n"); + } + catch { /* non-fatal */ } await HandleNodeAsync(node, ct); } catch (OperationCanceledException) { break; } @@ -183,12 +300,18 @@ public async Task ReceiveLoopAsync(CancellationToken ct) } } _logger.LogInformation("Receive loop ended."); + SaveCacheToDisk(); } private async Task HandleNodeAsync(BinaryNode node, CancellationToken ct) { switch (node.Tag) { + case "chat" when node.GetAttr("add") == "@xmlstreamstart": + // Multi-device: server embeds pair-device refs directly in the xmlstreamstart frame. + // Structure: [102 bytes]×6 + await HandleXmlStreamStartAsync(node, ct); + break; case "iq": await HandleIQAsync(node, ct); break; @@ -198,9 +321,39 @@ private async Task HandleNodeAsync(BinaryNode node, CancellationToken ct) case "notification": await HandleNotificationAsync(node, ct); break; + case "presence": + HandlePresenceNode(node); + break; + case "receipt": + await HandleReceiptAsync(node, ct); + break; case "success": - _logger.LogInformation("Session authenticated successfully."); - Authenticated?.Invoke(this, _auth); + if (!_sessionAuthenticated) + { + _sessionAuthenticated = true; + _logger.LogInformation("Session authenticated successfully."); + Authenticated?.Invoke(this, _auth); + // Upload pre-keys so other devices can start Signal sessions with us, + // then announce presence so WhatsApp starts delivering messages to this device. + _ = Task.Run(async () => + { + try + { + await UploadPreKeysAsync(ct); + await SendActiveAsync(ct); // CRITICAL: switch session from passive → active + await SendPresenceAsync(ct); + await SendAppStateSyncAsync(ct); + } + catch (Exception ex) { _logger.LogWarning(ex, "Post-auth setup failed (non-fatal)"); } + }); + // Start proactive keepalive pings (every 25s, like Baileys) + _keepAliveCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _ = Task.Run(() => KeepAliveLoopAsync(_keepAliveCts.Token)); + } + else + { + _logger.LogDebug("Received periodic success token (session already authenticated, skipping re-init)."); + } break; case "failure": _logger.LogWarning("Authentication failure: {Reason}", node.GetAttr("reason")); @@ -208,16 +361,199 @@ private async Task HandleNodeAsync(BinaryNode node, CancellationToken ct) case "stream:error": _logger.LogError("Stream error: {Code}", node.GetAttr("code")); break; + case "ib": + await HandleIbAsync(node, ct); + break; default: _logger.LogDebug("Unhandled node tag: {Tag}", node.Tag); break; } } + /// + /// Handles informational broadcast (ib) nodes from WhatsApp. + /// The most important one is dirty type="account_sync" — WhatsApp withholds + /// message delivery until the client sends a "clean" IQ to acknowledge the sync. + /// + private async Task HandleIbAsync(BinaryNode ib, CancellationToken ct) + { + foreach (var child in ib.Children) + { + if (child.Tag == "dirty") + { + var dirtyType = child.GetAttr("type") ?? ""; + var timestamp = child.GetAttr("timestamp") ?? ""; + + _logger.LogInformation("Received dirty notification: type={Type}, timestamp={Ts} — sending clean IQ", dirtyType, timestamp); + + var cleanIq = new BinaryNode("iq", new Dictionary + { + ["to"] = "@s.whatsapp.net", + ["type"] = "set", + ["xmlns"] = "urn:xmpp:whatsapp:dirty", + ["id"] = GenerateMessageId(), + }) + { + Content = new List + { + new("clean", new Dictionary + { + ["type"] = dirtyType, + ["timestamp"] = timestamp, + }), + }, + }; + + try + { + await SendNodeAsync(cleanIq, ct); + _logger.LogInformation("Sent clean IQ for dirty type={Type} — message delivery should resume.", dirtyType); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send clean IQ for dirty type={Type}", dirtyType); + } + } + else if (child.Tag == "edge_routing") + { + // WhatsApp suggests a preferred edge server. Store routing bytes for use on next + // reconnect (triggered by stream:error or socket close). Do NOT force an immediate + // disconnect — that causes an infinite reconnect loop. + var routingInfo = child.FindChild("routing_info"); + if (routingInfo?.Content is byte[] routingBytes && routingBytes.Length > 0) + { + PendingRoutingInfo = routingBytes; + _logger.LogInformation("Received edge_routing ({Len} bytes) — stored for next reconnect.", routingBytes.Length); + } + } + else if (child.Tag == "offline_preview") + { + // WA sends offline_preview to tell us there are queued offline messages. + // Baileys responds with to trigger delivery. + // Without this, WA never delivers the offline messages. + var msgCount = child.GetAttr("message") ?? child.GetAttr("count") ?? "0"; + // Request the FULL pending count (not a fixed 100) so the entire offline queue + // drains in one reconnect instead of 100-at-a-time across many. A healthy client + // (Baileys) drains the whole queue; combined with ACK-and-skip of dead backlog + // this keeps the bridge current. Cap high as a sanity bound. + var batchCount = (int.TryParse(msgCount, out var mc) && mc > 0) ? Math.Min(mc, 100000) : 5000; + _logger.LogInformation("offline_preview: {Count} queued — requesting full offline_batch of {Batch}", msgCount, batchCount); + var offlineBatch = new BinaryNode("ib") + { + Content = new List + { + new("offline_batch", new Dictionary { ["count"] = batchCount.ToString() }), + }, + }; + await SendNodeAsync(offlineBatch, ct); + } + else if (child.Tag == "offline") + { + // WA sends this after delivering all offline messages. + var offlineCount = child.GetAttr("count") ?? "0"; + _logger.LogInformation("offline delivery complete: {Count} items received", offlineCount); + } + else if (child.Tag == "thread_metadata") + { + // WhatsApp pushes the chat list on startup as items. + // Each is an active chat. + // JIDs may be @lid (privacy-preserving long-lived IDs) or @s.whatsapp.net or @g.us. + foreach (var item in child.Children) + { + if (item.Tag == "item") + { + var jid = item.GetAttr("from") ?? ""; + var ts = item.GetAttr("t") ?? "0"; + if (!string.IsNullOrEmpty(jid) && long.TryParse(ts, out var tsVal)) + { + _threadMetadata[jid] = tsVal; + _logger.LogDebug("thread_metadata: chat {Jid} t={Ts}", jid, tsVal); + } + } + } + _logger.LogInformation("Cached {Count} chats from thread_metadata", _threadMetadata.Count); + } + else + { + _logger.LogDebug("Unhandled ib child: {Tag}", child.Tag); + } + } + } + + private async Task HandleXmlStreamStartAsync(BinaryNode chat, CancellationToken ct) + { + // In the WhatsApp multi-device protocol, the server packs pair-device ref blobs + // directly into the first post-handshake frame as children of the xmlstreamstart node. + // Walk the children to find the first binary payload — that is the first QR ref. + byte[]? refBytes = null; + foreach (var container in chat.Children) + { + foreach (var child in container.Children) + { + if (child.Data is { Length: > 0 }) + { + refBytes = child.Data; + break; + } + } + if (refBytes != null) break; + } + + if (refBytes == null) + { + _logger.LogWarning("xmlstreamstart node contained no ref blobs — cannot generate QR."); + return; + } + + // The ref blob is raw bytes; base64-encode it to form the ref string. + var ref_ = Convert.ToBase64String(refBytes); + var qrParts = new[] + { + ref_, + Convert.ToBase64String(_auth.NoiseKeyPublic), + Convert.ToBase64String(_auth.SignedIdentityKeyPublic), + Convert.ToBase64String(_auth.AdvSecretKey), + }; + var qrString = string.Join(",", qrParts); + + _logger.LogInformation("QR Code ready (from xmlstreamstart, ref={RefLen} bytes).", refBytes.Length); + QRCodeGenerated?.Invoke(this, qrString); + // Connection stays open — server waits for QR scan (up to ~20s per ref). + } + private async Task HandleIQAsync(BinaryNode iq, CancellationToken ct) { + // Resolve any pending IQ awaiter first + var iqId = iq.GetAttr("id") ?? ""; + var iqType = iq.GetAttr("type") ?? ""; + _logger.LogDebug("HandleIQ: id={Id} type={Type} pendingKeys=[{Keys}]", + iqId, iqType, string.Join(",", _pendingIqs.Keys)); + if ((iqType == "result" || iqType == "error") && _pendingIqs.TryGetValue(iqId, out var tcs)) + { + _pendingIqs.Remove(iqId); + _logger.LogInformation("IQ resolved: id={Id} type={Type}", iqId, iqType); + tcs.SetResult(iq); + return; + } + var type = iq.GetAttr("type"); - if (type == "result") + if (type == "get") + { + // Respond to keep-alive pings from the server + if (iq.FindChild("ping") != null) + { + var pong = new BinaryNode("iq", new() + { + ["id"] = iq.GetAttr("id") ?? "", + ["type"] = "result", + ["to"] = iq.GetAttr("from") ?? "@s.whatsapp.net", + }); + await SendNodeAsync(pong, ct); + _logger.LogDebug("Responded to server ping id={Id}", iq.GetAttr("id")); + } + return; + } + else if (type == "result") { // Check for pair-device result (QR code ref) var pairDevice = iq.FindChild("pair-device"); @@ -227,11 +563,11 @@ private async Task HandleIQAsync(BinaryNode iq, CancellationToken ct) return; } - // Check for pair-success (phone scanned QR) + // Check for pair-success (phone scanned QR) — also handle type="result" path var pairSuccess = iq.FindChild("pair-success"); if (pairSuccess != null) { - HandlePairSuccess(pairSuccess); + await HandlePairSuccessAsync(iq, pairSuccess, ct); return; } } @@ -246,20 +582,39 @@ private async Task HandleIQAsync(BinaryNode iq, CancellationToken ct) { ["id"] = iq.GetAttr("id") ?? "", ["type"] = "result", - ["to"] = iq.GetAttr("from") ?? "s.whatsapp.net", + ["to"] = iq.GetAttr("from") ?? "@s.whatsapp.net", }); await SendNodeAsync(ack, ct); + // Generate QR from the first ref in the pair-device node + await HandlePairDeviceResultAsync(pairDevice, ct); + } + + // pair-success arrives as type="set" (server-initiated), not type="result" + var pairSuccess2 = iq.FindChild("pair-success"); + if (pairSuccess2 != null) + { + await HandlePairSuccessAsync(iq, pairSuccess2, ct); } } } private async Task HandlePairDeviceResultAsync(BinaryNode pairDevice, CancellationToken ct) { - // Extract ref token from server + // Extract ref token from server — the ref is a binary blob encoded as base64 in the QR var refNode = pairDevice.FindChild("ref"); - if (refNode?.Text == null) return; + if (refNode == null) return; + + // ref content arrives as raw bytes that are actually a UTF-8 string + // (the server sends e.g. 102 ASCII chars of a base64-like token as byte[]) + // Do NOT base64-encode it again — decode the bytes as UTF-8. + string ref_; + if (refNode.Data != null) + ref_ = System.Text.Encoding.UTF8.GetString(refNode.Data); + else if (refNode.Text != null) + ref_ = refNode.Text; + else + return; - var ref_ = refNode.Text; var qrParts = new[] { ref_, @@ -273,207 +628,1798 @@ private async Task HandlePairDeviceResultAsync(BinaryNode pairDevice, Cancellati QRCodeGenerated?.Invoke(this, qrString); } - private void HandlePairSuccess(BinaryNode pairSuccess) + private async Task HandlePairSuccessAsync(BinaryNode iq, BinaryNode pairSuccess, CancellationToken ct) { - _logger.LogInformation("Pairing successful! Extracting credentials."); + var msgId = iq.GetAttr("id") ?? ""; + _logger.LogInformation("=== PAIR-SUCCESS RECEIVED === id={Id}", msgId); var platform = pairSuccess.GetAttr("platform") ?? "UNKNOWN"; - _auth.Platform = platform; - - // In a full implementation: extract device ID, JID from the pair-success node, - // then save them into auth state. This requires decrypting the device identity - // proof which involves ADV (account data verification). - // Simplified: extract JID if present + // Extract JID var deviceNode = pairSuccess.FindChild("device"); - if (deviceNode != null) + var jid = deviceNode?.GetAttr("jid") ?? ""; + _logger.LogInformation("Paired as {Jid} on platform {Platform}", jid, platform); + + // ── ADV device-identity verification & signing ───────────────────── + // Baileys: configureSuccessfulPairing() in validate-connection.js + var devIdentityNode = pairSuccess.FindChild("device-identity"); + if (devIdentityNode?.Data == null) { - var jid = deviceNode.GetAttr("jid"); - if (!string.IsNullOrEmpty(jid)) - { - _auth.Me = new MeInfo { Id = jid }; - _logger.LogInformation("Paired as {Jid}", jid); - } + _logger.LogError("pair-success missing device-identity content — cannot complete pairing."); + return; } - Authenticated?.Invoke(this, _auth); - } + // 1. Decode ADVSignedDeviceIdentityHMAC + var hmacMsg = ADVSignedDeviceIdentityHMAC.ParseFrom(devIdentityNode.Data); - private void HandleMessageNode(BinaryNode node) - { - var from = node.GetAttr("from") ?? ""; - var id = node.GetAttr("id") ?? ""; - var participant = node.GetAttr("participant"); - var pushName = node.GetAttr("notify"); - var fromMe = node.GetAttr("fromMe") == "true" || node.GetAttr("from") == _auth.Me?.Id; + // 2. Verify HMAC-SHA256(details, advSecretKey) + // isHostedAccount = (hmacMsg.AccountType == 1) => prefix [6,5], else empty + var isHosted = hmacMsg.AccountType == 1; + var hmacInput = isHosted + ? (new byte[] { 6, 5 }).Concat(hmacMsg.Details).ToArray() + : hmacMsg.Details; + var expectedHmac = HMACSHA256.HashData(_auth.AdvSecretKey, hmacInput); + if (!expectedHmac.AsSpan().SequenceEqual(hmacMsg.Hmac)) + { + _logger.LogError("ADV HMAC verification failed — pairing rejected."); + return; + } + _logger.LogInformation("ADV HMAC verified OK."); - if (!long.TryParse(node.GetAttr("t"), out var timestamp)) - timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + // 3. Decode ADVSignedDeviceIdentity + var account = ADVSignedDeviceIdentity.ParseFrom(hmacMsg.Details); + + // 4. Verify account signature: XEdDSA.Verify(accountSignatureKey, [6,0] + deviceDetails + identityPub, accountSignature) + var accountMsg = new byte[] { 6, 0 } + .Concat(account.Details) + .Concat(_auth.SignedIdentityKeyPublic) + .ToArray(); + if (!XEdDSA.Verify(account.AccountSignatureKey, accountMsg, account.AccountSignature)) + { + _logger.LogError("Account signature verification failed — pairing rejected."); + return; + } + _logger.LogInformation("Account signature verified OK."); + + // 5. Sign device identity: XEdDSA.Sign(identityPrivate, prefix + deviceDetails + identityPub + accountSigKey) + var devicePrefix = isHosted ? new byte[] { 6, 6 } : new byte[] { 6, 1 }; + var deviceMsg = devicePrefix + .Concat(account.Details) + .Concat(_auth.SignedIdentityKeyPublic) + .Concat(account.AccountSignatureKey) + .ToArray(); + account.DeviceSignature = XEdDSA.Sign(_auth.SignedIdentityKeyPrivate, deviceMsg); + _logger.LogInformation("Device signature created."); - // Extract text content — walk the message body - string? text = null; - var body = node.FindChild("body"); - if (body?.Text != null) - text = body.Text; + // Store account in auth state for device-identity node when sending pkmsg + _auth.Account = account.ToByteArray(); - if (string.IsNullOrEmpty(text)) - return; // Skip non-text messages for now + // 6. Decode ADVDeviceIdentity to get keyIndex + var deviceIdentity = ADVDeviceIdentity.ParseFrom(account.Details); - var msg = new IncomingMessage + // 7. Re-encode ADVSignedDeviceIdentity (WITHOUT accountSignatureKey per Baileys protocol) + var accountEnc = account.ToByteArrayForReply(); + + // 8. Send pair-device-sign IQ as the result (this IS the ack — same msgId) + var deviceIdentityNode2 = new BinaryNode("device-identity", new() + { + ["key-index"] = deviceIdentity.KeyIndex.ToString(), + }) + { + Content = accountEnc, + }; + var pairDeviceSign = new BinaryNode("pair-device-sign") + { + Content = new List { deviceIdentityNode2 }, + }; + var reply = new BinaryNode("iq", new() { - Id = id, - From = participant ?? from, - RemoteJid = from, - Participant = participant, - Text = text, - FromMe = fromMe, - Timestamp = timestamp, + ["to"] = "@s.whatsapp.net", + ["type"] = "result", + ["id"] = msgId, + }) + { + Content = new List { pairDeviceSign }, }; + await SendNodeAsync(reply, ct); + _logger.LogInformation("pair-device-sign sent (keyIndex={KeyIndex}).", deviceIdentity.KeyIndex); + + // 9. Update auth state + _auth.Platform = platform; + _auth.Me = new MeInfo { Id = jid }; - MessageReceived?.Invoke(this, msg); + _logger.LogInformation("Firing Authenticated event — session established."); + Authenticated?.Invoke(this, _auth); } - private async Task HandleNotificationAsync(BinaryNode notification, CancellationToken ct) + private void HandleMessageNode(BinaryNode node) { - // ACK notifications - var id = notification.GetAttr("id"); - var to = notification.GetAttr("from") ?? "s.whatsapp.net"; - var ack = new BinaryNode("ack", new() + var from = node.GetAttr("from") ?? ""; + var id = node.GetAttr("id") ?? ""; + var participant = node.GetAttr("participant"); + var participantPn = node.GetAttr("participant_pn"); // phone JID when participant is a LID + var participantLid = node.GetAttr("participant_lid"); // LID when participant/from is a phone JID + var senderLid = node.GetAttr("sender_lid"); // LID when from= is a phone JID + var senderPn = node.GetAttr("sender_pn"); // phone JID when from= is a LID + var pushName = node.GetAttr("notify"); + + // ── Learn lid↔phone mappings from ALL addressing attributes, and CRITICALLY + // propagate each one into the Signal store so the decrypt path resolves the + // LID to the same session. Without this the two maps drift and MAC verification + // fails for LID senders (root cause, 2026-07-03). Registering also collapses any + // split lid-keyed session onto the canonical phone slot before we decrypt below. + var cacheUpdated = false; + void LearnMapping(string? lid, string? phone) { - ["id"] = id ?? "", - ["to"] = to, - ["type"] = "notification", - ["class"] = notification.GetAttr("type") ?? "", - }); - await SendNodeAsync(ack, ct); - } + if (string.IsNullOrEmpty(lid) || string.IsNullOrEmpty(phone)) return; + if (!lid.EndsWith("@lid") || !phone.EndsWith("@s.whatsapp.net")) return; + if (!_lidToPhone.TryGetValue(lid, out var ex) || ex != phone) + { + _lidToPhone[lid] = phone; + cacheUpdated = true; + } + // Propagate to the Signal session store (map + session migration). + try { _signalStore.RegisterLidMapping(lid, phone); } catch { /* best effort */ } + } - // ─── Send message ─────────────────────────────────────────────────────── + LearnMapping(participant, participantPn); // participant=LID, participant_pn=phone + if (from.EndsWith("@s.whatsapp.net")) LearnMapping(senderLid, from); // from=phone, sender_lid=LID + if (from.EndsWith("@lid")) LearnMapping(from, senderPn); // from=LID, sender_pn=phone + if (!string.IsNullOrEmpty(participant) && participant.EndsWith("@s.whatsapp.net")) + LearnMapping(participantLid, participant); // participant=phone, participant_lid=LID - /// Sends a text message to a JID. - public async Task SendTextMessageAsync(string jid, string text, CancellationToken ct) - { - var msgId = GenerateMessageId(); - var msgContent = Encoding.UTF8.GetBytes(text); + // Chat address for storage/display: normalize a LID `from` to its phone JID when + // known, so the message is stored under a phone key that getMessages(phone) finds. + var remoteJid = from.EndsWith("@lid") && _lidToPhone.TryGetValue(from, out var fromPhone) + ? fromPhone : from; - var msgNode = new BinaryNode("message", new() + // Also store push names keyed by sender JID (may be LID or phone JID) + if (!string.IsNullOrEmpty(pushName)) { - ["id"] = msgId, - ["type"] = "text", - ["to"] = jid, - }, new List + var senderJid = participant ?? from; + if (!string.IsNullOrEmpty(senderJid) && !_pushNames.ContainsKey(senderJid)) + { + _pushNames[senderJid] = pushName; + cacheUpdated = true; + } + // If participant is a LID, also map the phone JID if we know it + if (!string.IsNullOrEmpty(participantPn) && !_pushNames.ContainsKey(participantPn)) + { + _pushNames[participantPn] = pushName; + cacheUpdated = true; + } + // If from is a phone JID and we have a sender_lid, map both + if (!string.IsNullOrEmpty(senderLid) && !_pushNames.ContainsKey(senderLid)) + { + _pushNames[senderLid] = pushName; + cacheUpdated = true; + } + } + + if (cacheUpdated) SaveCacheToDisk(); + + // Track this chat in thread metadata + if (!string.IsNullOrEmpty(from) && long.TryParse(node.GetAttr("t"), out var msgTs)) + _threadMetadata[from] = msgTs; + + var myJidBase = _auth.Me?.Id?.Split(':')[0]; // e.g. "31633984381" + var fromMe = from == myJidBase + "@s.whatsapp.net" + || (myJidBase != null && from.StartsWith(myJidBase)); + + if (!long.TryParse(node.GetAttr("t"), out var timestamp)) + timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // ── Encrypted path: ── + var participantsNode = node.FindChild("participants"); + if (participantsNode != null) { - new("body", content: text), - }); + foreach (var toNode in participantsNode.GetChildren("to")) + { + var toJid = toNode.GetAttr("jid") ?? ""; + if (myJidBase != null && !toJid.StartsWith(myJidBase)) continue; - await SendNodeAsync(msgNode, ct); - _logger.LogInformation("Sent message to {Jid}: {Text}", jid, text.Length > 50 ? text[..50] + "…" : text); - } + var encNode = toNode.FindChild("enc"); + if (encNode?.Data == null) continue; - // ─── Low-level send/receive ───────────────────────────────────────────── + var encType = encNode.GetAttr("type") ?? "msg"; + try + { + var senderJid = participant ?? from; + _logger.LogDebug("Participants path: decrypting encType={EncType} from {Jid}", encType, senderJid); + var plaintext = _signalStore.DecryptMessage(senderJid, encType, encNode.Data, _auth); + var waMsg = WAMessage.ParseFrom(StripSignalPadding(plaintext)); - private async Task SendNodeAsync(BinaryNode node, CancellationToken ct) - { - var encoded = BinaryNodeEncoder.Encode(node); - var encrypted = EncryptFrame(encoded); - await _socket.SendFrameAsync(encrypted, ct); - } + // ── History sync notification (participants path) ───────────────────────── + if (waMsg.ProtocolMsg?.Type == Proto.ProtocolMessage.TYPE_HISTORY_SYNC_NOTIFICATION + && waMsg.ProtocolMsg.HistorySyncNotification != null) + { + _logger.LogInformation("Participants path: received HISTORY_SYNC_NOTIFICATION from {Jid}", senderJid); + _ = Task.Run(() => ProcessHistorySyncAsync(waMsg.ProtocolMsg.HistorySyncNotification, CancellationToken.None)); + _ = SendAckAsync(id, from, timestamp); + continue; + } - private bool _firstHandshakeFrame = true; + // ── Peer data operation response (ON_DEMAND history) ────────────────────── + if (waMsg.PeerDataResponse != null) + { + _logger.LogInformation("Participants path: received PeerDataOperationResponseMessage from {Jid}", senderJid); + _ = Task.Run(() => ProcessPeerDataResponseAsync(waMsg.PeerDataResponse, CancellationToken.None)); + _ = SendAckAsync(id, from, timestamp); + continue; + } - private async Task SendHandshakeMessageAsync(HandshakeMessage msg, CancellationToken ct) - { - var msgBytes = msg.ToByteArray(); + // ── Skip other protocol messages (key sync, app state, etc.) ────────────── + if (waMsg.ProtocolMsg != null) + { + _logger.LogDebug("Participants path: protocol message type={PT} from {Jid} — skipping", + waMsg.ProtocolMsg.Type, senderJid); + _ = SendAckAsync(id, from, timestamp); + continue; + } - if (_firstHandshakeFrame) - { - // Baileys wire format for the FIRST frame: - // [WA_PROLOGUE: 4 bytes] [length: 3 bytes BE] [proto payload: N bytes] - // The WA prologue is OUTSIDE the 3-byte length framing (unlike subsequent frames). - var raw = new byte[WA_PROLOGUE.Length + 3 + msgBytes.Length]; - WA_PROLOGUE.CopyTo(raw, 0); - raw[4] = (byte)(msgBytes.Length >> 16); - raw[5] = (byte)(msgBytes.Length >> 8); - raw[6] = (byte)(msgBytes.Length); - msgBytes.CopyTo(raw, 7); - await _socket.SendRawAsync(raw, ct); - _firstHandshakeFrame = false; - } - else - { - // Subsequent frames: standard [3-byte len][payload] - await _socket.SendFrameAsync(msgBytes, ct); - } - } + // ── Fire MessageReceived for ALL user message types ──────────────────────── + var (msgType, text, mediaUrl, mimeType, fileName, fileSize, + duration, width, height, mediaKey, mediaSha256Enc, + reactionEmoji, reactionTargetId) = waMsg.GetAllFields(); - private byte[] EncryptFrame(byte[] data) - { - if (!_handshakeDone || _sendKey == null) - throw new InvalidOperationException("Handshake not complete."); - return AesGcmHelper.EncryptWithCounter(_sendKey, _sendCounter++, data); - } + if (msgType == Messages.MessageType.Unknown) + { + _logger.LogDebug("Participants path: unknown message type from {Jid} — ACKing and skipping", senderJid); + _ = SendAckAsync(id, from, timestamp); + continue; + } - private byte[] DecryptFrame(byte[] data) - { - if (!_handshakeDone || _recvKey == null) - throw new InvalidOperationException("Handshake not complete."); - return AesGcmHelper.DecryptWithCounter(_recvKey, _recvCounter++, data); - } + _logger.LogInformation("Participants path: {MsgType} message from {Jid}", msgType, senderJid); - // ─── Helpers ──────────────────────────────────────────────────────────── + var (quotedId, quotedFrom, quotedText, quotedType) = waMsg.GetQuotedContext(); - // Baileys default version: [2, 3000, 1015920] - // buildHash = MD5("2.3000.1015920") - private const string WA_VERSION = "2.3000.1015920"; + MessageReceived?.Invoke(this, new Messages.IncomingMessage + { + Id = id, + From = senderJid, + RemoteJid = remoteJid, + Participant = participant, + Type = msgType, + Text = text, + FromMe = fromMe, + Timestamp = timestamp, + PushName = pushName, + MediaUrl = mediaUrl, + MimeType = mimeType, + FileName = fileName, + FileSize = fileSize, + Duration = duration, + Width = width, + Height = height, + MediaKey = mediaKey, + MediaSha256Enc = mediaSha256Enc, + ReactionEmoji = reactionEmoji, + ReactionTargetId = reactionTargetId, + QuotedMessageId = quotedId, + QuotedFrom = quotedFrom, + QuotedText = quotedText, + QuotedType = quotedType, + }); - private byte[] BuildClientPayload() - { - var userAgent = new UserAgent - { - Platform = 14, // WEB - AppVersion = new AppVersion { Primary = 2, Secondary = 3000, Tertiary = 1015920 }, - Mcc = "000", - Mnc = "000", - OsVersion = "0.1", - Device = "Chrome", // Baileys browser[1] = "Chrome" (Browsers.ubuntu('Chrome')) - OsBuildNumber = "0.1", - LocaleLanguageIso6391 = "en", - LocaleCountryIso31661Alpha2 = "US", - }; + _ = SendAckAsync(id, from, timestamp); + } + catch (Exception ex) + { + _logger.LogError(ex, "Participants path: failed to decrypt encType={EncType} from {Jid}", encType, from); + _ = HandleUndecryptableAsync(id, from, timestamp); + } + } + return; + } - if (_auth.IsFresh) + // ── Direct (no wrapper) ────────────────────────── + var directEnc = node.FindChild("enc"); + if (directEnc?.Data != null) { - // Fresh registration: include device pairing data so server knows our keys - var buildHash = MD5.HashData(System.Text.Encoding.UTF8.GetBytes(WA_VERSION)); + var encType = directEnc.GetAttr("type") ?? "msg"; + try + { + var senderJid = participant ?? from; + byte[] plaintext; + try + { + plaintext = _signalStore.DecryptMessage(senderJid, encType, directEnc.Data, _auth); + } + catch (Exception decEx) + { + _logger.LogWarning(decEx, "Failed to decrypt direct enc message from {Jid}", from); + _ = HandleUndecryptableAsync(id, from, timestamp); + return; + } - // Registration ID as 4-byte big-endian - var eRegid = new byte[4]; - eRegid[0] = (byte)(_auth.RegistrationId >> 24); - eRegid[1] = (byte)(_auth.RegistrationId >> 16); - eRegid[2] = (byte)(_auth.RegistrationId >> 8); - eRegid[3] = (byte)(_auth.RegistrationId); + _logger.LogInformation("Decrypted {Bytes} bytes from {Jid} (category={Category})", + plaintext.Length, from, node.GetAttr("category") ?? "?"); - // Signed pre-key ID as 3-byte big-endian - var eSkeyId = new byte[3]; - eSkeyId[0] = (byte)(_auth.SignedPreKeyId >> 16); - eSkeyId[1] = (byte)(_auth.SignedPreKeyId >> 8); - eSkeyId[2] = (byte)(_auth.SignedPreKeyId); + // Strip Signal protocol padding (0x80 followed by zero bytes at end) + var protoBytes2 = StripSignalPadding(plaintext); - var deviceProps = new DevicePropsMessage - { - Os = "Windows", - PlatformType = 1, // CHROME - }.ToByteArray(); + WAMessage waMsg; + try + { + waMsg = WAMessage.ParseFrom(protoBytes2); + } + catch (Exception parseEx) + { + // Peer/device messages may not be standard WAMessage format — log at Warning so we can diagnose + _logger.LogWarning(parseEx, "Could not parse decrypted message as WAMessage from {Jid} ({Len} bytes, first={First})", + from, protoBytes2.Length, Convert.ToHexString(protoBytes2[..Math.Min(16, protoBytes2.Length)])); + _ = SendAckAsync(id, from, timestamp); + return; + } - return new ClientPayload - { - Passive = false, - Pull = false, - ConnectType = 1, // WIFI_UNKNOWN - ConnectReason = 1, // USER_ACTIVATED - UserAgent = userAgent, - WebInfo = new WebInfo { WebSubPlatform = 0 }, - DevicePairingData = new DevicePairingRegistrationData + var text = waMsg.GetText(); + _logger.LogInformation("Parsed WAMessage from {Jid}: text={Text}, hasDeviceSent={DevSent}, hasSKDM={SKDM}", + from, text ?? "(null)", waMsg.DeviceSentMessage != null, waMsg.SenderKeyDist != null); + + // ── History sync notification ───────────────────────────────── + if (waMsg.ProtocolMsg?.Type == Proto.ProtocolMessage.TYPE_HISTORY_SYNC_NOTIFICATION + && waMsg.ProtocolMsg.HistorySyncNotification != null) + { + _ = Task.Run(() => ProcessHistorySyncAsync(waMsg.ProtocolMsg.HistorySyncNotification, CancellationToken.None)); + _ = SendAckAsync(id, from, timestamp); + return; + } + + // ── Peer data operation response (ON_DEMAND history inline) ────────── + if (waMsg.PeerDataResponse != null) + { + _logger.LogInformation("Direct enc path: received PeerDataOperationResponseMessage from {Jid}", from); + _ = Task.Run(() => ProcessPeerDataResponseAsync(waMsg.PeerDataResponse, CancellationToken.None)); + _ = SendAckAsync(id, from, timestamp); + return; + } + + // ── Skip other protocol messages (key sync, app state, etc.) ────────────── + if (waMsg.ProtocolMsg != null) + { + _logger.LogDebug("Direct enc path: protocol message type={PT} from {Jid} — skipping", + waMsg.ProtocolMsg.Type, from); + _ = SendAckAsync(id, from, timestamp); + return; + } + + // ── Fire MessageReceived for ALL user message types ──────────────────────── + var (msgType, text2, mediaUrl, mimeType, fileName, fileSize, + duration, width, height, mediaKey, mediaSha256Enc, + reactionEmoji, reactionTargetId) = waMsg.GetAllFields(); + + if (msgType != Messages.MessageType.Unknown) + { + _logger.LogInformation("Direct enc path: {MsgType} message from {Jid}", msgType, from); + var (quotedId2, quotedFrom2, quotedText2, quotedType2) = waMsg.GetQuotedContext(); + MessageReceived?.Invoke(this, new Messages.IncomingMessage + { + Id = id, + From = participant ?? from, + RemoteJid = remoteJid, + Participant = participant, + Type = msgType, + Text = text2, + FromMe = fromMe, + Timestamp = timestamp, + PushName = pushName, + MediaUrl = mediaUrl, + MimeType = mimeType, + FileName = fileName, + FileSize = fileSize, + Duration = duration, + Width = width, + Height = height, + MediaKey = mediaKey, + MediaSha256Enc = mediaSha256Enc, + ReactionEmoji = reactionEmoji, + ReactionTargetId = reactionTargetId, + QuotedMessageId = quotedId2, + QuotedFrom = quotedFrom2, + QuotedText = quotedText2, + QuotedType = quotedType2, + }); + } + else + { + _logger.LogDebug("Direct enc path: unknown message type from {Jid} — ACKing", from); + } + _ = SendAckAsync(id, from, timestamp); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error handling direct enc message from {Jid}", from); + } + return; + } + + // ── Fallback: plain text body (legacy) ──────────────────────────────── + var body = node.FindChild("body"); + var plainText = body?.Text; + if (string.IsNullOrEmpty(plainText)) return; + + MessageReceived?.Invoke(this, new IncomingMessage + { + Id = id, + From = participant ?? from, + RemoteJid = remoteJid, + Text = plainText, + FromMe = fromMe, + Timestamp = timestamp, + PushName = pushName, + }); + } + + /// + /// Sends the passive→active switch IQ (xmlns="passive", tag="active"). + /// This is the critical call Baileys makes after auth to tell WhatsApp to start + /// delivering queued messages to this companion device. Without it, WhatsApp keeps + /// the session in passive mode and never pushes any message/dirty/notification nodes. + /// + private async Task SendActiveAsync(CancellationToken ct) + { + var iq = new BinaryNode("iq", new Dictionary + { + ["to"] = "@s.whatsapp.net", + ["type"] = "set", + ["xmlns"] = "passive", + ["id"] = GenerateMessageId(), + }) + { + Content = new List { new BinaryNode("active") }, + }; + try + { + await SendIQAsync(iq, ct, timeoutMs: 10000); + _logger.LogInformation("Passive→active switch sent — WhatsApp should now deliver messages."); + } + catch (TimeoutException) + { + _logger.LogWarning("passive/active IQ timed out — WhatsApp may not deliver messages."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "passive/active IQ failed."); + } + } + + /// + /// Sends a presence update to tell WhatsApp this device is online and ready to receive messages. + /// Without this, WhatsApp may not deliver messages to linked devices. + /// + private async Task SendPresenceAsync(CancellationToken ct) + { + try + { + var presence = new BinaryNode("presence", new Dictionary + { + ["type"] = "available", + }); + await SendNodeAsync(presence, ct); + _logger.LogInformation("Sent presence: available — WhatsApp should now deliver messages to this device."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send presence update"); + } + } + + /// + /// Sends a keepalive ping IQ every 25 seconds, like Baileys does after login. + /// The server expects this to consider the device fully online and deliver messages. + /// + private async Task KeepAliveLoopAsync(CancellationToken ct) + { + const int intervalMs = 25_000; // ping cadence + const int deadAfterMs = intervalMs + 15_000; // ~40s of total silence => declare dead + _logger.LogInformation("Keepalive loop started ({Interval}s interval, dead after {Dead}s silence).", + intervalMs / 1000, deadAfterMs / 1000); + try + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(intervalMs, ct); + if (ct.IsCancellationRequested) break; + + // Dead-connection watchdog: if we have not received ANY frame (including the + // pong to our own ping) for too long, the socket is dead/half-open. Dispose it + // so the parked ReceiveAsync throws, the receive loop exits, and reconnection runs. + var silentMs = (DateTime.UtcNow.Ticks - System.Threading.Interlocked.Read(ref _lastRecvTicks)) + / TimeSpan.TicksPerMillisecond; + if (silentMs > deadAfterMs) + { + _logger.LogWarning("Keepalive: no data for {Ms}ms — declaring connection dead, tearing down socket.", silentMs); + try { await _socket.DisposeAsync(); } catch { /* best effort */ } + break; + } + + try + { + var ping = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["to"] = "@s.whatsapp.net", + ["type"] = "get", + ["xmlns"] = "w:p", + }) + { + Content = new List { new("ping") }, + }; + await SendNodeAsync(ping, ct); + _logger.LogDebug("Sent keepalive ping."); + } + catch (Exception ex) + { + // A ping WRITE failure means the socket is gone. Tear it down so the receive + // loop unblocks and reconnection runs (previously it just broke this loop, + // leaving the receive loop parked forever). + _logger.LogWarning(ex, "Keepalive ping write failed — tearing down socket to force reconnect."); + try { await _socket.DisposeAsync(); } catch { /* best effort */ } + break; + } + } + } + catch (OperationCanceledException) { } + _logger.LogInformation("Keepalive loop ended."); + } + + private async Task SendAckAsync(string msgId, string to, long timestamp) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var ack = new BinaryNode("ack", new Dictionary + { + ["id"] = msgId, + ["to"] = to, + ["type"] = "message", + ["t"] = timestamp.ToString(), + }); + await SendNodeAsync(ack, cts.Token); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send ACK for message {Id}", msgId); + } + } + + /// + /// Sends a retry receipt to WhatsApp, asking the sender to re-encrypt using + /// a fresh pre-key bundle. Called when we receive a message we cannot decrypt + /// (e.g. our session state was lost after a restart). + /// + /// + /// Decides how to handle a message we could not decrypt. For a RECENT (live) message we + /// send a retry receipt so the sender re-keys and we recover it. For an OLD message from + /// the offline backlog (a dead ratchet chain that will never decrypt) we simply ACK it so + /// WhatsApp stops resending it and the offline queue drains quickly to current messages, + /// instead of looping forever on undecryptable history. Old messages are already on every + /// participant's phone, so nothing important is lost. This keeps the bridge current — the + /// behaviour a healthy client (e.g. Baileys) gets for free by never accumulating a backlog. + /// + private async Task HandleUndecryptableAsync(string id, string from, long timestamp) + { + const long BacklogThresholdSeconds = 2 * 3600; // messages older than 2h = offline backlog + var ageSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp; + if (timestamp > 0 && ageSeconds > BacklogThresholdSeconds) + { + _logger.LogInformation("Undecryptable BACKLOG message {Id} from {From} (age {Hours}h) — ACK to drain queue, no retry.", id, from, ageSeconds / 3600); + await SendAckAsync(id, from, timestamp); + } + else + { + await SendRetryReceiptAsync(id, from, timestamp); + } + } + + public Task SendManualRetryReceiptAsync(string to, string msgId, long timestamp, CancellationToken ct) => + SendRetryReceiptAsync(msgId, to, timestamp); + + // Per-message retry-receipt attempt counter. A message we can never decrypt (dead + // ratchet chain after a long offline gap) otherwise loops forever: fail → retry + // receipt → resend → fail, starving delivery of every other message behind it in the + // offline queue. After MaxRetryAttempts we ACK the message instead, so WhatsApp stops + // resending it and the queue drains to newer/live messages. + private readonly System.Collections.Concurrent.ConcurrentDictionary _retryAttempts = new(); + private const int MaxRetryAttempts = 5; + + private async Task SendRetryReceiptAsync(string msgId, string to, long timestamp) + { + var attempt = _retryAttempts.AddOrUpdate(msgId, 1, (_, n) => n + 1); + if (attempt > MaxRetryAttempts) + { + _retryAttempts.TryRemove(msgId, out _); + _logger.LogWarning("Retry cap ({Max}) reached for {MsgId} — undecryptable, ACKing to drain offline queue.", MaxRetryAttempts, msgId); + await SendAckAsync(msgId, to, timestamp); + return; + } + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var retry = new BinaryNode("receipt", new Dictionary + { + ["id"] = msgId, + ["type"] = "retry", + ["to"] = to, + ["t"] = timestamp.ToString(), + }) + { + Content = new List + { + new("retry", new Dictionary + { + ["count"] = attempt.ToString(), + ["id"] = msgId, + ["t"] = timestamp.ToString(), + ["v"] = "1", + }), + new("registration", null, new byte[] { (byte)(_auth.RegistrationId >> 24), (byte)(_auth.RegistrationId >> 16), (byte)(_auth.RegistrationId >> 8), (byte)_auth.RegistrationId }), + // — our identity + signed pre-key + one-time pre-key so the sender can + // re-establish a fresh Signal session. MUST include (one-time pre-key) — + // without it the phone ACKs the retry receipt but never re-encrypts the message. + BuildRetryKeysNode(_auth), + }, + }; + await SendNodeAsync(retry, cts.Token); + _logger.LogInformation("Sent retry receipt for {MsgId} to {To} — session state lost, requesting re-key.", msgId, to); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send retry receipt for {MsgId}", msgId); + } + } + + /// Builds a node from our signed pre-key — included in retry receipts so the + /// sender can re-encrypt using a fresh Signal session with our current keys. + private static BinaryNode BuildSignedPreKeyNode(Auth.AuthState auth) + { + var spkId = auth.SignedPreKeyId; + var idBytes = new byte[] { (byte)(spkId >> 16), (byte)(spkId >> 8), (byte)spkId }; + return new BinaryNode("skey", null, new List + { + new("id", null, idBytes), + new("value", null, auth.SignedPreKeyPublic), + new("signature", null, auth.SignedPreKeySignature), + }); + } + + /// Builds the node for retry receipts. + /// Node order matches Baileys: type, identity, key (one-time), skey (signed), device-identity. + /// The device-identity is REQUIRED for the phone to trust and process the retry receipt. + /// Uses _retryPreKeyIndex to assign a unique pre-key per message so that if multiple + /// re-encrypted pkmsg responses arrive they can each be decrypted independently. + private BinaryNode BuildRetryKeysNode(Auth.AuthState auth) + { + static byte[] Be3(uint v) => [(byte)(v >> 16), (byte)(v >> 8), (byte)v]; + + var children = new List + { + new("type", null, new byte[] { 5 }), // DJB_TYPE = 0x05 + new("identity", null, auth.SignedIdentityKeyPublic), + }; + + // Pick a unique one-time pre-key for each retry receipt. _retryPreKeyIndex increments + // per call so that each re-encrypted pkmsg response uses a different X3DH pre-key and + // can be decrypted independently (keys are consumed by InitIncomingSession on use). + var keyIndex = _retryPreKeyIndex++; + var otpk = keyIndex < auth.PreKeys.Count ? auth.PreKeys[keyIndex] : auth.PreKeys.LastOrDefault(); + if (otpk != null) + { + // comes BEFORE — this matches Baileys xmppPreKey / xmppSignedPreKey order + children.Add(new BinaryNode("key", null, new List + { + new("id", null, Be3(otpk.Id)), + new("value", null, otpk.Public), + })); + } + + // (signed pre-key) AFTER + children.Add(BuildSignedPreKeyNode(auth)); + + // device-identity — REQUIRED! Phone uses this to verify our device and re-establish session. + if (auth.Account != null) + { + children.Add(new BinaryNode("device-identity") { Content = auth.Account }); + } + + return new BinaryNode("keys", null, children); + } + + /// + /// Sends the initial app state sync request (xmlns="w:app:state:sync"). + /// Baileys calls resyncAppState immediately after auth — WhatsApp may withhold + /// message delivery until the companion device has "checked in" with a sync request. + /// We request all 5 standard collections at version=0 (initial sync). + /// We don't need to process the response content, just sending the request is enough + /// to signal to WhatsApp that this device is ready to receive messages. + /// + private async Task SendAppStateSyncAsync(CancellationToken ct) + { + var collections = new[] { "critical_block", "critical_unblock_to_primary", "regular_high", "regular_low", "regular" }; + var collectionNodes = collections.Select(name => new BinaryNode("collection", new Dictionary + { + ["name"] = name, + ["version"] = "0", + ["return_snapshot"] = "true", + })).ToList(); + + var syncNode = new BinaryNode("sync") { Content = collectionNodes }; + var iq = new BinaryNode("iq", new Dictionary + { + ["to"] = "@s.whatsapp.net", + ["xmlns"] = "w:app:state:sync", + ["type"] = "set", + ["id"] = GenerateMessageId(), + }) { Content = new List { syncNode } }; + + try + { + var result = await SendIQAsync(iq, ct, timeoutMs: 60000); + _logger.LogInformation("App state sync request acknowledged (WhatsApp should now route messages to this device)."); + } + catch (TimeoutException ex) + { + _logger.LogWarning(ex, "App state sync IQ timed out — continuing anyway (messages may still arrive)"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "App state sync request failed (non-fatal)"); + } + } + + // Cached app-state collection versions received via server_sync notifications. + // key = collection name (e.g. "contact", "regular_low"), value = version number + private readonly System.Collections.Concurrent.ConcurrentDictionary _serverSyncVersions = new(); + + // Thread metadata from nodes — key=JID (may be @lid), value=unix timestamp + private readonly System.Collections.Concurrent.ConcurrentDictionary _threadMetadata = new(); + + // LID → phone JID mapping — populated from participant_pn attributes in incoming messages + // key = "178430138150925@lid", value = "31633984381@s.whatsapp.net" + private readonly System.Collections.Concurrent.ConcurrentDictionary _lidToPhone = new(); + + // JID → push name — populated from "notify" attribute of incoming messages + private readonly System.Collections.Concurrent.ConcurrentDictionary _pushNames = new(); + + private async Task HandleNotificationAsync(BinaryNode notification, CancellationToken ct) + { + var id = notification.GetAttr("id") ?? ""; + var type = notification.GetAttr("type") ?? ""; + var to = notification.GetAttr("from") ?? "s.whatsapp.net"; + + _logger.LogInformation("Notification: type={Type} id={Id} from={From}", type, id, to); + + // ACK the notification first + var ack = new BinaryNode("ack", new() + { + ["id"] = id, + ["to"] = to, + ["type"] = "notification", + ["class"] = type, + }); + await SendNodeAsync(ack, ct); + + // --- Handle server_sync notifications --- + // WhatsApp sends these in response to w:app:state:sync IQs (instead of IQ results). + // They tell us the current version of each collection. + if (type == "server_sync") + { + var children = notification.Children; + foreach (var child in children) + { + if (child.Tag == "collection") + { + var name = child.GetAttr("name") ?? ""; + var verStr = child.GetAttr("version") ?? "0"; + if (int.TryParse(verStr, out var ver)) + { + _serverSyncVersions[name] = ver; + _logger.LogInformation("server_sync: collection={Name} version={Ver}", name, ver); + } + } + } + } + + // Generic: if any notification has an ID matching a pending IQ, resolve it. + // WhatsApp sometimes sends IQ responses as notifications (e.g. w:app:state:sync, potentially w:m). + if (!string.IsNullOrEmpty(id) && _pendingIqs.TryGetValue(id, out var pendingTcs)) + { + _pendingIqs.Remove(id); + _logger.LogInformation("Resolved pending IQ {Id} via {Type} notification", id, type); + pendingTcs.TrySetResult(notification); + } + } + + // ─── Send message ─────────────────────────────────────────────────────── + + /// Sends an encrypted text message to a JID using Signal Protocol. + public async Task SendTextMessageAsync(string jid, string text, CancellationToken ct) + { + // 1. Normalize JID + var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net"; + var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0]; + + // 2. Get recipient device list via USync + List recipientDeviceJids; + try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get device list for {Phone}, using fallback", phoneNumber); + recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"]; + } + + // 3. Get sender's own devices for multi-device sync + var senderDeviceJids = new List(); + var myJid = _auth.Me?.Id; // e.g. "31633984381:44@s.whatsapp.net" + if (myJid != null) + { + var myPhone = myJid.Split('@')[0].Split(':')[0]; + try + { + var myDevices = await GetDeviceListAsync(myPhone, ct); + foreach (var d in myDevices) + { + // Skip our own companion device (can't encrypt to ourselves) + if (d == myJid) continue; + senderDeviceJids.Add(d); + } + _logger.LogInformation("Sender has {Count} other devices for sync: {Jids}", + senderDeviceJids.Count, string.Join(", ", senderDeviceJids)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get sender device list, skipping multi-device sync"); + } + } + + // Combine all device JIDs for pre-key bundle fetching + var allDeviceJids = new List(recipientDeviceJids); + allDeviceJids.AddRange(senderDeviceJids); + + // 4. For each device without a session, fetch pre-key bundle + var needBundles = allDeviceJids.Where(d => !_signalStore.HasSession(d)).ToList(); + if (needBundles.Count > 0) + { + try + { + _logger.LogInformation("Fetching pre-key bundles for {Count} devices: {Jids}", + needBundles.Count, string.Join(", ", needBundles)); + var bundles = await FetchPreKeyBundlesAsync(needBundles, ct); + _logger.LogInformation("Got {Count} bundles back", bundles.Count); + foreach (var (deviceJid, bundle) in bundles) + { + try + { + _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); + _logger.LogInformation("Initialized outgoing session for {Jid}", deviceJid); + } + catch (Exception ex2) + { + _logger.LogWarning(ex2, "Failed to init outgoing session for {Jid}", deviceJid); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch pre-key bundles"); + } + } + + // 5. Proto-encode the message with random PKCS7-style padding (Baileys: writeRandomPadMax16) + var msgRaw = new WAMessage { Conversation = text }.ToByteArray(); + + // 6. Build padded proto for recipient devices (direct message) + var recipientProto = PadMessage(msgRaw); + + // 7. Build padded proto for sender's own devices (wrapped in DeviceSentMessage) + byte[]? senderProto = null; + if (senderDeviceJids.Count > 0) + { + var deviceSentMsg = new WAMessage + { + DeviceSentMessage = new DeviceSentMessage + { + DestinationJid = normalizedJid, + Message = new WAMessage { Conversation = text }, + } + }; + senderProto = PadMessage(deviceSentMsg.ToByteArray()); + } + + // 8. Encrypt for each device + var msgId = GenerateMessageId(); + var hasPkMsg = false; + var toNodes = new List(); + + // Encrypt for recipient devices + foreach (var deviceJid in recipientDeviceJids) + { + var node = EncryptForDevice(deviceJid, recipientProto, ref hasPkMsg); + if (node != null) toNodes.Add(node); + } + + // Encrypt for sender's own devices (multi-device sync) + foreach (var deviceJid in senderDeviceJids) + { + var node = EncryptForDevice(deviceJid, senderProto!, ref hasPkMsg); + if (node != null) toNodes.Add(node); + } + + if (toNodes.Count == 0) + { + var reason = $"No devices encrypted successfully for {normalizedJid} — message NOT sent (stale/missing Signal session or pre-key bundle fetch failed)."; + _logger.LogError(reason); + throw new InvalidOperationException(reason); + } + + // 9. Build and send message node + var participantsNode = new BinaryNode("participants") + { + Content = toNodes, + }; + var contentNodes = new List { participantsNode }; + + // Include device-identity when sending pkmsg (first message to a device) + if (hasPkMsg && _auth.Account != null) + { + var accountProto = ADVSignedDeviceIdentity.ParseFrom(_auth.Account); + var deviceIdentityBytes = accountProto.ToByteArray(); + contentNodes.Add(new BinaryNode("device-identity") { Content = deviceIdentityBytes }); + _logger.LogInformation("Including device-identity in message (pkmsg detected, {Len} bytes)", deviceIdentityBytes.Length); + } + else if (hasPkMsg) + { + _logger.LogWarning("pkmsg detected but Account is NULL — device-identity NOT included. Recipient may reject. Re-pair to fix."); + } + + var msgAttrs = new Dictionary + { + ["id"] = msgId, + ["type"] = "text", + ["to"] = normalizedJid, + }; + + var msgNode = new BinaryNode("message", msgAttrs) { Content = contentNodes }; + + await SendNodeAsync(msgNode, ct); + _logger.LogInformation("Sent encrypted message to {Jid} via {RecipientCount}+{SenderCount} devices", + normalizedJid, recipientDeviceJids.Count, senderDeviceJids.Count); + } + + /// + /// Sends an emoji reaction to a specific message. + /// + /// The chat JID (e.g. "31612345678@s.whatsapp.net") containing the target message. + /// The ID of the message to react to. + /// Whether the target message was sent by us. + /// The reaction emoji, e.g. "👍". Pass "" to remove an existing reaction. + public async Task SendReactionAsync(string targetJid, string targetMessageId, bool targetFromMe, string emoji, CancellationToken ct) + { + var normalizedJid = targetJid.Contains('@') ? targetJid : $"{targetJid.TrimStart('+')}@s.whatsapp.net"; + var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0]; + + // Get recipient device list + List recipientDeviceJids; + try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get device list for {Phone}, using fallback", phoneNumber); + recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"]; + } + + // Get sender's own devices for multi-device sync + var senderDeviceJids = new List(); + var myJid = _auth.Me?.Id; + if (myJid != null) + { + var myPhone = myJid.Split('@')[0].Split(':')[0]; + try + { + var myDevices = await GetDeviceListAsync(myPhone, ct); + foreach (var d in myDevices) + { + if (d == myJid) continue; + senderDeviceJids.Add(d); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get sender device list for reaction, skipping multi-device sync"); + } + } + + var allDeviceJids = new List(recipientDeviceJids); + allDeviceJids.AddRange(senderDeviceJids); + + // Fetch pre-key bundles for devices without sessions + var needBundles = allDeviceJids.Where(d => !_signalStore.HasSession(d)).ToList(); + if (needBundles.Count > 0) + { + try + { + var bundles = await FetchPreKeyBundlesAsync(needBundles, ct); + foreach (var (deviceJid, bundle) in bundles) + { + try { _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); } + catch (Exception ex2) { _logger.LogWarning(ex2, "Failed to init session for {Jid}", deviceJid); } + } + } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch pre-key bundles for reaction"); } + } + + // Build reaction proto + var reactionProto = new WAMessage + { + ReactionMessage = new Dawa.Proto.ReactionMessage + { + Key = new Dawa.Proto.MessageKey + { + RemoteJid = normalizedJid, + FromMe = targetFromMe, + Id = targetMessageId, + }, + Text = emoji, + SenderTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + } + }; + + var recipientProto = PadMessage(reactionProto.ToByteArrayWithReaction()); + + // For own devices, same payload (reactions are not wrapped in DeviceSentMessage) + byte[]? senderProto = senderDeviceJids.Count > 0 ? recipientProto : null; + + var msgId = GenerateMessageId(); + var hasPkMsg = false; + var toNodes = new List(); + + foreach (var deviceJid in recipientDeviceJids) + { + var node = EncryptForDevice(deviceJid, recipientProto, ref hasPkMsg); + if (node != null) toNodes.Add(node); + } + foreach (var deviceJid in senderDeviceJids) + { + var node = EncryptForDevice(deviceJid, senderProto!, ref hasPkMsg); + if (node != null) toNodes.Add(node); + } + + if (toNodes.Count == 0) + { + var reason = $"No devices encrypted successfully for reaction to {normalizedJid} — reaction NOT sent (stale/missing Signal session or pre-key bundle fetch failed)."; + _logger.LogError(reason); + throw new InvalidOperationException(reason); + } + + var participantsNode = new BinaryNode("participants") { Content = toNodes }; + var contentNodes = new List { participantsNode }; + + if (hasPkMsg && _auth.Account != null) + { + var accountProto = ADVSignedDeviceIdentity.ParseFrom(_auth.Account); + var deviceIdentityBytes = accountProto.ToByteArray(); + contentNodes.Add(new BinaryNode("device-identity") { Content = deviceIdentityBytes }); + } + + var msgNode = new BinaryNode("message", new Dictionary + { + ["id"] = msgId, + ["type"] = "reaction", + ["to"] = normalizedJid, + }) { Content = contentNodes }; + + await SendNodeAsync(msgNode, ct); + _logger.LogInformation("Sent reaction '{Emoji}' to message {MsgId} in {Jid}", emoji, targetMessageId, normalizedJid); + } + + /// + /// Sends a media message (image, audio, or document) to a JID. + /// must be "image", "audio", or "document". + /// is the raw (unencrypted) file bytes. + /// e.g. "image/jpeg", "audio/ogg; codecs=opus", "application/pdf". + /// optional text (images only). + /// optional file name (documents). + /// + public async Task SendMediaAsync(string jid, byte[] fileBytes, string mediaType, string mimeType, + string caption, string fileName, CancellationToken ct) + { + var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net"; + var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0]; + + // 1. Encrypt the media + var enc = Dawa.Crypto.MediaCrypto.Encrypt(fileBytes, mediaType); + _logger.LogInformation("Media encrypted: {Bytes} → {EncBytes} bytes for {Type}", fileBytes.Length, enc.EncryptedBytes.Length, mediaType); + + // 2. Get upload URL from WhatsApp server via w:m IQ (Baileys pattern) + var encSha256B64 = Convert.ToBase64String(enc.FileEncSha256); + var (mediaUrl, directPath, uploadAuth) = await RequestMediaUploadUrlAsync(mediaType, enc.EncryptedBytes.Length, encSha256B64, ct); + _logger.LogInformation("Media upload URL: {Url} directPath={DP}", mediaUrl, directPath); + + // 3. Upload encrypted bytes to CDN (raw binary, application/octet-stream). + // The content is encrypted so the actual MIME type is irrelevant; CDN accepts octet-stream. + // URL already contains ?auth=...&token=... from RequestMediaUploadUrlAsync. + var rawContent = new ByteArrayContent(enc.EncryptedBytes); + rawContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + + using var uploadRequest = new HttpRequestMessage(HttpMethod.Post, mediaUrl) + { + Content = rawContent, + }; + uploadRequest.Headers.TryAddWithoutValidation("Origin", "https://web.whatsapp.com"); + uploadRequest.Headers.TryAddWithoutValidation("Referer", "https://web.whatsapp.com/"); + + // Diagnostic: write full URL + encrypted bytes to disk so we can reproduce the upload via curl + try { + var diagDir = @"C:\inetpub\whatsappbridge-api\logs"; + System.IO.File.WriteAllText(System.IO.Path.Combine(diagDir, "cdn-upload-debug.txt"), + $"URL: {mediaUrl}\nEncBytes: {enc.EncryptedBytes.Length}\nContentType: application/octet-stream\nToken: {encSha256B64.Replace('+','-').Replace('/','_').TrimEnd('=')}\nPlain b64: {encSha256B64}\n"); + System.IO.File.WriteAllBytes(System.IO.Path.Combine(diagDir, "cdn-upload-bytes.bin"), enc.EncryptedBytes); + } catch { } + _logger.LogInformation("Uploading {Bytes} bytes ({Mime}) to CDN: {Url}", enc.EncryptedBytes.Length, mimeType, mediaUrl); + var uploadResp = await _http.SendAsync(uploadRequest, ct); + var respBody = await uploadResp.Content.ReadAsStringAsync(ct); + _logger.LogInformation("CDN upload response: {Status} body={Body}", uploadResp.StatusCode, respBody); + + // Diagnostic: capture CDN response to disk for offline analysis + try { + var diagDir = @"C:\inetpub\whatsappbridge-api\logs"; + var respHeaders = string.Join("\n", uploadResp.Headers.Select(h => $"{h.Key}: {string.Join(", ", h.Value)}")) + + "\n" + string.Join("\n", uploadResp.Content.Headers.Select(h => $"{h.Key}: {string.Join(", ", h.Value)}")); + System.IO.File.WriteAllText(System.IO.Path.Combine(diagDir, "cdn-upload-response.txt"), + $"Status: {(int)uploadResp.StatusCode} {uploadResp.StatusCode}\nTimestamp: {DateTime.UtcNow:u}\n--- Response Headers ---\n{respHeaders}\n--- Response Body ---\n{respBody}\n"); + } catch { } + + if (!uploadResp.IsSuccessStatusCode) + throw new InvalidOperationException($"CDN upload failed: {uploadResp.StatusCode} - {respBody[..Math.Min(200, respBody.Length)]}"); + + // Parse CDN JSON response for final url + directPath + var cdnJson = respBody; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(cdnJson); + var root = doc.RootElement; + if (root.TryGetProperty("url", out var urlProp) && urlProp.GetString() is { Length: > 0 } finalUrl) + mediaUrl = finalUrl; + if (root.TryGetProperty("direct_path", out var dpProp) && dpProp.GetString() is { Length: > 0 } finalDp) + directPath = finalDp; + } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse CDN response JSON"); } + _logger.LogInformation("Media uploaded: url={Url} directPath={DP}", mediaUrl, directPath); + + // 4. Build media proto node + WAMessage mediaMsg; + switch (mediaType) + { + case "image": + mediaMsg = new WAMessage + { + ImageMessage = new Dawa.Proto.ImageMessage + { + Url = mediaUrl, + DirectPath = directPath, + MimeType = mimeType, + Caption = caption, + FileSha256 = enc.FileSha256, + FileEncSha256 = enc.FileEncSha256, + FileLength = (ulong)enc.FileLength, + MediaKey = enc.MediaKey, + MediaKeyTimestamp = enc.MediaKeyTimestamp, + } + }; + break; + case "audio": + mediaMsg = new WAMessage + { + AudioMessage = new Dawa.Proto.AudioMessage + { + Url = mediaUrl, + DirectPath = directPath, + MimeType = mimeType, + FileSha256 = enc.FileSha256, + FileEncSha256 = enc.FileEncSha256, + FileLength = (ulong)enc.FileLength, + MediaKey = enc.MediaKey, + MediaKeyTimestamp = enc.MediaKeyTimestamp, + Ptt = mimeType.Contains("ogg"), + } + }; + break; + default: // document + mediaMsg = new WAMessage + { + DocumentMessage = new Dawa.Proto.DocumentMessage + { + Url = mediaUrl, + DirectPath = directPath, + MimeType = mimeType, + Title = string.IsNullOrEmpty(caption) ? fileName : caption, + FileName = fileName, + FileSha256 = enc.FileSha256, + FileEncSha256 = enc.FileEncSha256, + FileLength = (ulong)enc.FileLength, + MediaKey = enc.MediaKey, + MediaKeyTimestamp = enc.MediaKeyTimestamp, + } + }; + break; + } + + // 5. Get device lists (same as text/reaction) + List recipientDeviceJids; + try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get device list for {Phone}, using fallback", phoneNumber); + recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"]; + } + + var senderDeviceJids = new List(); + var myJid = _auth.Me?.Id; + if (myJid != null) + { + var myPhone = myJid.Split('@')[0].Split(':')[0]; + try + { + var myDevices = await GetDeviceListAsync(myPhone, ct); + foreach (var d in myDevices) + { + if (d == myJid) continue; + senderDeviceJids.Add(d); + } + } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to get sender device list for media"); } + } + + var allDeviceJids = new List(recipientDeviceJids); + allDeviceJids.AddRange(senderDeviceJids); + + var needBundles = allDeviceJids.Where(d => !_signalStore.HasSession(d)).ToList(); + if (needBundles.Count > 0) + { + try + { + var bundles = await FetchPreKeyBundlesAsync(needBundles, ct); + foreach (var (deviceJid, bundle) in bundles) + { + try { _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); } + catch (Exception ex2) { _logger.LogWarning(ex2, "Failed to init session for {Jid}", deviceJid); } + } + } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch pre-key bundles for media"); } + } + + // 6. Encrypt for each device + var recipientProto = PadMessage(mediaMsg.ToByteArrayWithMedia()); + byte[]? senderProto = null; + if (senderDeviceJids.Count > 0) + { + var deviceSentMsg = new WAMessage + { + DeviceSentMessage = new Dawa.Proto.DeviceSentMessage + { + DestinationJid = normalizedJid, + Message = mediaMsg, + } + }; + senderProto = PadMessage(deviceSentMsg.ToByteArray()); + } + + var msgId = GenerateMessageId(); + var hasPkMsg = false; + var toNodes = new List(); + + foreach (var deviceJid in recipientDeviceJids) + { + var node = EncryptForDevice(deviceJid, recipientProto, ref hasPkMsg); + if (node != null) toNodes.Add(node); + } + if (senderProto != null) + { + foreach (var deviceJid in senderDeviceJids) + { + var node = EncryptForDevice(deviceJid, senderProto, ref hasPkMsg); + if (node != null) toNodes.Add(node); + } + } + + if (toNodes.Count == 0) + { + var reason = $"No devices encrypted for media to {normalizedJid} — media NOT sent (stale/missing Signal session or pre-key bundle fetch failed)."; + _logger.LogError(reason); + throw new InvalidOperationException(reason); + } + + // 7. Build and send message node + var participantsNode = new BinaryNode("participants") { Content = toNodes }; + var contentNodes = new List { participantsNode }; + + if (hasPkMsg && _auth.Account != null) + { + var accountProto = ADVSignedDeviceIdentity.ParseFrom(_auth.Account); + contentNodes.Add(new BinaryNode("device-identity") { Content = accountProto.ToByteArray() }); + } + + var msgNode = new BinaryNode("message", new Dictionary + { + ["id"] = msgId, + ["type"] = "media", + ["to"] = normalizedJid, + }) { Content = contentNodes }; + + await SendNodeAsync(msgNode, ct); + _logger.LogInformation("Sent {Type} media to {Jid} ({Bytes} bytes)", mediaType, normalizedJid, fileBytes.Length); + } + + /// + /// Requests a media upload URL from WhatsApp. + /// Tries the Baileys w:m IQ (type=set, media_conn) first. + /// Falls back to direct mmg.whatsapp.net upload URL if the IQ times out + /// (the server silently ignores w:m IQs for companion device sessions). + /// Returns (uploadUrl, directPath, authToken). + /// + private async Task<(string Url, string DirectPath, string Auth)> RequestMediaUploadUrlAsync( + string mediaType, long encryptedSize, string fileEncSha256B64, CancellationToken ct) + { + // --- Attempt 1: Baileys exact pattern (type=set, inner , to=s.whatsapp.net) --- + try + { + var mediaConnNode = new BinaryNode("media_conn", new Dictionary()); + var iq = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["to"] = "s.whatsapp.net", // no @, encodes as dict token — matches Baileys S_WHATSAPP_NET + ["xmlns"] = "w:m", + ["type"] = "set", + }) { Content = new List { mediaConnNode } }; + + _logger.LogInformation("Requesting media_conn via w:m set IQ (Baileys pattern)"); + var result = await SendIQAsync(iq, ct, timeoutMs: 8000); + + // Response: + var mcNode = result.FindChild("media_conn") ?? FindDeep(result, "media_conn"); + if (mcNode != null) + { + var auth = mcNode.GetAttr("auth") ?? ""; + // Pick first host that has an child; fall back to first host + var uploadHost = mcNode.Children.FirstOrDefault(h => h.Tag == "host" && h.Children.Any(c => c.Tag == "upload")) + ?? mcNode.Children.FirstOrDefault(h => h.Tag == "host"); + var hostName = uploadHost?.GetAttr("hostname") ?? "mmg.whatsapp.net"; + // Token: base64url (RFC 4648) — Baileys uses: replace +→- /→_ strip =, then encodeURIComponent. + // Result is already URL-safe so encodeURIComponent is a no-op for these chars. + var tokenB64 = Uri.EscapeDataString(fileEncSha256B64.Replace('+', '-').Replace('/', '_').TrimEnd('=')); + // Baileys URL: https://{host}/mms/{type}/{token}?auth={auth}&token={token} + // The token appears both in the URL PATH and as a query param. + var uploadUrl = $"https://{hostName}/mms/{mediaType}/{tokenB64}?auth={Uri.EscapeDataString(auth)}&token={tokenB64}"; + _logger.LogInformation("Got media_conn auth, upload to {Host}", hostName); + return (uploadUrl, "", auth); + } + + // IQ returned something but no media_conn child — log and fall through + _logger.LogWarning("w:m IQ result had no media_conn child, falling back to direct upload"); + } + catch (TimeoutException) + { + _logger.LogWarning("w:m IQ timed out (server ignores companion device w:m IQs) — using direct mmg upload"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "w:m IQ failed — using direct mmg upload"); + } + + // --- Fallback: direct upload to mmg.whatsapp.net without auth token --- + // Some companion device implementations upload directly using the encrypted hash as token. + // URL format used by open-source WA clients when no media_conn auth is available. + var hashB64Url = Uri.EscapeDataString(fileEncSha256B64.Replace('+', '-').Replace('/', '_').TrimEnd('=')); + var directUploadUrl = $"https://mmg.whatsapp.net/mms/{mediaType}/{hashB64Url}?hash={hashB64Url}&type={mediaType}&v=4"; + _logger.LogInformation("Using direct mmg upload URL for {Type}", mediaType); + return (directUploadUrl, "", ""); + } + + /// Pads a proto-encoded message with random PKCS7-style padding (Baileys: writeRandomPadMax16). + private static byte[] PadMessage(byte[] msgRaw) + { + var padLen = (byte)(System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 16) + 1); // 1-16 + var padded = new byte[msgRaw.Length + padLen]; + msgRaw.CopyTo(padded, 0); + Array.Fill(padded, padLen, msgRaw.Length, padLen); + return padded; + } + + /// Encrypts a padded proto for a single device, returning a 'to' node or null on failure. + private BinaryNode? EncryptForDevice(string deviceJid, byte[] paddedProto, ref bool hasPkMsg) + { + if (!_signalStore.HasSession(deviceJid)) + { + _logger.LogWarning("No session for {Jid} after bundle fetch, skipping", deviceJid); + return null; + } + try + { + var (encBytes, isPreKey) = _signalStore.EncryptMessage(deviceJid, paddedProto, _auth); + if (isPreKey) hasPkMsg = true; + var encNode = new BinaryNode("enc", new Dictionary + { + ["v"] = "2", + ["type"] = isPreKey ? "pkmsg" : "msg", + }) { Content = encBytes }; + + return new BinaryNode("to", new Dictionary + { + ["jid"] = deviceJid, + }) { Content = new List { encNode } }; + } + catch (Exception ex) + { + _logger.LogWarning("Failed to encrypt for {Jid}: {Error}", deviceJid, ex.Message); + return null; + } + } + + // ─── IQ helper ────────────────────────────────────────────────────────── + + private async Task SendIQAsync(BinaryNode iq, CancellationToken ct, int timeoutMs = 15000) + { + var id = iq.GetAttr("id") ?? GenerateMessageId(); + if (!iq.Attrs.ContainsKey("id")) iq.Attrs["id"] = id; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingIqs[id] = tcs; + + _logger.LogInformation("Sending IQ id={Id} xmlns={Xmlns} type={Type}, pending count={Count}", + id, iq.GetAttr("xmlns") ?? "?", iq.GetAttr("type") ?? "?", _pendingIqs.Count); + + await SendNodeAsync(iq, ct); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(timeoutMs); + + try + { + return await tcs.Task.WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + _pendingIqs.Remove(id); + throw new TimeoutException($"IQ {id} timed out after {timeoutMs}ms"); + } + } + + // ─── Fetch pre-key bundles ─────────────────────────────────────────────── + + /// + /// Sends an encrypt IQ to fetch pre-key bundles for a list of device JIDs. + /// Returns a dictionary keyed by device JID. + /// + private async Task> FetchPreKeyBundlesAsync( + IEnumerable deviceJids, CancellationToken ct) + { + var userNodes = deviceJids.Select(d => new BinaryNode("user", new Dictionary + { + ["jid"] = d, + })).ToList(); + + var keyNode = new BinaryNode("key") { Content = userNodes }; + var iqId = GenerateMessageId(); + var iq = new BinaryNode("iq", new Dictionary + { + ["xmlns"] = "encrypt", + ["type"] = "get", + ["to"] = "@s.whatsapp.net", + ["id"] = iqId, + }) { Content = new List { keyNode } }; + + BinaryNode response; + try { response = await SendIQAsync(iq, ct, timeoutMs: 30000); } + catch (Exception ex) + { + _logger.LogWarning(ex, "FetchPreKeyBundles IQ failed"); + return new Dictionary(); + } + + var result = new Dictionary(); + var listNode = response.FindChild("list") ?? response; + + foreach (var userNode in listNode.GetChildren("user")) + { + var userJid = userNode.GetAttr("jid") ?? ""; + if (string.IsNullOrEmpty(userJid)) continue; + + try + { + var bundle = ParsePreKeyBundle(userNode); + result[userJid] = bundle; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse pre-key bundle for {Jid}", userJid); + } + } + + return result; + } + + private static PreKeyBundle ParsePreKeyBundle(BinaryNode userNode) + { + var regNode = userNode.FindChild("registration"); + var typeNode = userNode.FindChild("type"); + var idNode = userNode.FindChild("identity"); + var skeyNode = userNode.FindChild("skey"); + var otpkNode = userNode.FindChild("key"); + + var regBytes = regNode?.Data ?? []; + uint regId = regBytes.Length >= 4 + ? (uint)((regBytes[0] << 24) | (regBytes[1] << 16) | (regBytes[2] << 8) | regBytes[3]) + : 0; + + var identityKey = idNode?.Data ?? []; + + byte[] spkPub = []; + uint spkId = 0; + byte[] spkSig = []; + if (skeyNode != null) + { + var skeyIdBytes = skeyNode.FindChild("id")?.Data ?? []; + spkId = skeyIdBytes.Length >= 3 + ? (uint)((skeyIdBytes[0] << 16) | (skeyIdBytes[1] << 8) | skeyIdBytes[2]) + : 0; + spkPub = skeyNode.FindChild("value")?.Data ?? []; + spkSig = skeyNode.FindChild("signature")?.Data ?? []; + } + + byte[]? otpkPub = null; + uint otpkId = 0; + if (otpkNode != null) + { + var otpkIdBytes = otpkNode.FindChild("id")?.Data ?? []; + otpkId = otpkIdBytes.Length >= 3 + ? (uint)((otpkIdBytes[0] << 16) | (otpkIdBytes[1] << 8) | otpkIdBytes[2]) + : 0; + otpkPub = otpkNode.FindChild("value")?.Data; + } + + return new PreKeyBundle + { + TheirIdentityPub = identityKey, + TheirSignedPreKeyPub = spkPub, + TheirSignedPreKeyId = spkId, + TheirSignedPreKeySig = spkSig, + TheirOneTimePreKeyPub = otpkPub, + TheirOneTimePreKeyId = otpkId, + PeerRegistrationId = regId, + }; + } + + // ─── Get device list (USync) ───────────────────────────────────────────── + + /// + /// Sends a USync IQ to get all device JIDs for a phone number. + /// Returns list of device JIDs like "31633984381:0@s.whatsapp.net". + /// + private async Task> GetDeviceListAsync(string phoneNumber, CancellationToken ct) + { + var jid = $"{phoneNumber.TrimStart('+')}@s.whatsapp.net"; + var sid = GenerateMessageId(); + var iqId = GenerateMessageId(); + + var devicesNode = new BinaryNode("devices", new Dictionary { ["version"] = "2" }); + var queryNode = new BinaryNode("query") { Content = new List { devicesNode } }; + + var userNode = new BinaryNode("user", new Dictionary { ["jid"] = jid }); + var listNode = new BinaryNode("list") { Content = new List { userNode } }; + + var usyncNode = new BinaryNode("usync", new Dictionary + { + ["context"] = "message", + ["mode"] = "query", + ["last"] = "true", + ["index"] = "0", + ["sid"] = sid, + }) { Content = new List { queryNode, listNode } }; + + var iq = new BinaryNode("iq", new Dictionary + { + ["to"] = "@s.whatsapp.net", + ["type"] = "get", + ["xmlns"] = "usync", + ["id"] = iqId, + }) { Content = new List { usyncNode } }; + + BinaryNode response; + try { response = await SendIQAsync(iq, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, "GetDeviceList IQ failed for {Phone}", phoneNumber); + return [$"{phoneNumber}:0@s.whatsapp.net"]; + } + + var deviceJids = new List(); + WalkForDevices(response, phoneNumber, deviceJids); + + if (deviceJids.Count == 0) + deviceJids.Add($"{phoneNumber}:0@s.whatsapp.net"); + + return deviceJids; + } + + private static void WalkForDevices(BinaryNode node, string phoneNumber, List result) + { + if (node.Tag == "device") + { + // USync response has two formats: + // 1. + // 2. (just the device ID, no full JID) + var jid = node.GetAttr("jid"); + if (!string.IsNullOrEmpty(jid)) + { + result.Add(jid); + return; + } + var id = node.GetAttr("id"); + if (id != null) + { + // Device 0 is the primary phone — Baileys encodes as "user@server" (no :0) + // Other devices use "user:device@server" + if (id == "0") + result.Add($"{phoneNumber}@s.whatsapp.net"); + else + result.Add($"{phoneNumber}:{id}@s.whatsapp.net"); + return; + } + } + foreach (var child in node.Children) + WalkForDevices(child, phoneNumber, result); + } + + // ─── Low-level send/receive ───────────────────────────────────────────── + + private async Task SendNodeAsync(BinaryNode node, CancellationToken ct) + { + // Log outbound node so we can compare sent vs received in nodelog/nodes.log + try + { + var logDir = Path.Combine(_options.SessionDirectory, "..", "nodelog"); + Directory.CreateDirectory(logDir); + var logFile = Path.Combine(logDir, "nodes.log"); + File.AppendAllText(logFile, $"[{DateTime.UtcNow:HH:mm:ss}] SEND: {node}\n"); + } + catch { /* non-fatal */ } + + var encoded = BinaryNodeEncoder.Encode(node); + // Prepend flags byte (0x00 = uncompressed) — server strips this on receive + // just as we strip it from server frames in StripFlagsAndDecompress(). + var frameData = new byte[1 + encoded.Length]; + frameData[0] = 0; + encoded.CopyTo(frameData, 1); + var encrypted = EncryptFrame(frameData); + await _socket.SendFrameAsync(encrypted, ct); + } + + private bool _firstHandshakeFrame = true; + + private async Task SendHandshakeMessageAsync(HandshakeMessage msg, CancellationToken ct) + { + var msgBytes = msg.ToByteArray(); + + if (_firstHandshakeFrame) + { + // Baileys wire format for the FIRST frame: + // [WA_PROLOGUE: 4 bytes] [length: 3 bytes BE] [proto payload: N bytes] + // The WA prologue is OUTSIDE the 3-byte length framing (unlike subsequent frames). + var raw = new byte[WA_PROLOGUE.Length + 3 + msgBytes.Length]; + WA_PROLOGUE.CopyTo(raw, 0); + raw[4] = (byte)(msgBytes.Length >> 16); + raw[5] = (byte)(msgBytes.Length >> 8); + raw[6] = (byte)(msgBytes.Length); + msgBytes.CopyTo(raw, 7); + await _socket.SendRawAsync(raw, ct); + _firstHandshakeFrame = false; + } + else + { + // Subsequent frames: standard [3-byte len][payload] + await _socket.SendFrameAsync(msgBytes, ct); + } + } + + private byte[] EncryptFrame(byte[] data) + { + if (!_handshakeDone || _sendKey == null) + throw new InvalidOperationException("Handshake not complete."); + return AesGcmHelper.EncryptWithCounter(_sendKey, _sendCounter++, data); + } + + private byte[] DecryptFrame(byte[] data) + { + if (!_handshakeDone || _recvKey == null) + throw new InvalidOperationException("Handshake not complete."); + return AesGcmHelper.DecryptWithCounter(_recvKey, _recvCounter++, data); + } + + // ─── Helpers ──────────────────────────────────────────────────────────── + + private static byte[] StripFlagsAndDecompress(byte[] decrypted) + { + if (decrypted.Length == 0) return decrypted; + var flags = decrypted[0]; + var data = decrypted[1..]; + + if ((flags & 2) != 0) + { + // WhatsApp uses zlib compression (with 2-byte header), not raw deflate. + // Baileys uses Node.js inflateSync() which handles the zlib wrapper. + // .NET ZLibStream handles the zlib header correctly. + using var input = new MemoryStream(data); + using var zlib = new ZLibStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + zlib.CopyTo(output); + return output.ToArray(); + } + + return data; + } + + // WA web version now comes from WaVersionProvider (live fetch + disk cache + compiled + // fallback) — WhatsApp 405-rejects fresh registrations from outdated versions. + + private byte[] BuildClientPayload() + { + var userAgent = new UserAgent + { + Platform = 14, // WEB + AppVersion = new AppVersion + { + Primary = WaVersionProvider.Primary, + Secondary = WaVersionProvider.Secondary, + Tertiary = WaVersionProvider.Tertiary, + }, + Mcc = "000", + Mnc = "000", + OsVersion = "0.1", + Device = "Desktop", // Baileys getUserAgent always uses "Desktop" + OsBuildNumber = "0.1", + LocaleLanguageIso6391 = "en", + LocaleCountryIso31661Alpha2 = "US", + }; + + if (_auth.IsFresh) + { + // Fresh registration: include device pairing data so server knows our keys + var buildHash = MD5.HashData(System.Text.Encoding.UTF8.GetBytes(WaVersionProvider.VersionString)); + + // Registration ID as 4-byte big-endian + var eRegid = new byte[4]; + eRegid[0] = (byte)(_auth.RegistrationId >> 24); + eRegid[1] = (byte)(_auth.RegistrationId >> 16); + eRegid[2] = (byte)(_auth.RegistrationId >> 8); + eRegid[3] = (byte)(_auth.RegistrationId); + + // Signed pre-key ID as 3-byte big-endian + var eSkeyId = new byte[3]; + eSkeyId[0] = (byte)(_auth.SignedPreKeyId >> 16); + eSkeyId[1] = (byte)(_auth.SignedPreKeyId >> 8); + eSkeyId[2] = (byte)(_auth.SignedPreKeyId); + + var deviceProps = new DevicePropsMessage + { + // Os="Ubuntu", HistorySyncConfig — set by default + PlatformType = 1, // CHROME + Version = new AppVersion + { + Primary = WaVersionProvider.Primary, + Secondary = WaVersionProvider.Secondary, + Tertiary = WaVersionProvider.Tertiary, + }, + }.ToByteArray(); + + return new ClientPayload + { + Passive = false, + Pull = false, + ConnectType = 1, // WIFI_UNKNOWN + ConnectReason = 1, // USER_ACTIVATED + UserAgent = userAgent, + WebInfo = new WebInfo { WebSubPlatform = 0 }, + DevicePairingData = new DevicePairingRegistrationData { ERegid = eRegid, EKeytype = [5], // KEY_BUNDLE_TYPE @@ -484,34 +2430,1973 @@ private byte[] BuildClientPayload() BuildHash = buildHash, DeviceProps = deviceProps, }, - }.ToByteArray(); + }.ToByteArray(); + } + else + { + // Session restore (login) + // Me.Id format: "31633984381:20@s.whatsapp.net" — extract phone and device number + var rawId = _auth.Me?.Id.Split('@')[0] ?? "0"; // "31633984381:20" + var parts = rawId.Split(':'); + ulong.TryParse(parts[0], out var userId); // "31633984381" → 31633984381 + uint.TryParse(parts.Length > 1 ? parts[1] : "0", out var deviceId); // "20" → 20 + return new ClientPayload + { + Username = userId, + Device = deviceId, // CRITICAL: server needs device number to route the session + Passive = true, + Pull = true, + ConnectType = 1, + ConnectReason = 1, + UserAgent = userAgent, + WebInfo = new WebInfo { WebSubPlatform = 0 }, + }.ToByteArray(); + } + } + + /// + /// Uploads one-time pre-keys and the signed pre-key to WhatsApp server + /// so other devices can initiate Signal sessions with us. + /// Called once after successful authentication. + /// + private async Task UploadPreKeysAsync(CancellationToken ct) + { + const int BatchSize = 30; // upload 30 one-time pre-keys at a time + + // Generate fresh pre-keys if we've run out — private keys must be available for decryption + if (_auth.PreKeys.Count == 0) + { + _logger.LogWarning("Pre-key pool exhausted — generating 100 new pre-keys."); + // Start IDs from a high offset to avoid collisions with any still-live server-side keys + uint startId = 1001; + for (uint i = 0; i < 100; i++) + { + var (priv, pub) = Crypto.Curve25519Helper.GenerateKeyPair(); + _auth.PreKeys.Add(new Auth.PreKey { Id = startId + i, Private = priv, Public = pub }); + } + } + + var keysToUpload = _auth.PreKeys.Take(BatchSize).ToList(); + + if (keysToUpload.Count == 0) + { + _logger.LogWarning("No pre-keys available to upload — others cannot initiate Signal sessions."); + return; + } + + // Baileys format: raw 32-byte keys (no 0x05 prefix) — the "type=[0x05]" node indicates key type + static byte[] Be3(uint v) => [(byte)(v >> 16), (byte)(v >> 8), (byte)v]; + static byte[] Be4(uint v) => [(byte)(v >> 24), (byte)(v >> 16), (byte)(v >> 8), (byte)v]; + + // Build key list nodes — value = raw 32-byte public key (no prefix) + var keyNodes = keysToUpload.Select(k => + new BinaryNode("key", null, new List + { + new("id", null, Be3(k.Id)), + new("value", null, k.Public), // raw 32 bytes + }) + ).ToList(); + + // Signed pre-key node + var skeyNode = new BinaryNode("skey", null, new List + { + new("id", null, Be3(_auth.SignedPreKeyId)), + new("value", null, _auth.SignedPreKeyPublic), // raw 32 bytes + new("signature", null, _auth.SignedPreKeySignature), + }); + + var content = new List + { + new("registration", null, Be4(_auth.RegistrationId)), + new("type", null, new byte[] { 5 }), // KEY_BUNDLE_TYPE — indicates Curve25519 + new("identity", null, _auth.SignedIdentityKeyPublic), // raw 32 bytes + new("list", null, keyNodes), + skeyNode, + }; + + var iqNode = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["xmlns"] = "encrypt", + ["type"] = "set", + ["to"] = "@s.whatsapp.net", + }) + { + Content = content, + }; + + try + { + var result = await SendIQAsync(iqNode, ct); + _logger.LogInformation("Pre-keys uploaded successfully ({Count} one-time keys).", keysToUpload.Count); + + // NOTE: Do NOT remove uploaded pre-keys from the local pool. + // Their private keys must remain in _auth.PreKeys so that InitIncomingSession + // can look them up by ID when a pkmsg arrives. The keys are removed there, + // after the session is actually established and the private key consumed. + // Persist updated auth state in case we generated new pre-keys above. + Authenticated?.Invoke(this, _auth); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload pre-keys."); + } + } + + private static string GenerateMessageId() + { + var bytes = RandomNumberGenerator.GetBytes(8); + return BitConverter.ToString(bytes).Replace("-", "").ToUpper(); + } + + // ─── App state sync: contact fetching ─────────────────────────────────── + + /// + /// Fetches a 32-byte app state key from the server by keyId. + /// + private async Task FetchAppStateSyncKeyAsync(byte[] keyId, CancellationToken ct) + { + var iq = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["to"] = "@s.whatsapp.net", + ["type"] = "get", + ["xmlns"] = "w:sync:app:state:k", + }) + { + Content = new List + { + new BinaryNode("key", null, new List + { + new BinaryNode("id", null, keyId), + }), + }, + }; + + var result = await SendIQAsync(iq, ct, timeoutMs: 15000); + + // Navigate result → key → key-data + var keyNode = result.FindChild("key") ?? result; + var keyDataNode = keyNode.FindChild("key-data"); + if (keyDataNode?.Data is { Length: 32 } appKey) + return appKey; + + // Fallback: walk children for a 32-byte data blob + foreach (var child in result.Children) + { + if (child.Data is { Length: 32 } d) return d; + foreach (var grandchild in child.Children) + if (grandchild.Data is { Length: 32 } d2) return d2; + } + + throw new InvalidOperationException($"App state sync key response did not contain a 32-byte key (tag={result.Tag})"); + } + + /// + /// Fetches contacts from WhatsApp via app state sync of the "contact" collection. + /// Returns (JID, Name) pairs for all contacts found in the snapshot. + /// + private BinaryNode BuildAppStateSyncIQ(string collectionName, int version, bool returnSnapshot) + { + var attrs = new Dictionary + { + ["name"] = collectionName, + ["version"] = version.ToString(), + }; + if (returnSnapshot) attrs["return_snapshot"] = "true"; + + return new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["to"] = "@s.whatsapp.net", + ["type"] = "set", + ["xmlns"] = "w:app:state:sync", + }) + { + Content = new List + { + new BinaryNode("sync", null, new List + { + new BinaryNode("collection", attrs), + }), + }, + }; + } + + /// + /// Returns contacts from multiple sources: + /// Push names collected from incoming messages (participant_pn, sender_lid, notify attributes). + /// USync contact queries consistently return empty results for companion devices. + /// + public Task> FetchContactsAsync(CancellationToken ct) + { + _logger.LogInformation("FetchContactsAsync: starting (pushNames={Names}, lidToPhone={Lids})", + _pushNames.Count, _lidToPhone.Count); + + var contacts = new List<(string Jid, string Name)>(); + var seenJids = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var kv in _pushNames) + { + // Resolve LID JIDs to phone JIDs using the lidToPhone mapping + var jid = kv.Key.EndsWith("@lid") && _lidToPhone.TryGetValue(kv.Key, out var ph) ? ph : kv.Key; + // Only include contacts with resolved phone JIDs (skip unresolvable LIDs and non-contacts) + if (jid.EndsWith("@s.whatsapp.net") && seenJids.Add(jid) && !string.IsNullOrEmpty(kv.Value)) + contacts.Add((jid, kv.Value)); + } + + _logger.LogInformation("FetchContactsAsync: returning {Count} contacts", contacts.Count); + return Task.FromResult(contacts); + } + + /// + /// Uses USync IQ (which reliably returns results for companion devices) to fetch + /// contact names for a set of JIDs. Handles both @lid and @s.whatsapp.net JIDs. + /// + private async Task> FetchContactsViaUsyncAsync( + IEnumerable jids, CancellationToken ct) + { + var jidList = jids.ToList(); + if (jidList.Count == 0) return []; + + // Build user nodes: phone JIDs use jid= attribute, LID JIDs use a child node + var userNodes = jidList.Select(j => + { + if (j.EndsWith("@lid")) + { + // LID JIDs must be sent as ... + return new BinaryNode("user") + { + Content = new List + { + new BinaryNode("lid") { Content = j }, + } + }; + } + // Phone JIDs: + return new BinaryNode("user", new Dictionary { ["jid"] = j }); + }).ToList(); + + var usyncNode = new BinaryNode("usync", new Dictionary + { + ["context"] = "interactive", + ["mode"] = "query", + ["last"] = "true", + ["index"] = "0", + ["sid"] = GenerateMessageId(), + }) + { + Content = new List + { + new BinaryNode("query") { Content = new List { new BinaryNode("contact") } }, + new BinaryNode("list") { Content = userNodes }, + }, + }; + + var iq = new BinaryNode("iq", new Dictionary + { + ["to"] = "@s.whatsapp.net", + ["type"] = "get", + ["xmlns"] = "usync", + ["id"] = GenerateMessageId(), + }) { Content = new List { usyncNode } }; + + BinaryNode result; + try { result = await SendIQAsync(iq, ct, timeoutMs: 15000); } + catch (Exception ex) + { + _logger.LogWarning(ex, "FetchContactsViaUsyncAsync: IQ failed"); + return []; + } + + // Parse response: name... + var contacts = new List<(string Jid, string Name)>(); + WalkUsyncForContacts(result, contacts); + _logger.LogInformation("FetchContactsViaUsyncAsync: parsed {Count} contacts from USync response", contacts.Count); + return contacts; + } + + private void WalkUsyncForContacts(BinaryNode node, List<(string Jid, string Name)> result) + { + if (node.Tag == "user") + { + var jid = node.GetAttr("jid") ?? ""; + var lid = node.GetAttr("lid") ?? ""; + + // Cache LID → phone JID mapping from USync response + if (!string.IsNullOrEmpty(lid) && !string.IsNullOrEmpty(jid)) + _lidToPhone[lid] = jid; + + // Look for child with push name or status + var contactNode = node.FindChild("contact"); + var name = contactNode?.GetAttr("name") + ?? contactNode?.Text + ?? node.GetAttr("name"); + + if (!string.IsNullOrEmpty(jid)) + { + // Use phone JID, not LID + var displayJid = jid.EndsWith("@lid") && _lidToPhone.TryGetValue(jid, out var ph) ? ph : jid; + var displayName = name ?? (displayJid.Contains('@') ? displayJid.Split('@')[0] : displayJid); + result.Add((displayJid, displayName)); + } + return; + } + foreach (var child in node.Children) + WalkUsyncForContacts(child, result); + } + + // ─── Presence ────────────────────────────────────────────────────────── + + private readonly System.Collections.Concurrent.ConcurrentDictionary _presenceCache = new(); + + private void HandlePresenceNode(BinaryNode node) + { + var jid = node.GetAttr("from") ?? ""; + var type = node.GetAttr("type") ?? "available"; + var status = type == "unavailable" ? "unavailable" : "available"; + + var composing = node.FindChild("composing"); + var recording = node.FindChild("recording"); + if (composing != null) status = "composing"; + if (recording != null) status = "recording"; + + _presenceCache[jid] = new PresenceInfo(jid, status, DateTime.UtcNow); + _logger.LogDebug("Presence: {Jid} → {Status}", jid, status); + } + + public async Task SubscribePresenceAsync(string jid, CancellationToken ct) + { + var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net"; + var node = new BinaryNode("presence", new Dictionary + { + ["type"] = "subscribe", + ["to"] = normalizedJid, + }); + await SendNodeAsync(node, ct); + } + + public PresenceInfo? GetPresence(string jid) + => _presenceCache.TryGetValue(jid, out var p) ? p : null; + + // ─── Profile picture ──────────────────────────────────────────────────── + + public async Task FetchProfilePictureAsync(string jid, CancellationToken ct) + { + var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net"; + var iq = new BinaryNode("iq", new Dictionary + { + ["to"] = normalizedJid, + ["type"] = "get", + ["xmlns"] = "w:profile:pic", + ["id"] = GenerateMessageId(), + }) + { + Content = new List + { + // "query=url" tells WhatsApp to return the CDN URL instead of the raw image + new BinaryNode("picture", new Dictionary { ["type"] = "image", ["query"] = "url" }) + } + }; + + try + { + var result = await SendIQAsync(iq, ct, timeoutMs: 15000); + _logger.LogInformation("Profile pic response: tag={Tag} type={Type}", result.Tag, result.GetAttr("type") ?? "?"); + // Result may have a child, or a direct url attr + var picNode = result.FindChild("picture"); + var url = picNode?.GetAttr("url") ?? result.GetAttr("url"); + _logger.LogInformation("Profile pic url={Url}", url ?? "(none)"); + return url; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch profile picture for {Jid}", jid); + return null; + } + } + + // ─── Read receipts ────────────────────────────────────────────────────── + + public async Task SendReadReceiptAsync(string jid, string messageId, long timestamp, CancellationToken ct) + { + var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net"; + var receipt = new BinaryNode("receipt", new Dictionary + { + ["id"] = messageId, + ["to"] = normalizedJid, + ["type"] = "read", + ["t"] = timestamp.ToString(), + }); + await SendNodeAsync(receipt, ct); + _logger.LogInformation("Sent read receipt for message {MsgId} to {Jid}", messageId, normalizedJid); + } + + // ─── Chats (from thread_metadata + message history) ───────────────────── + + /// + /// Returns the list of active chats. Primary source is thread_metadata from + /// the ib node that WhatsApp sends immediately after authentication. + /// Falls back to message history if thread_metadata is empty. + /// + public Task> FetchChatsAsync(CancellationToken ct) + { + var chats = new List<(string Jid, string Name, bool Archived, bool Pinned)>(); + + // Track seen JIDs to deduplicate: a chat may appear as both LID and phone JID in thread_metadata + var seenJids = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Use thread_metadata as primary source (populated immediately at startup) + if (_threadMetadata.Count > 0) + { + _logger.LogInformation("FetchChatsAsync: returning {Count} chats from thread_metadata", _threadMetadata.Count); + + foreach (var kv in _threadMetadata.OrderByDescending(x => x.Value)) + { + var rawJid = kv.Key; + + // Resolve LID to phone JID if possible + string jid; + if (rawJid.EndsWith("@lid") && _lidToPhone.TryGetValue(rawJid, out var resolvedJid)) + jid = resolvedJid; + else + jid = rawJid; + + // Deduplicate: skip if we already emitted this resolved JID + if (!seenJids.Add(jid)) continue; + + // Get display name from push names, then JID + string name; + if (_pushNames.TryGetValue(rawJid, out var pn) || _pushNames.TryGetValue(jid, out pn)) + name = pn; + else + name = jid.Split('@')[0].Split(':')[0]; + + chats.Add((jid, name, false, false)); + } + } + else + { + // Fallback: build from push names cache (populated by received messages) + _logger.LogInformation("FetchChatsAsync: no thread_metadata, using pushNames cache ({Count} entries)", _pushNames.Count); + foreach (var kv in _pushNames) + { + var jid = kv.Key.EndsWith("@lid") && _lidToPhone.TryGetValue(kv.Key, out var ph) ? ph : kv.Key; + if (seenJids.Add(jid)) + chats.Add((jid, kv.Value, false, false)); + } + } + + _logger.LogInformation("FetchChatsAsync: returning {Count} chats", chats.Count); + return Task.FromResult(chats); + } + + /// Returns internal cache state for debugging. + public object GetCacheDebugInfo() => new + { + threadMetadataCount = _threadMetadata.Count, + threadMetadata = _threadMetadata.OrderByDescending(x => x.Value) + .Take(20) + .Select(kv => new { jid = kv.Key, t = kv.Value }) + .ToList(), + lidToPhoneCount = _lidToPhone.Count, + lidToPhone = _lidToPhone.Take(10) + .Select(kv => new { lid = kv.Key, phone = kv.Value }) + .ToList(), + pushNamesCount = _pushNames.Count, + pushNames = _pushNames.Take(10) + .Select(kv => new { jid = kv.Key, name = kv.Value }) + .ToList(), + serverSyncVersions = _serverSyncVersions.ToDictionary(kv => kv.Key, kv => kv.Value), + }; + + // ─── Message history ───────────────────────────────────────────────────── + + /// + /// Attempts to fetch message history for a JID using a w:msg sync IQ. + /// Returns an empty list if the server does not respond (companion devices may not support this). + /// Note: in-memory message cache is used as primary source; this IQ is a best-effort supplement. + /// + /// + /// Requests an ON_DEMAND history sync from the phone by sending a + /// peerDataOperationRequestMessage to our own JID. + /// The phone responds by pushing a HISTORY_SYNC_NOTIFICATION (syncType 5 = ON_DEMAND) + /// which our receive loop handles, firing HistoryMessageReceived for each past message. + /// Returns the messages collected from that push notification (or empty if the phone + /// does not respond within the timeout). + /// + public async Task> RequestOnDemandHistorySyncAsync(string chatJid, int count, CancellationToken ct) + { + var normalizedJid = chatJid.Contains('@') ? chatJid : $"{chatJid}@s.whatsapp.net"; + + var myJid = _auth.Me?.Id; + if (myJid == null) + { + // Fail-safe: never throw out of on-demand history sync. It is an optional, + // best-effort request; if we cannot make it, the caller simply gets no extra + // history and all normal paths (automatic history sync, offline queue, live + // delivery) keep working untouched. + _logger.LogWarning("OnDemandHistorySync: not authenticated — skipping (normal delivery unaffected)"); + return new List(); + } + + // If given a LID, resolve to phone JID for the peerDataOperationRequestMessage + // (phone stores chats by phone JID internally, not LID) + string requestJid = normalizedJid; + if (normalizedJid.EndsWith("@lid")) + { + // Try to resolve LID → phone JID via our cache + if (_lidToPhone.TryGetValue(normalizedJid, out var phoneJid)) + { + _logger.LogInformation("OnDemandHistorySync: resolved LID {Lid} → {Phone} for request", normalizedJid, phoneJid); + requestJid = phoneJid; + } + else + { + // Strip LID number and try as phone number + var lidNum = normalizedJid.Split('@')[0]; + _logger.LogWarning("OnDemandHistorySync: no phone mapping for LID {Lid} — will try phone JID too", normalizedJid); + } + } + + // Collect messages for the target JID as they arrive (match both phone and LID variants) + var collected = new List(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + EventHandler onMsg = (_, msg) => + { + // Exception-safe: a malformed history message must never throw into the + // HistoryMessageReceived event dispatch (which also feeds normal history sync). + try + { + var remoteBase = normalizedJid.Split('@')[0]; + var requestBase = requestJid.Split('@')[0]; + if (msg.RemoteJid == normalizedJid || + msg.RemoteJid == requestJid || + msg.From == normalizedJid || + msg.From == requestJid || + (msg.RemoteJid?.Split('@')[0] == remoteBase) || + (msg.RemoteJid?.Split('@')[0] == requestBase)) + { + lock (collected) { collected.Add(msg); } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OnDemandHistorySync: collector handler error (ignored)"); + } + }; + + EventHandler onComplete = (_, n) => + { + _logger.LogInformation("OnDemandHistorySync: HistorySyncCompleted fired ({N} msgs)", n); + tcs.TrySetResult(n); + }; + + HistoryMessageReceived += onMsg; + HistorySyncCompleted += onComplete; + + try + { + _logger.LogInformation("OnDemandHistorySync: sending peerDataOperationRequestMessage for {ChatJid} (requestJid={ReqJid})", normalizedJid, requestJid); + await SendPeerDataOperationRequestAsync(myJid, requestJid, count, ct); + // If LID and request JID differ, also try with the original LID (phone may accept either) + if (requestJid != normalizedJid) + { + await Task.Delay(1000, ct); + await SendPeerDataOperationRequestAsync(myJid, normalizedJid, count, ct); + } + + // Wait up to 35 seconds for the phone to push the history sync notification + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + using (linked.Token.Register(() => tcs.TrySetCanceled())) + await tcs.Task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _logger.LogInformation("OnDemandHistorySync: timed out / cancelled waiting for push notification"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OnDemandHistorySync: error"); + } + finally + { + HistoryMessageReceived -= onMsg; + HistorySyncCompleted -= onComplete; + } + + lock (collected) { return [.. collected]; } + } + + private async Task SendPeerDataOperationRequestAsync(string myJid, string targetChatJid, int count, CancellationToken ct) + { + var myPhone = myJid.Split('@')[0].Split(':')[0]; + + // Get all our own devices + List myDevices; + try { myDevices = await GetDeviceListAsync(myPhone, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, "OnDemandHistorySync: failed to get own device list"); + myDevices = [$"{myPhone}:0@s.whatsapp.net"]; + } + + _logger.LogInformation("OnDemandHistorySync: encrypting for {Count} own devices: {Jids}", + myDevices.Count, string.Join(", ", myDevices)); + + // Fetch pre-key bundles for devices without sessions + var needBundles = myDevices.Where(d => !_signalStore.HasSession(d)).ToList(); + if (needBundles.Count > 0) + { + try + { + var bundles = await FetchPreKeyBundlesAsync(needBundles, ct); + foreach (var (deviceJid, bundle) in bundles) + try { _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); } + catch (Exception ex2) { _logger.LogWarning(ex2, "OnDemandHistorySync: session init failed for {Jid}", deviceJid); } + } + catch (Exception ex) { _logger.LogWarning(ex, "OnDemandHistorySync: failed to fetch bundles"); } + } + + // Build the PeerDataOperationRequestMessage proto + var msg = new WAMessage + { + PeerDataOperation = new Proto.PeerDataOperationRequestMessage + { + RequestType = Proto.PeerDataOperationRequestMessage.TYPE_HISTORY_SYNC_ON_DEMAND, + ChatJid = targetChatJid, + OnDemandMsgCount = count, + } + }; + var protoBytes = PadMessage(msg.ToByteArrayWithPeerDataOperation()); + + // Encrypt for each of our own devices + var msgId = GenerateMessageId(); + var hasPkMsg = false; + var toNodes = new List(); + foreach (var deviceJid in myDevices) + { + var node = EncryptForDevice(deviceJid, protoBytes, ref hasPkMsg); + if (node != null) toNodes.Add(node); + } + + if (toNodes.Count == 0) + { + _logger.LogWarning("OnDemandHistorySync: no devices encrypted to — cannot send request"); + return; + } + + var participantsNode = new BinaryNode("participants") { Content = toNodes }; + var contentNodes = new List { participantsNode }; + + if (hasPkMsg && _auth.Account != null) + { + var accountProto = ADVSignedDeviceIdentity.ParseFrom(_auth.Account); + contentNodes.Add(new BinaryNode("device-identity") { Content = accountProto.ToByteArray() }); + } + + var msgNode = new BinaryNode("message", new Dictionary + { + ["id"] = msgId, + ["type"] = "text", + ["to"] = $"{myPhone}@s.whatsapp.net", + }) { Content = contentNodes }; + + // Track this PDO message so that if the phone sends a retry receipt we can resend + _sentPdoMsgIds.Add(msgId); + _lastPdoRequest = (targetChatJid, null, false, 0, count); + + await SendNodeAsync(msgNode, ct); + _logger.LogInformation("OnDemandHistorySync: sent peerDataOperationRequestMessage (msgId={Id}) to self", msgId); + } + + public async Task> FetchMessageHistoryAsync(string jid, int count, CancellationToken ct) + { + // Normalize JID + var normalizedJid = jid.Contains('@') ? jid : $"{jid}@s.whatsapp.net"; + + var iq = new BinaryNode("iq", new Dictionary + { + ["to"] = "s.whatsapp.net", + ["type"] = "set", + ["xmlns"] = "w:msg", + ["id"] = GenerateMessageId(), + }) + { + Content = new List + { + new BinaryNode("sync") + { + Content = new List + { + new BinaryNode("conversation", new Dictionary + { + ["jid"] = normalizedJid, + ["t"] = "0", + ["count"] = count.ToString(), + }), + }, + }, + }, + }; + + try + { + _logger.LogInformation("FetchMessageHistoryAsync: sending w:msg sync IQ for {Jid}", normalizedJid); + var result = await SendIQAsync(iq, ct, timeoutMs: 20000); + _logger.LogInformation("FetchMessageHistoryAsync: w:msg result tag={Tag}", result.Tag); + + // Parse any message nodes in the result + var messages = new List(); + var msgNodes = new List(); + CollectNodes(result, "message", msgNodes); + foreach (var msgNode in msgNodes) + { + var text = msgNode.FindChild("body")?.Content as string + ?? msgNode.GetAttr("body"); + if (string.IsNullOrEmpty(text)) continue; + + var from = msgNode.GetAttr("from") ?? ""; + long.TryParse(msgNode.GetAttr("t"), out var ts); + var fromMe = from == _auth.Me?.Id?.Split(':')[0] + "@s.whatsapp.net"; + messages.Add(new IncomingMessage + { + Id = msgNode.GetAttr("id") ?? "", + From = from, + RemoteJid = normalizedJid, + Text = text, + FromMe = fromMe, + Timestamp = ts, + }); + } + + return messages; + } + catch (TimeoutException) + { + _logger.LogInformation("FetchMessageHistoryAsync: w:msg IQ timed out (companion devices may not support history sync via IQ)"); + return new List(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "FetchMessageHistoryAsync: w:msg IQ failed for {Jid}", normalizedJid); + return new List(); + } + } + + private static void CollectNodes(BinaryNode node, string tag, List result) + { + if (node.Tag == tag) result.Add(node); + foreach (var child in node.Children) + CollectNodes(child, tag, result); + } + + // ─── History Sync (HistorySyncNotification → CDN download → proto parse) ─ + + private static readonly HttpClient _httpClient = new(new HttpClientHandler + { + AutomaticDecompression = System.Net.DecompressionMethods.None, // handle ourselves + }); + + /// + /// Handles a HistorySyncNotification message from the phone. + /// Downloads the encrypted blob from WhatsApp CDN, decrypts it, parses the + /// HistorySync protobuf, and fires HistoryMessageReceived for each message and HistorySyncCompleted at the end. + /// + private async Task HandleHistorySyncAsync( + Dawa.Proto.HistorySyncNotification notification, + string msgId, string from, long timestamp, CancellationToken ct) + { + _logger.LogInformation( + "HistorySync: type={Type} chunkOrder={Chunk} directPath={Path} fileLen={Len}", + notification.SyncType, notification.Progress, notification.DirectPath, notification.FileLength); + + try + { + // ── 1. Download encrypted blob from WhatsApp CDN ─────────────────── + if (string.IsNullOrEmpty(notification.DirectPath)) + { + _logger.LogWarning("HistorySync: no directPath — cannot download blob"); + _ = SendAckAsync(msgId, from, timestamp); + return; + } + + var cdnUrl = "https://mmg.whatsapp.net" + notification.DirectPath; + byte[] encryptedBlob; + try + { + encryptedBlob = await _httpClient.GetByteArrayAsync(cdnUrl, ct); + _logger.LogInformation("HistorySync: downloaded {Bytes} bytes from CDN", encryptedBlob.Length); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: CDN download failed for {Url}", cdnUrl); + _ = SendAckAsync(msgId, from, timestamp); + return; + } + + // ── 2. Decrypt: HKDF-expand mediaKey → IV(16) + aesKey(32) + macKey(32) ─ + // Info string for history sync is "WhatsApp History Keys" + // Expand mediaKey using HKDF-SHA256 with WhatsApp's standard media key derivation. + // Info: "WhatsApp History Keys", salt: 32 zero bytes, output: 112 bytes. + // Layout (from Baileys): IV=0..15, AES=16..47, MAC=48..79 + var expanded = Dawa.Crypto.DawaHKDF.DeriveKey( + notification.MediaKey, + salt: new byte[32], // zero salt + info: System.Text.Encoding.UTF8.GetBytes("WhatsApp History Keys"), + outputLength: 80); + + var iv = expanded[0..16]; // bytes 0..15 + var aesKey = expanded[16..48]; // bytes 16..47 + // macKey is expanded[48..80] — we trust the download, skip MAC verification for now + + // Strip trailing 10-byte HMAC + var ciphertext = encryptedBlob[..^10]; + + byte[] decrypted; + try + { + using var aes = System.Security.Cryptography.Aes.Create(); + aes.Key = aesKey; + aes.IV = iv; + aes.Mode = System.Security.Cryptography.CipherMode.CBC; + aes.Padding = System.Security.Cryptography.PaddingMode.PKCS7; + using var dec = aes.CreateDecryptor(); + decrypted = dec.TransformFinalBlock(ciphertext, 0, ciphertext.Length); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: AES decryption failed"); + _ = SendAckAsync(msgId, from, timestamp); + return; + } + + // ── 3. Gunzip the decrypted bytes ───────────────────────────────── + byte[] protoBytes; + try + { + using var inStream = new System.IO.MemoryStream(decrypted); + using var gzip = new System.IO.Compression.GZipStream(inStream, System.IO.Compression.CompressionMode.Decompress); + using var outStream = new System.IO.MemoryStream(); + await gzip.CopyToAsync(outStream, ct); + protoBytes = outStream.ToArray(); + _logger.LogInformation("HistorySync: decompressed to {Bytes} bytes", protoBytes.Length); + } + catch + { + // Not gzip — use raw bytes + protoBytes = decrypted; + _logger.LogInformation("HistorySync: not gzip-compressed, using raw {Bytes} bytes", protoBytes.Length); + } + + // ── 4. Parse HistorySync protobuf ────────────────────────────────── + Dawa.Proto.HistorySync historySync; + try + { + historySync = Dawa.Proto.HistorySync.Decode(protoBytes); + _logger.LogInformation("HistorySync: parsed {ConvCount} conversations, {NameCount} push names", + historySync.Conversations.Count, historySync.PushNames.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: failed to parse HistorySync proto ({Bytes} bytes)", protoBytes.Length); + _ = SendAckAsync(msgId, from, timestamp); + return; + } + + // ── 5. Store push names from history ───────────────────────────────── + foreach (var pn in historySync.PushNames) + { + if (!string.IsNullOrEmpty(pn.Id) && !string.IsNullOrEmpty(pn.PushName)) + _pushNames.TryAdd(pn.Id, pn.PushName); + } + + // ── 6. Fire HistoryMessageReceived for each message ─────────────────── + var totalMessages = 0; + foreach (var conv in historySync.Conversations) + { + var chatJid = conv.Id; + if (string.IsNullOrEmpty(chatJid)) continue; + + foreach (var hm in conv.Messages) + { + var wmi = hm.Message; + if (wmi == null) continue; + var key = wmi.Key; + if (key == null) continue; + + var text = wmi.MessageBody?.EffectiveText; + if (string.IsNullOrEmpty(text)) continue; + + var msgFromMe = key.FromMe; + var msgFrom = msgFromMe + ? (_auth.Me?.Id ?? chatJid) + : (!string.IsNullOrEmpty(key.Participant) ? key.Participant : chatJid); + + HistoryMessageReceived?.Invoke(this, new IncomingMessage + { + Id = key.Id, + From = msgFrom, + RemoteJid = key.RemoteJid.Length > 0 ? key.RemoteJid : chatJid, + Participant = key.Participant.Length > 0 ? key.Participant : null, + Text = text, + FromMe = msgFromMe, + Timestamp = (long)wmi.Timestamp, + PushName = wmi.PushName, + }); + totalMessages++; + } + + // Update thread metadata with latest message timestamp + if (conv.Messages.Count > 0) + { + var latest = conv.Messages + .Where(m => m.Message != null) + .Max(m => (long)m.Message!.Timestamp); + _threadMetadata.TryAdd(chatJid, latest); + } + } + + _logger.LogInformation("HistorySync: fired {Total} HistoryMessageReceived events across {Convs} conversations", + totalMessages, historySync.Conversations.Count); + + // Signal completion so callers can persist the full sync + HistorySyncCompleted?.Invoke(this, totalMessages); + + SaveCacheToDisk(); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: unexpected error"); + } + finally + { + _ = SendAckAsync(msgId, from, timestamp); + } + } + + // ─── Groups ────────────────────────────────────────────────────────────── + + /// + /// Returns the list of group JIDs from thread_metadata (those ending in @g.us). + /// + public List GetGroupJids() + => _threadMetadata.Keys.Where(j => j.EndsWith("@g.us")).ToList(); + + /// + /// Fetches group metadata (name, participants) for a specific group JID. + /// Sends an IQ to the group's server and parses the response. + /// + public async Task FetchGroupMetadataAsync(string groupJid, CancellationToken ct) + { + if (!groupJid.EndsWith("@g.us")) + throw new ArgumentException("Not a group JID", nameof(groupJid)); + + var iq = new BinaryNode("iq", new Dictionary + { + ["to"] = groupJid, + ["type"] = "get", + ["xmlns"] = "w:g2", + ["id"] = GenerateMessageId(), + }) + { + Content = new List + { + new BinaryNode("query", new Dictionary { ["request"] = "interactive" }), + }, + }; + + BinaryNode result; + try { result = await SendIQAsync(iq, ct, timeoutMs: 15000); } + catch (Exception ex) + { + _logger.LogWarning(ex, "FetchGroupMetadataAsync: IQ failed for {Group}", groupJid); + return null; + } + + // Response: + // ... + var groupNode = FindDeep(result, "group"); + if (groupNode == null) + { + _logger.LogWarning("FetchGroupMetadataAsync: no node in response for {Group}", groupJid); + return null; + } + + var subject = groupNode.GetAttr("subject") ?? ""; + var creator = groupNode.GetAttr("creator") ?? ""; + var creation = groupNode.GetAttr("creation") ?? "0"; + long.TryParse(creation, out var creationTs); + + // Participants — group IQ includes phone_number attribute for LID→phone resolution + var cacheUpdated = false; + var participants = groupNode.GetChildren("participant").Select(p => + { + var lidJid = p.GetAttr("jid") ?? ""; + var phoneJid = p.GetAttr("phone_number"); // e.g. "254708713947@s.whatsapp.net" + var pType = p.GetAttr("type") ?? "member"; + + // Populate LID→phone cache from the group response + if (!string.IsNullOrEmpty(lidJid) && !string.IsNullOrEmpty(phoneJid) + && !_lidToPhone.ContainsKey(lidJid)) + { + _lidToPhone[lidJid] = phoneJid; + cacheUpdated = true; + } + + // Resolve display JID to phone number JID where possible + var displayJid = (!string.IsNullOrEmpty(phoneJid)) ? phoneJid : lidJid; + + return new GroupParticipant( + Jid: displayJid, + LidJid: lidJid, + Type: pType + ); + }).Where(p => !string.IsNullOrEmpty(p.Jid)).ToList(); + + if (cacheUpdated) SaveCacheToDisk(); + + return new GroupMetadata( + Jid: groupJid, + Subject: subject, + Creator: creator, + CreationTimestamp: creationTs, + Participants: participants + ); + } + + private static BinaryNode? FindDeep(BinaryNode node, string tag) + { + if (node.Tag == tag) return node; + foreach (var child in node.Children) + { + var found = FindDeep(child, tag); + if (found != null) return found; + } + return null; + } + + /// + /// Resolves a LID JID to a phone JID using a usync IQ query. + /// If already cached, returns the cached value immediately. + /// Otherwise sends a usync query to WhatsApp and caches the result. + /// Returns null if the LID cannot be resolved. + /// + public async Task ResolveLidAsync(string lidJid, CancellationToken ct) + { + if (!lidJid.EndsWith("@lid")) + return lidJid; // Not a LID, return as-is + + // Check in-memory cache first + if (_lidToPhone.TryGetValue(lidJid, out var cached)) + { + _logger.LogInformation("ResolveLidAsync: {Lid} → {Phone} (cached)", lidJid, cached); + return cached; + } + + // Send usync IQ to resolve this LID + _logger.LogInformation("ResolveLidAsync: sending usync IQ to resolve {Lid}", lidJid); + var contacts = await FetchContactsViaUsyncAsync([lidJid], ct); + + // Check cache again — FetchContactsViaUsyncAsync populates _lidToPhone on success + if (_lidToPhone.TryGetValue(lidJid, out var resolved)) + { + _logger.LogInformation("ResolveLidAsync: {Lid} → {Phone} (resolved via usync)", lidJid, resolved); + SaveCacheToDisk(); + return resolved; + } + + _logger.LogWarning("ResolveLidAsync: could not resolve {Lid} (usync returned {Count} contacts, none matched)", + lidJid, contacts.Count); + return null; + } + + /// + /// Returns all in-memory stored messages for a given JID. + /// Checks both the given JID and any known LID variant. + /// + public List GetStoredMessages(string jid) + { + var result = new List(); + // Note: messages are stored via MessageReceived event in the service layer, + // not in NoiseProcessor. This returns an empty list — use WhatsAppBridgeService.GetMessagesAsync. + return result; + } + + public async ValueTask DisposeAsync() + { + _keepAliveCts?.Cancel(); + _keepAliveCts?.Dispose(); + await ValueTask.CompletedTask; + } + + // ─── Typing / presence (public API) ───────────────────────────────────── + + /// + /// Sends a typing indicator (composing) or stops it (paused) for a specific chat. + /// + public async Task SendTypingAsync(string jid, bool isTyping, CancellationToken ct) + { + var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net"; + var chatstate = new BinaryNode("chatstate", + new Dictionary { ["to"] = normalizedJid }, + isTyping + ? new List { new BinaryNode("composing") } + : new List { new BinaryNode("paused") }); + await SendNodeAsync(chatstate, ct); + _logger.LogInformation("Sent typing={IsTyping} to {Jid}", isTyping, normalizedJid); + } + + /// + /// Updates this device's presence to available or unavailable. + /// + public async Task SendUserPresenceAsync(bool isOnline, CancellationToken ct) + { + var presence = new BinaryNode("presence", new Dictionary + { + ["type"] = isOnline ? "available" : "unavailable", + }); + await SendNodeAsync(presence, ct); + _logger.LogInformation("Sent presence={Status}", isOnline ? "available" : "unavailable"); + } + + // ─── Message revoke ────────────────────────────────────────────────────── + + /// + /// Sends a ProtocolMessage REVOKE to delete a sent message for everyone. + /// + public async Task RevokeMessageAsync(string jid, string messageId, bool fromMe, long timestamp, CancellationToken ct) + { + var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net"; + var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0]; + + List recipientDeviceJids; + try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); } + catch { recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"]; } + + var senderDeviceJids = new List(); + var myJid = _auth.Me?.Id; + if (myJid != null) + { + var myPhone = myJid.Split('@')[0].Split(':')[0]; + try + { + var myDevices = await GetDeviceListAsync(myPhone, ct); + senderDeviceJids.AddRange(myDevices.Where(d => d != myJid)); + } + catch { } + } + + var revokeMsg = new Proto.WAMessage + { + ProtocolMsg = new Proto.ProtocolMessage + { + Type = Proto.ProtocolMessage.TYPE_REVOKE, + Key = new Proto.MessageKey + { + RemoteJid = normalizedJid, + FromMe = fromMe, + Id = messageId, + }, + } + }; + + var msgBytes = revokeMsg.ToByteArrayWithRevoke(); + var padded = PadMessage(msgBytes); + + await SendEncryptedMessageAsync(normalizedJid, messageId, timestamp, + recipientDeviceJids, senderDeviceJids, padded, ct); + + _logger.LogInformation("Sent revoke for message {MsgId} in chat {Jid}", messageId, normalizedJid); + } + + // ─── Message forwarding ────────────────────────────────────────────────── + + /// + /// Forwards an already-received message to another chat. + /// The message content is re-sent with IsForwarded=true in ContextInfo. + /// + public async Task ForwardMessageAsync(string toJid, string text, CancellationToken ct) + { + // Simple implementation: forward as extended text with forwarded context + var normalizedJid = toJid.Contains('@') ? toJid : $"{toJid.TrimStart('+')}@s.whatsapp.net"; + var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0]; + + List recipientDeviceJids; + try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); } + catch { recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"]; } + + var senderDeviceJids = new List(); + var myJid = _auth.Me?.Id; + if (myJid != null) + { + var myPhone = myJid.Split('@')[0].Split(':')[0]; + try + { + var myDevices = await GetDeviceListAsync(myPhone, ct); + senderDeviceJids.AddRange(myDevices.Where(d => d != myJid)); + } + catch { } } - else + + var fwdMsg = new Proto.WAMessage { - // Session restore (login) - ulong.TryParse(_auth.Me?.Id.Split('@')[0] ?? "0", out var userId); - return new ClientPayload + ExtendedTextMessage = new Proto.ExtendedTextMessage { - Username = userId, - Passive = true, - Pull = true, - ConnectType = 1, - ConnectReason = 1, - UserAgent = userAgent, - WebInfo = new WebInfo { WebSubPlatform = 0 }, - }.ToByteArray(); + Text = text, + ContextInfo = new Proto.ContextInfo { IsForwarded = true, ForwardingScore = 1 }, + } + }; + + var msgBytes = fwdMsg.ToByteArray(); + var padded = PadMessage(msgBytes); + var msgId = GenerateMessageId(); + + await SendEncryptedMessageAsync(normalizedJid, msgId, + DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + recipientDeviceJids, senderDeviceJids, padded, ct); + + _logger.LogInformation("Forwarded message to {Jid}", normalizedJid); + } + + // ─── Group management ──────────────────────────────────────────────────── + + /// Creates a new WhatsApp group and returns its JID. + public async Task CreateGroupAsync(string subject, IEnumerable participantJids, CancellationToken ct) + { + var participants = participantJids.Select(j => + { + var jid = j.Contains('@') ? j : $"{j.TrimStart('+')}@s.whatsapp.net"; + return new BinaryNode("participant", new Dictionary { ["jid"] = jid }); + }).ToList(); + + var iq = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["type"] = "set", + ["xmlns"] = "w:g2", + ["to"] = "g.us", + }) + { + Content = new List + { + new BinaryNode("create", new Dictionary { ["subject"] = subject, ["key"] = GenerateMessageId() }) + { + Content = participants + } + } + }; + + try + { + var result = await SendIQAsync(iq, ct, timeoutMs: 15000); + var groupNode = result.FindChild("group") ?? result.FindChild("create"); + var groupJid = groupNode?.GetAttr("jid"); + _logger.LogInformation("Created group '{Subject}' → {Jid}", subject, groupJid ?? "(no jid in response)"); + return groupJid; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CreateGroupAsync failed for '{Subject}'", subject); + return null; } } - private static string GenerateMessageId() + /// Leaves a WhatsApp group. + public async Task LeaveGroupAsync(string groupJid, CancellationToken ct) { - var bytes = RandomNumberGenerator.GetBytes(8); - return BitConverter.ToString(bytes).Replace("-", "").ToUpper(); + var iq = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["type"] = "set", + ["xmlns"] = "w:g2", + ["to"] = "g.us", + }) + { + Content = new List + { + new BinaryNode("leave") + { + Content = new List + { + new BinaryNode("group", new Dictionary { ["id"] = groupJid }) + } + } + } + }; + + try { await SendIQAsync(iq, ct, timeoutMs: 10000); } + catch (Exception ex) { _logger.LogWarning(ex, "LeaveGroupAsync failed for {Group}", groupJid); } } - public async ValueTask DisposeAsync() + /// Adds participants to a group. Returns per-JID result codes. + public async Task> AddGroupParticipantsAsync(string groupJid, IEnumerable jids, CancellationToken ct) + => await ModifyGroupParticipantsAsync(groupJid, jids, "add", ct); + + /// Removes participants from a group. Returns per-JID result codes. + public async Task> RemoveGroupParticipantsAsync(string groupJid, IEnumerable jids, CancellationToken ct) + => await ModifyGroupParticipantsAsync(groupJid, jids, "remove", ct); + + private async Task> ModifyGroupParticipantsAsync( + string groupJid, IEnumerable jids, string action, CancellationToken ct) { - // Nothing to dispose here — socket is owned by the caller - await ValueTask.CompletedTask; + var participants = jids.Select(j => + { + var jid = j.Contains('@') ? j : $"{j.TrimStart('+')}@s.whatsapp.net"; + return new BinaryNode("participant", new Dictionary { ["jid"] = jid }); + }).ToList(); + + var iq = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["type"] = "set", + ["xmlns"] = "w:g2", + ["to"] = groupJid, + }) + { + Content = new List + { + new BinaryNode(action) { Content = participants } + } + }; + + var results = new Dictionary(); + try + { + var result = await SendIQAsync(iq, ct, timeoutMs: 15000); + // Result may have children + var resultNode = result.FindChild(action); + if (resultNode?.Content is List children) + { + foreach (var p in children) + { + var jid = p.GetAttr("jid") ?? ""; + var err = p.GetAttr("error") ?? "200"; + results[jid] = err; + } + } + } + catch (Exception ex) { _logger.LogWarning(ex, "ModifyGroupParticipants({Action}) failed for {Group}", action, groupJid); } + return results; + } + + /// Gets the group's invite link URL. + public async Task GetGroupInviteLinkAsync(string groupJid, CancellationToken ct) + { + var iq = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["type"] = "get", + ["xmlns"] = "w:g2", + ["to"] = groupJid, + }) + { + Content = new List { new BinaryNode("invite") } + }; + + try + { + var result = await SendIQAsync(iq, ct, timeoutMs: 10000); + var inviteNode = result.FindChild("invite"); + var code = inviteNode?.GetAttr("code"); + return code != null ? $"https://chat.whatsapp.com/{code}" : null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "GetGroupInviteLinkAsync failed for {Group}", groupJid); + return null; + } + } + + /// Updates the group subject (name). + public async Task UpdateGroupSubjectAsync(string groupJid, string newSubject, CancellationToken ct) + { + var iq = new BinaryNode("iq", new Dictionary + { + ["id"] = GenerateMessageId(), + ["type"] = "set", + ["xmlns"] = "w:g2", + ["to"] = groupJid, + }) + { + Content = new List + { + new BinaryNode("subject") { Content = System.Text.Encoding.UTF8.GetBytes(newSubject) } + } + }; + + try { await SendIQAsync(iq, ct, timeoutMs: 10000); } + catch (Exception ex) { _logger.LogWarning(ex, "UpdateGroupSubjectAsync failed for {Group}", groupJid); } + } + + // ─── Media download ────────────────────────────────────────────────────── + + /// + /// Downloads and decrypts a WhatsApp media file from the CDN. + /// + /// The CDN URL from IncomingMessage.MediaUrl. + /// Base64-encoded media key from IncomingMessage.MediaKey. + /// MIME type (used to derive HKDF info string). + /// Decrypted plaintext bytes. + public static async Task DownloadMediaAsync(string mediaUrl, string mediaKeyBase64, string mimeType) + { + var mediaKey = Convert.FromBase64String(mediaKeyBase64); + var mediaType = mimeType.Split('/')[0] switch + { + "image" => "image", + "video" => "video", + "audio" => "audio", + "document" => "document", + _ => "image", + }; + + var encBytes = await _http.GetByteArrayAsync(mediaUrl); + return Crypto.MediaCrypto.Decrypt(encBytes, mediaKey, mediaType); + } + + // ─── Delivery/read receipt tracking ───────────────────────────────────── + + private readonly System.Collections.Concurrent.ConcurrentDictionary _messageStatuses = new(); + + public event EventHandler<(string MessageId, Messages.MessageStatus Status)>? MessageStatusUpdated; + + private async Task HandleReceiptAsync(BinaryNode node, CancellationToken ct) + { + var id = node.GetAttr("id") ?? ""; + var from = node.GetAttr("from") ?? node.GetAttr("to") ?? ""; + var type = node.GetAttr("type") ?? "delivery"; + var t = long.TryParse(node.GetAttr("t"), out var rt) ? rt : 0L; + + // Update delivery/read status for outgoing messages + var status = type switch + { + "read" => Messages.MessageStatus.Read, + "played" => Messages.MessageStatus.Played, + _ => Messages.MessageStatus.Delivered, + }; + + if (!string.IsNullOrEmpty(id)) + { + _messageStatuses[id] = status; + MessageStatusUpdated?.Invoke(this, (id, status)); + _logger.LogDebug("Receipt: msg {Id} → {Status}", id, status); + } + + // ACK the receipt + if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(from)) + { + try + { + using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var ack = new BinaryNode("ack", new Dictionary + { + ["id"] = id, + ["to"] = from, + ["class"] = "receipt", + ["t"] = t.ToString(), + }); + await SendNodeAsync(ack, cts2.Token); + _logger.LogDebug("Sent ACK for receipt id={Id} from={From}", id, from); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to ACK receipt {Id}", id); + } + } + + // If the phone tells us it couldn't decrypt one of our PDO messages, resend it + if (type == "retry" && _sentPdoMsgIds.Contains(id) && _lastPdoRequest.HasValue) + { + var req = _lastPdoRequest.Value; + _logger.LogInformation( + "HandleReceipt: phone can't decrypt our PDO {Id} — resending fresh PDO for chat {Chat}", + id, req.ChatJid); + _ = Task.Run(async () => + { + try + { + await RequestOnDemandHistorySyncAsync(req.ChatJid, req.Count, ct); + } + catch (Exception ex2) + { + _logger.LogWarning(ex2, "Failed to resend PDO after retry receipt"); + } + }, ct); + } + else if (type == "retry") + { + // A recipient device could not decrypt one of our messages and is asking us to + // re-key. WhatsApp names the SPECIFIC failing device in the `participant` + // attribute (for multi-device / own-device fan-out); `from` is only the + // conversation peer. Dropping the session keyed by `from` never heals the stale + // own-device (or specific-device) session, so the sync copy stays undecryptable + // ("Waiting for this message") forever. Drop the session for the device named in + // `participant` (fall back to `from` for 1:1). Then the next send to that device + // establishes a fresh pkmsg it can read. + try + { + var participant = node.GetAttr("participant"); + var failedDevice = !string.IsNullOrEmpty(participant) ? participant : from; + + // Device 0 is keyed bare ("user@server", no ":0") in the send path — normalize. + if (failedDevice.EndsWith(":0@s.whatsapp.net", StringComparison.Ordinal)) + failedDevice = failedDevice.Replace(":0@s.whatsapp.net", "@s.whatsapp.net"); + + if (!string.IsNullOrEmpty(failedDevice) && _signalStore.HasSession(failedDevice)) + { + _signalStore.DeleteSession(failedDevice); + _logger.LogInformation("HandleReceipt: retry for {Id} — dropped stale session for failed device {Dev} (from={From}); next send re-keys.", id, failedDevice, from); + } + else + { + _logger.LogInformation("HandleReceipt: retry for {Id} failed device {Dev} (from={From}) — no stored session to drop (next send establishes fresh).", id, failedDevice, from); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "HandleReceipt: failed to drop session on retry receipt (from={From})", from); + } + } + } + + public Messages.MessageStatus? GetMessageStatus(string messageId) + => _messageStatuses.TryGetValue(messageId, out var s) ? s : null; + + // ─── Helper: Send encrypted message to device list ─────────────────────── + + /// + /// Shared helper used by RevokeMessageAsync and ForwardMessageAsync to encrypt + /// and send a pre-built padded proto blob to a list of devices. + /// + private async Task SendEncryptedMessageAsync( + string toJid, string msgId, long timestamp, + List recipientDeviceJids, List senderDeviceJids, + byte[] paddedProto, CancellationToken ct) + { + var allDeviceJids = new List(recipientDeviceJids); + allDeviceJids.AddRange(senderDeviceJids); + + var needBundles = allDeviceJids.Where(d => !_signalStore.HasSession(d)).ToList(); + if (needBundles.Count > 0) + { + try + { + var bundles = await FetchPreKeyBundlesAsync(needBundles, ct); + foreach (var (deviceJid, bundle) in bundles) + { + try { _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); } + catch (Exception ex2) { _logger.LogWarning(ex2, "Failed to init session for {Jid}", deviceJid); } + } + } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch pre-key bundles"); } + } + + bool hasPkMsg = false; + var participantNodes = new List(); + foreach (var deviceJid in recipientDeviceJids) + { + var node = EncryptForDevice(deviceJid, paddedProto, ref hasPkMsg); + if (node != null) participantNodes.Add(node); + } + + var senderNodes = new List(); + foreach (var deviceJid in senderDeviceJids) + { + var node = EncryptForDevice(deviceJid, paddedProto, ref hasPkMsg); + if (node != null) senderNodes.Add(node); + } + + var attrs = new Dictionary + { + ["id"] = msgId, + ["type"] = "text", + ["to"] = toJid, + ["t"] = timestamp.ToString(), + }; + + var msgNode = new BinaryNode("message", attrs); + var content = new List(); + + if (participantNodes.Count > 0 || senderNodes.Count > 0) + { + var pNode = new BinaryNode("participants") { Content = new List(participantNodes) }; + content.Add(pNode); + if (senderNodes.Count > 0) + { + var sNode = new BinaryNode("device-sent-message") { Content = senderNodes }; + content.Add(sNode); + } + } + + msgNode.Content = content; + await SendNodeAsync(msgNode, ct); + } + + // ─── History sync ──────────────────────────────────────────────────────── + + /// + /// Handles a PeerDataOperationResponseMessage (response to our ON_DEMAND history request). + /// Each result may contain inline compressed+encrypted history data. + /// + private Task ProcessPeerDataResponseAsync(Proto.PeerDataOperationResponseMessage response, CancellationToken ct) + { + _logger.LogInformation("PeerDataResponse: {Count} results", response.Results.Count); + foreach (var result in response.Results) + { + _logger.LogInformation("PeerDataResponse result: type={Type}, historyDataLen={Len}", + result.ResultType, result.HistoryData.Length); + + if (result.ResultType != 0) + { + _logger.LogWarning("PeerDataResponse: non-OK result type {Type}", result.ResultType); + continue; + } + + if (result.HistoryData.Length == 0) + { + _logger.LogInformation("PeerDataResponse: empty history data (no messages in requested range)"); + HistorySyncCompleted?.Invoke(this, 0); + continue; + } + + // History data is inline: zlib-compressed proto (same format as CDN blob after decryption) + // but WITHOUT the AES-CBC encryption layer — delivered inline in the response. + try + { + var protoBytes = ZlibInflate(result.HistoryData); + var sync = Proto.HistorySync.Decode(protoBytes); + + _logger.LogInformation("PeerDataResponse: decoded {Convs} conversations (syncType={Type})", + sync.Conversations.Count, sync.SyncType); + + // Reuse same emission logic + var myJidBase = _auth.Me?.Id?.Split(':')[0].Split('@')[0]; + var pushNames = sync.PushNames.ToDictionary(p => p.Id, p => p.PushName); + var total = 0; + foreach (var conversation in sync.Conversations) + { + // Register every conversation so it appears in the chat list + _threadMetadata.TryAdd(conversation.Id, 0); + + foreach (var histMsg in conversation.Messages) + { + var wmi = histMsg.Message; + if (wmi?.Key == null) continue; + + var body = wmi.MessageBody; + var text = body?.EffectiveText ?? ""; + var mediaType = body?.MediaType ?? ""; + + // Determine message type (text or media) + Messages.MessageType msgType; + if (!string.IsNullOrEmpty(text) && !text.StartsWith("[")) + msgType = Messages.MessageType.Text; + else if (mediaType == "image") msgType = Messages.MessageType.Image; + else if (mediaType == "video") msgType = Messages.MessageType.Video; + else if (mediaType == "audio") msgType = Messages.MessageType.Audio; + else if (mediaType == "document") msgType = Messages.MessageType.Document; + else msgType = Messages.MessageType.Unknown; + + // Skip messages with genuinely no content + if (msgType == Messages.MessageType.Unknown) continue; + + // Build media URL and key for non-text messages + string? mediaUrl = null; + string? mediaKey = null; + string? mimeType = null; + string? fileName = null; + if (body != null && msgType != Messages.MessageType.Text) + { + var directPath = msgType == Messages.MessageType.Image ? body.ImageDirectPath : + msgType == Messages.MessageType.Video ? body.VideoDirectPath : + msgType == Messages.MessageType.Audio ? body.AudioDirectPath : + msgType == Messages.MessageType.Document ? body.DocumentDirectPath : ""; + var rawKey = msgType == Messages.MessageType.Image ? body.ImageMediaKey : + msgType == Messages.MessageType.Video ? body.VideoMediaKey : + msgType == Messages.MessageType.Audio ? body.AudioMediaKey : + msgType == Messages.MessageType.Document ? body.DocumentMediaKey : []; + if (!string.IsNullOrEmpty(directPath)) + mediaUrl = $"https://mmg.whatsapp.net{directPath}"; + if (rawKey.Length > 0) + mediaKey = Convert.ToBase64String(rawKey); + mimeType = msgType == Messages.MessageType.Image ? "image/jpeg" : + msgType == Messages.MessageType.Video ? "video/mp4" : + msgType == Messages.MessageType.Audio ? "audio/ogg" : "application/octet-stream"; + fileName = msgType == Messages.MessageType.Document ? body.DocumentFileName : null; + } + + var remoteJid = wmi.Key.RemoteJid ?? conversation.Id; + var fromMe = wmi.Key.FromMe; + var senderJid = fromMe + ? (myJidBase != null ? $"{myJidBase}@s.whatsapp.net" : remoteJid) + : (wmi.Key.Participant.Length > 0 ? wmi.Key.Participant : remoteJid); + + pushNames.TryGetValue(senderJid.Split('@')[0], out var senderName); + var msg = new Messages.IncomingMessage + { + Id = wmi.Key.Id, + From = senderJid, + RemoteJid = remoteJid, + Type = msgType, + Text = text, + FromMe = fromMe, + Timestamp = (long)wmi.Timestamp, + PushName = wmi.PushName.Length > 0 ? wmi.PushName : senderName, + MediaUrl = mediaUrl, + MediaKey = mediaKey, + MimeType = mimeType, + FileName = fileName, + }; + HistoryMessageReceived?.Invoke(this, msg); + total++; + } + } + _logger.LogInformation("PeerDataResponse: emitted {N} messages", total); + HistorySyncCompleted?.Invoke(this, total); + } + catch (Exception ex) + { + _logger.LogError(ex, "PeerDataResponse: failed to decode history data ({Len} bytes)", result.HistoryData.Length); + } + } + return Task.CompletedTask; + } + + /// + /// Downloads, decrypts, decompresses and parses a WhatsApp history sync blob, + /// then fires for every past message. + /// Called automatically when the server sends a HISTORY_SYNC_NOTIFICATION. + /// + private async Task ProcessHistorySyncAsync(Proto.HistorySyncNotification notif, CancellationToken ct) + { + try + { + var syncTypeName = notif.SyncType switch + { + Proto.HistorySyncNotification.INITIAL_BOOTSTRAP => "INITIAL_BOOTSTRAP", + Proto.HistorySyncNotification.RECENT => "RECENT", + Proto.HistorySyncNotification.FULL => "FULL", + Proto.HistorySyncNotification.PUSH_NAME => "PUSH_NAME", + Proto.HistorySyncNotification.ON_DEMAND => "ON_DEMAND", + _ => notif.SyncType.ToString(), + }; + _logger.LogInformation("HistorySync: received {Type} notification, directPath={Path}", + syncTypeName, notif.DirectPath); + + // Newer WA versions send InlineBlob (field 10): zlib-compressed proto, no CDN download or decryption needed. + // Older versions use DirectPath + MediaKey for CDN download + AES decryption. + byte[] protoBytes; + + if (notif.InlineBlob != null && notif.InlineBlob.Length > 0) + { + _logger.LogInformation("HistorySync: InlineBlob path ({Bytes} bytes)", notif.InlineBlob.Length); + try + { + protoBytes = ZlibInflate(notif.InlineBlob); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: InlineBlob decompression failed"); + return; + } + _logger.LogInformation("HistorySync: InlineBlob decompressed to {Bytes} proto bytes", protoBytes.Length); + } + else if (notif.MediaKey != null && notif.MediaKey.Length > 0 && !string.IsNullOrEmpty(notif.DirectPath)) + { + // 1. Download blob from CDN + var downloadUrl = $"https://mmg.whatsapp.net{notif.DirectPath}"; + _logger.LogInformation("HistorySync: downloading from {Url}", downloadUrl); + + byte[] encBlob; + using var resp = await _http.GetAsync(downloadUrl, ct); + resp.EnsureSuccessStatusCode(); + encBlob = await resp.Content.ReadAsByteArrayAsync(ct); + _logger.LogInformation("HistorySync: downloaded {Bytes} encrypted bytes", encBlob.Length); + + // 2. Decrypt: AES-CBC with keys derived from mediaKey via HKDF "WhatsApp History Keys" + byte[] compressed; + try + { + compressed = Crypto.MediaCrypto.Decrypt(encBlob, notif.MediaKey, "md-msg-hist"); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: decryption failed"); + return; + } + _logger.LogInformation("HistorySync: decrypted to {Bytes} compressed bytes", compressed.Length); + + // 3. Decompress + try + { + protoBytes = ZlibInflate(compressed); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: decompression failed"); + return; + } + _logger.LogInformation("HistorySync: decompressed to {Bytes} proto bytes", protoBytes.Length); + } + else + { + _logger.LogWarning("HistorySync: no InlineBlob, no MediaKey/DirectPath — skipping"); + return; + } + + // 4. Proto decode + Proto.HistorySync sync; + try + { + sync = Proto.HistorySync.Decode(protoBytes); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: proto decode failed"); + return; + } + + // 5. Build pushname lookup + var pushNames = sync.PushNames.ToDictionary(p => p.Id, p => p.PushName); + + // 6. Emit all messages + var myJidBase = _auth.Me?.Id?.Split(':')[0].Split('@')[0]; + var totalMsgs = 0; + foreach (var conversation in sync.Conversations) + { + var chatJid = conversation.Id; + // Add conversation to thread metadata so chat appears in chat list + _threadMetadata.TryAdd(chatJid, 0); + + foreach (var histMsg in conversation.Messages) + { + var wmi = histMsg.Message; + if (wmi?.Key == null) continue; + + var body = wmi.MessageBody; + var text = body?.EffectiveText ?? ""; + var mediaType = body?.MediaType ?? ""; + + // Determine message type + Messages.MessageType msgType; + if (!string.IsNullOrEmpty(text) && !text.StartsWith("[")) + msgType = Messages.MessageType.Text; + else if (mediaType == "image") + msgType = Messages.MessageType.Image; + else if (mediaType == "video") + msgType = Messages.MessageType.Video; + else if (mediaType == "audio") + msgType = Messages.MessageType.Audio; + else if (mediaType == "document") + msgType = Messages.MessageType.Document; + else if (!string.IsNullOrEmpty(text)) // captions like [image], [video] + msgType = Messages.MessageType.Text; + else + continue; // truly unknown/empty — skip + + var remoteJid = wmi.Key.RemoteJid ?? chatJid; + var fromMe = wmi.Key.FromMe; + var senderJid = fromMe + ? (myJidBase != null ? $"{myJidBase}@s.whatsapp.net" : remoteJid) + : (wmi.Key.Participant.Length > 0 ? wmi.Key.Participant : remoteJid); + + pushNames.TryGetValue(senderJid.Split('@')[0], out var senderName); + + // Build media URL from directPath + string? mediaUrl = null; + string? mediaKey = null; + string? mimeType = null; + string? fileName = null; + if (body != null) + { + var directPath = body.ImageDirectPath.Length > 0 ? body.ImageDirectPath : + body.VideoDirectPath.Length > 0 ? body.VideoDirectPath : + body.AudioDirectPath.Length > 0 ? body.AudioDirectPath : + body.DocumentDirectPath.Length > 0 ? body.DocumentDirectPath : ""; + var rawKey = body.ImageMediaKey.Length > 0 ? body.ImageMediaKey : + body.VideoMediaKey.Length > 0 ? body.VideoMediaKey : + body.AudioMediaKey.Length > 0 ? body.AudioMediaKey : + body.DocumentMediaKey.Length > 0 ? body.DocumentMediaKey : []; + if (directPath.Length > 0) + mediaUrl = "https://mmg.whatsapp.net" + directPath; + if (rawKey.Length > 0) + mediaKey = Convert.ToBase64String(rawKey); + if (body.DocumentFileName.Length > 0) + fileName = body.DocumentFileName; + mimeType = mediaType switch + { + "image" => "image/jpeg", + "video" => "video/mp4", + "audio" => "audio/ogg", + "document" => "application/octet-stream", + _ => null + }; + } + + var msg = new Messages.IncomingMessage + { + Id = wmi.Key.Id, + From = senderJid, + RemoteJid = remoteJid, + Participant = wmi.Key.Participant.Length > 0 ? wmi.Key.Participant : null, + Type = msgType, + Text = !string.IsNullOrEmpty(text) ? text : null, + FromMe = fromMe, + Timestamp = (long)wmi.Timestamp, + PushName = wmi.PushName.Length > 0 ? wmi.PushName : senderName, + MediaUrl = mediaUrl, + MediaKey = mediaKey, + MimeType = mimeType, + FileName = fileName, + }; + + HistoryMessageReceived?.Invoke(this, msg); + totalMsgs++; + } + } + + _logger.LogInformation("HistorySync: emitted {Count} messages from {Convs} conversations ({Type})", + totalMsgs, sync.Conversations.Count, syncTypeName); + + HistorySyncCompleted?.Invoke(this, totalMsgs); + } + catch (Exception ex) + { + _logger.LogError(ex, "HistorySync: unhandled error"); + } + } + + /// Decompresses zlib-deflated data (strips 2-byte zlib header + 4-byte adler32 trailer). + /// + /// Strip Signal Protocol message padding: ISO 7816-4 style padding + /// where the actual message is followed by 0x80 and then zero bytes. + /// After AES-CBC PKCS7 decryption, this layer must be removed before proto parsing. + /// + private static byte[] StripSignalPadding(byte[] data) + { + int end = data.Length - 1; + while (end > 0 && data[end] == 0x00) end--; + if (end >= 0 && data[end] == 0x80) + return data[..end]; + return data; // no padding found — return as-is + } + + private static byte[] ZlibInflate(byte[] data) + { + // zlib stream: 2-byte header + deflate data + 4-byte adler32 + // System.IO.Compression.DeflateStream reads raw deflate (no header). + using var input = new System.IO.MemoryStream(data, 2, data.Length - 2); + using var deflate = new System.IO.Compression.DeflateStream(input, System.IO.Compression.CompressionMode.Decompress); + using var output = new System.IO.MemoryStream(); + deflate.CopyTo(output); + return output.ToArray(); } } + +public record GroupParticipant(string Jid, string LidJid, string Type); +public record GroupMetadata(string Jid, string Subject, string Creator, long CreationTimestamp, List Participants); + +// PresenceInfo record — lives in Dawa.Noise namespace +public record PresenceInfo(string Jid, string Status, DateTime LastSeen); diff --git a/src/Dawa/Noise/NoiseState.cs b/src/Dawa/Noise/NoiseState.cs index c6df4df..4d4b6df 100644 --- a/src/Dawa/Noise/NoiseState.cs +++ b/src/Dawa/Noise/NoiseState.cs @@ -42,11 +42,13 @@ public void MixHash(byte[] data) /// Mixes input key material into the chaining key and optionally sets a new cipher key. public void MixKey(byte[] inputKeyMaterial) { - // HKDF output: first 32 bytes = new encryption key (k), second 32 bytes = new chaining key (ck). - // Matches Baileys: encKey = hashOutput.slice(0, 32), salt = hashOutput.slice(32) - var (k, ck) = DawaHKDF.DeriveKeys(inputKeyMaterial, _ck); - _ck = ck; - _k = k; + // Noise spec + Baileys: HKDF[0:32] = new chaining key (salt for next derivation), + // HKDF[32:64] = new cipher key. + // Baileys noise.ts: const [write, read] = deriveKey(data, noiseSalt) + // noiseSalt = write (first 32), encKey = read (last 32) + var (newCk, newK) = DawaHKDF.DeriveKeys(inputKeyMaterial, _ck); + _ck = newCk; // HKDF[0:32] → chaining key (salt for next derivation) + _k = newK; // HKDF[32:64] → cipher key _n = 0; } diff --git a/src/Dawa/Noise/WaVersionProvider.cs b/src/Dawa/Noise/WaVersionProvider.cs new file mode 100644 index 0000000..ed1d480 --- /dev/null +++ b/src/Dawa/Noise/WaVersionProvider.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Dawa.Noise; + +/// +/// Provides the WhatsApp Web client version announced in the ClientPayload. +/// WhatsApp rejects fresh device registrations from outdated versions with +/// "Authentication failure: 405" (already-paired sessions keep working), so the +/// version must track WhatsApp's minimum. Baileys maintains the current value in +/// baileys-version.json; we fetch it before each connect (throttled) and fall back +/// to the compiled constant when offline. The active version is mirrored to +/// wa-version-active.json next to the app so external watchdogs can compare it +/// against the live upstream value. +/// +public static class WaVersionProvider +{ + public const uint Primary = 2; + public const uint Secondary = 3000; + + // Last version verified working, 2026-07-14. Used when the fetch fails and no disk cache exists. + private const uint FallbackTertiary = 1035194821; + + private const string SourceUrl = + "https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/baileys-version.json"; + + private static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(12); + private static readonly TimeSpan RetryInterval = TimeSpan.FromHours(1); + private static readonly SemaphoreSlim Gate = new(1, 1); + + private static uint _tertiary = FallbackTertiary; + private static DateTime _nextFetchUtc = DateTime.MinValue; + + static WaVersionProvider() => TryReadCache(); + + public static uint Tertiary => _tertiary; + public static string VersionString => $"{Primary}.{Secondary}.{_tertiary}"; + + /// + /// Refreshes the version from upstream if the throttle window has passed. + /// Never throws — on any failure the current (cached or fallback) value stays active. + /// + public static async Task RefreshAsync(ILogger? logger = null, CancellationToken ct = default) + { + if (DateTime.UtcNow < _nextFetchUtc) return; + + await Gate.WaitAsync(ct); + try + { + if (DateTime.UtcNow < _nextFetchUtc) return; + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) }; + var json = await http.GetStringAsync(SourceUrl, ct); + using var doc = JsonDocument.Parse(json); + var arr = doc.RootElement.GetProperty("version"); + + if (arr.GetArrayLength() == 3 + && arr[0].GetUInt32() == Primary + && arr[1].GetUInt32() == Secondary) + { + var tertiary = arr[2].GetUInt32(); + if (tertiary != _tertiary) + { + logger?.LogInformation("WA web version updated: {Old} -> {New}", _tertiary, tertiary); + _tertiary = tertiary; + } + _nextFetchUtc = DateTime.UtcNow + RefreshInterval; + TryWriteCache(); + } + else + { + // Major/minor changed upstream — a protocol shift beyond a tertiary bump. + // Keep the known-good value; a human (or watchdog alert) must look at this. + logger?.LogWarning("WA version upstream has unexpected shape: {Json} — keeping {Current}", + json.Trim(), VersionString); + _nextFetchUtc = DateTime.UtcNow + RefreshInterval; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("WA version fetch failed ({Msg}) — using {Current}", ex.Message, VersionString); + _nextFetchUtc = DateTime.UtcNow + RetryInterval; + } + finally + { + Gate.Release(); + } + } + + private static string CachePath => Path.Combine(AppContext.BaseDirectory, "wa-version-active.json"); + + private static void TryWriteCache() + { + try + { + File.WriteAllText(CachePath, JsonSerializer.Serialize(new + { + version = new[] { Primary, Secondary, _tertiary }, + fetchedUtc = DateTime.UtcNow, + })); + } + catch + { + // Cache is best-effort; the in-memory value is what matters. + } + } + + private static void TryReadCache() + { + try + { + if (!File.Exists(CachePath)) return; + using var doc = JsonDocument.Parse(File.ReadAllText(CachePath)); + var arr = doc.RootElement.GetProperty("version"); + if (arr.GetArrayLength() == 3 + && arr[0].GetUInt32() == Primary + && arr[1].GetUInt32() == Secondary + && arr[2].GetUInt32() > FallbackTertiary) + { + _tertiary = arr[2].GetUInt32(); + } + } + catch + { + // Corrupt cache — fall back to the compiled constant. + } + } +} diff --git a/src/Dawa/Proto/HistorySyncProto.cs b/src/Dawa/Proto/HistorySyncProto.cs new file mode 100644 index 0000000..5f3f075 --- /dev/null +++ b/src/Dawa/Proto/HistorySyncProto.cs @@ -0,0 +1,337 @@ +namespace Dawa.Proto; + +// ───────────────────────────────────────────────────────────────────────────── +// History-sync proto decoders. +// Field numbers verified against Baileys WAProto/index.js encode methods. +// Uses the existing ProtoReader from ProtoEncoder.cs (byte[] based). +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// Embedded in ProtocolMessage field 6 (HISTORY_SYNC_NOTIFICATION). +/// Points to the encrypted, compressed HistorySync blob on the CDN. +/// +public sealed class HistorySyncNotification +{ + public byte[] FileSha256 { get; set; } = []; // field 1 + public ulong FileLength { get; set; } // field 2 + public byte[] MediaKey { get; set; } = []; // field 3 + public byte[] FileEncSha256 { get; set; } = []; // field 4 + public string DirectPath { get; set; } = ""; // field 5 + public int SyncType { get; set; } // field 6 + public int Progress { get; set; } // field 9 + public byte[] InlineBlob { get; set; } = []; // field 10 (newer WA: zlib blob sent inline) + + public const int INITIAL_BOOTSTRAP = 0; + public const int RECENT = 2; + public const int FULL = 3; + public const int PUSH_NAME = 4; + public const int ON_DEMAND = 5; + public const int NON_BLOCKING_DATA = 6; + + public static HistorySyncNotification Decode(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + var obj = new HistorySyncNotification(); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.FileSha256 = r.ReadBytes(); break; + case 2: obj.FileLength = r.ReadVarint(); break; + case 3: obj.MediaKey = r.ReadBytes(); break; + case 4: obj.FileEncSha256 = r.ReadBytes(); break; + case 5: obj.DirectPath = r.ReadString(); break; + case 6: obj.SyncType = r.ReadInt32(); break; + case 9: obj.Progress = r.ReadInt32(); break; + case 10: obj.InlineBlob = r.ReadBytes(); break; + default: r.Skip(wt); break; + } + } + return obj; + } +} + +/// Top-level proto decoded from the decrypted+decompressed history blob. +public sealed class HistorySync +{ + public int SyncType { get; set; } + public List Conversations { get; set; } = []; + public List PushNames { get; set; } = []; + + public static HistorySync Decode(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + var obj = new HistorySync(); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.SyncType = r.ReadInt32(); break; + case 2: obj.Conversations.Add(Conversation.Decode(r.ReadBytes())); break; + case 7: obj.PushNames.Add(Pushname.Decode(r.ReadBytes())); break; + default: r.Skip(wt); break; + } + } + return obj; + } +} + +public sealed class Conversation +{ + public string Id { get; set; } = ""; + public List Messages { get; set; } = []; + public string Name { get; set; } = ""; + public bool Archived { get; set; } + + public static Conversation Decode(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + var obj = new Conversation(); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.Id = r.ReadString(); break; + case 2: obj.Messages.Add(HistorySyncMsg.Decode(r.ReadBytes())); break; + case 13: obj.Name = r.ReadString(); break; + case 16: obj.Archived = r.ReadBool(); break; + default: r.Skip(wt); break; + } + } + return obj; + } +} + +public sealed class HistorySyncMsg +{ + public HistoryWebMessageInfo? Message { get; set; } + + public static HistorySyncMsg Decode(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + var obj = new HistorySyncMsg(); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.Message = HistoryWebMessageInfo.Decode(r.ReadBytes()); break; + default: r.Skip(wt); break; + } + } + return obj; + } +} + +/// Minimal decode of WebMessageInfo — only fields needed for history display. +public sealed class HistoryWebMessageInfo +{ + public HistoryMessageKey? Key { get; set; } // field 1 + public HistoryWaMessage? MessageBody { get; set; } // field 2 + public ulong Timestamp { get; set; } // field 3 + public string Participant { get; set; } = ""; // field 5 (group sender) + public string PushName { get; set; } = ""; // field 19 + + public static HistoryWebMessageInfo Decode(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + var obj = new HistoryWebMessageInfo(); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.Key = HistoryMessageKey.Decode(r.ReadBytes()); break; + case 2: obj.MessageBody = HistoryWaMessage.Decode(r.ReadBytes()); break; + case 3: obj.Timestamp = r.ReadVarint(); break; + case 5: obj.Participant = r.ReadString(); break; + case 19: obj.PushName = r.ReadString(); break; + default: r.Skip(wt); break; + } + } + return obj; + } +} + +public sealed class HistoryMessageKey +{ + public string RemoteJid { get; set; } = ""; + public bool FromMe { get; set; } + public string Id { get; set; } = ""; + public string Participant { get; set; } = ""; // field 4 (group sender) + + public static HistoryMessageKey Decode(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + var obj = new HistoryMessageKey(); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.RemoteJid = r.ReadString(); break; + case 2: obj.FromMe = r.ReadBool(); break; + case 3: obj.Id = r.ReadString(); break; + case 4: obj.Participant = r.ReadString(); break; + default: r.Skip(wt); break; + } + } + return obj; + } +} + +/// Minimal Message decode — plain text, captions, and media metadata. +public sealed class HistoryWaMessage +{ + public string Conversation { get; set; } = ""; // field 1 — plain text + public string ExtendedText { get; set; } = ""; // field 6 → ExtendedTextMessage.text (field 1) + public string ImageCaption { get; set; } = ""; // field 4 → ImageMessage.caption (field 7) + public string ImageDirectPath { get; set; } = ""; // field 4 → ImageMessage.directPath (field 10) + public byte[] ImageMediaKey { get; set; } = []; // field 4 → ImageMessage.mediaKey (field 19) + public string VideoCaption { get; set; } = ""; // field 13→ VideoMessage.caption (field 7) + public string VideoDirectPath { get; set; } = ""; // field 13→ VideoMessage.directPath (field 10) + public byte[] VideoMediaKey { get; set; } = []; // field 13→ VideoMessage.mediaKey (field 19) + public string AudioDirectPath { get; set; } = ""; // field 26→ AudioMessage.directPath (field 10) + public byte[] AudioMediaKey { get; set; } = []; // field 26→ AudioMessage.mediaKey (field 19) + public string DocumentFileName { get; set; } = ""; // field 8 → DocumentMessage.fileName (field 4) + public string DocumentDirectPath { get; set; } = ""; // field 8 → DocumentMessage.directPath (field 10) + public byte[] DocumentMediaKey { get; set; } = []; // field 8 → DocumentMessage.mediaKey (field 19) + + // Media type detected + public string MediaType => + ImageDirectPath.Length > 0 || ImageMediaKey.Length > 0 ? "image" : + VideoDirectPath.Length > 0 || VideoMediaKey.Length > 0 ? "video" : + AudioDirectPath.Length > 0 || AudioMediaKey.Length > 0 ? "audio" : + DocumentDirectPath.Length > 0 ? "document" : + ""; + + public string EffectiveText => + Conversation.Length > 0 ? Conversation : + ExtendedText.Length > 0 ? ExtendedText : + ImageCaption.Length > 0 ? $"[image] {ImageCaption}".Trim() : + VideoCaption.Length > 0 ? $"[video] {VideoCaption}".Trim() : + MediaType.Length > 0 ? $"[{MediaType}]" : + ""; + + public static HistoryWaMessage Decode(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + var obj = new HistoryWaMessage(); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.Conversation = r.ReadString(); break; + case 4: ReadImageMessage(r.ReadBytes(), obj); break; // ImageMessage + case 6: obj.ExtendedText = ReadTextField1(r.ReadBytes()); break; // ExtendedTextMessage + case 8: ReadDocumentMessage(r.ReadBytes(), obj); break; // DocumentMessage + case 13: ReadVideoMessage(r.ReadBytes(), obj); break; // VideoMessage + case 26: ReadAudioMessage(r.ReadBytes(), obj); break; // AudioMessage (field 26) + default: r.Skip(wt); break; + } + } + return obj; + } + + private static string ReadTextField1(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (f, wt) = r.ReadTag(); + if (f == 1) return r.ReadString(); + r.Skip(wt); + } + return ""; + } + + private static void ReadImageMessage(byte[] data, HistoryWaMessage obj) + { + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (f, wt) = r.ReadTag(); + switch (f) + { + case 7: obj.ImageCaption = r.ReadString(); break; // caption + case 10: obj.ImageDirectPath = r.ReadString(); break; // directPath + case 19: obj.ImageMediaKey = r.ReadBytes(); break; // mediaKey + default: r.Skip(wt); break; + } + } + } + + private static void ReadVideoMessage(byte[] data, HistoryWaMessage obj) + { + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (f, wt) = r.ReadTag(); + switch (f) + { + case 7: obj.VideoCaption = r.ReadString(); break; // caption + case 10: obj.VideoDirectPath = r.ReadString(); break; // directPath + case 19: obj.VideoMediaKey = r.ReadBytes(); break; // mediaKey + default: r.Skip(wt); break; + } + } + } + + private static void ReadAudioMessage(byte[] data, HistoryWaMessage obj) + { + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (f, wt) = r.ReadTag(); + switch (f) + { + case 10: obj.AudioDirectPath = r.ReadString(); break; // directPath + case 19: obj.AudioMediaKey = r.ReadBytes(); break; // mediaKey + default: r.Skip(wt); break; + } + } + } + + private static void ReadDocumentMessage(byte[] data, HistoryWaMessage obj) + { + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (f, wt) = r.ReadTag(); + switch (f) + { + case 4: obj.DocumentFileName = r.ReadString(); break; // fileName + case 10: obj.DocumentDirectPath = r.ReadString(); break; // directPath + case 19: obj.DocumentMediaKey = r.ReadBytes(); break; // mediaKey + default: r.Skip(wt); break; + } + } + } +} + +public sealed class Pushname +{ + public string Id { get; set; } = ""; + public string PushName { get; set; } = ""; + + public static Pushname Decode(byte[] data) + { + var r = ProtoEncoder.CreateReader(data); + var obj = new Pushname(); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.Id = r.ReadString(); break; + case 2: obj.PushName = r.ReadString(); break; + default: r.Skip(wt); break; + } + } + return obj; + } +} diff --git a/src/Dawa/Proto/ProtoEncoder.cs b/src/Dawa/Proto/ProtoEncoder.cs index f8ea5ef..07efc2e 100644 --- a/src/Dawa/Proto/ProtoEncoder.cs +++ b/src/Dawa/Proto/ProtoEncoder.cs @@ -122,6 +122,8 @@ public ulong ReadVarint() int shift = 0; while (true) { + if (_pos >= _data.Length) + throw new InvalidDataException($"Protobuf varint read past end of buffer at pos={_pos}, len={_data.Length}"); var b = _data[_pos++]; result |= ((ulong)(b & 0x7F)) << shift; if ((b & 0x80) == 0) break; @@ -133,6 +135,11 @@ public ulong ReadVarint() public byte[] ReadBytes() { var len = (int)ReadVarint(); + if (len < 0 || _pos + len > _data.Length) + { + _pos = _data.Length; // stop parsing gracefully + return []; + } var data = _data[_pos..(_pos + len)]; _pos += len; return data; @@ -150,9 +157,26 @@ public void Skip(int wireType) switch (wireType) { case 0: ReadVarint(); break; - case 1: _pos += 8; break; - case 2: _pos += (int)ReadVarint(); break; - case 5: _pos += 4; break; + case 1: + if (_pos + 8 > _data.Length) { _pos = _data.Length; break; } + _pos += 8; break; + case 2: + var len2 = (int)ReadVarint(); + if (len2 < 0 || _pos + len2 > _data.Length) { _pos = _data.Length; break; } + _pos += len2; break; + case 5: + if (_pos + 4 > _data.Length) { _pos = _data.Length; break; } + _pos += 4; break; + case 3: // SGROUP: skip until matching EGROUP (wire type 4) + while (HasMore) + { + var (_, wt) = ReadTag(); + if (wt == 4) break; // EGROUP + Skip(wt); + } + break; + case 4: break; // EGROUP — consumed by parent SGROUP skip + default: _pos = _data.Length; break; // unknown wire type — stop parsing this message } } } diff --git a/src/Dawa/Proto/SyncProto.cs b/src/Dawa/Proto/SyncProto.cs new file mode 100644 index 0000000..e3da999 --- /dev/null +++ b/src/Dawa/Proto/SyncProto.cs @@ -0,0 +1,195 @@ +namespace Dawa.Proto; + +// SyncdSnapshot (returned in node of app state sync IQ result) +// field 1 = SyncdVersion (message, skip) +// field 2 = SyncdRecord[] (repeated message) +// field 3 = mac (bytes, skip) +// field 4 = keyId (SyncdIndex message → field 1 = blob bytes) +public sealed class SyncdSnapshot +{ + public List Records { get; } = []; + public byte[] KeyId { get; set; } = []; + + public static SyncdSnapshot ParseFrom(byte[] data) + { + var msg = new SyncdSnapshot(); + var r = new ProtoReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 2: msg.Records.Add(SyncdRecord.ParseFrom(r.ReadBytes())); break; + case 4: msg.KeyId = ParseIndex(r.ReadBytes()); break; + default: r.Skip(wt); break; + } + } + return msg; + } + + private static byte[] ParseIndex(byte[] data) + { + var r = new ProtoReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + if (field == 1) return r.ReadBytes(); + r.Skip(wt); + } + return []; + } +} + +// SyncdRecord +// field 1 = index (SyncdIndex → blob) +// field 2 = value (SyncdValue → blob) -- this is the encrypted payload +// field 3 = keyId (SyncdIndex → blob) +public sealed class SyncdRecord +{ + public byte[] IndexBlob { get; set; } = []; + public byte[] ValueBlob { get; set; } = []; // IV(16) + ciphertext + public byte[] KeyId { get; set; } = []; + + public static SyncdRecord ParseFrom(byte[] data) + { + var msg = new SyncdRecord(); + var r = new ProtoReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: msg.IndexBlob = ParseBlob(r.ReadBytes()); break; + case 2: msg.ValueBlob = ParseBlob(r.ReadBytes()); break; + case 3: msg.KeyId = ParseBlob(r.ReadBytes()); break; + default: r.Skip(wt); break; + } + } + return msg; + } + + private static byte[] ParseBlob(byte[] data) + { + var r = new ProtoReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + if (field == 1) return r.ReadBytes(); + r.Skip(wt); + } + return []; + } +} + +// SyncActionData (decrypted payload of each SyncdRecord.ValueBlob) +// field 1 = index (bytes → UTF-8 JSON, e.g. ["contact","31612345678@s.whatsapp.net"]) +// field 2 = value (SyncActionValue) +public sealed class SyncActionData +{ + public string[] Index { get; set; } = []; // parsed from JSON bytes + public SyncActionValue? Value { get; set; } + + public static SyncActionData ParseFrom(byte[] data) + { + var msg = new SyncActionData(); + var r = new ProtoReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: + var indexJson = System.Text.Encoding.UTF8.GetString(r.ReadBytes()); + // JSON array like ["contact","31612345678@s.whatsapp.net"] + try { msg.Index = System.Text.Json.JsonSerializer.Deserialize(indexJson) ?? []; } + catch { msg.Index = []; } + break; + case 2: msg.Value = SyncActionValue.ParseFrom(r.ReadBytes()); break; + default: r.Skip(wt); break; + } + } + return msg; + } +} + +// SyncActionValue +// field 1 = timestamp (int64, skip) +// field 3 = contactAction (SyncActionContactAction) +// field 5 = archiveAction (SyncActionChatAction — field 1 = archived bool) +// field 6 = pinAction (inner message — field 1 = pinned bool) +// (many other field types we don't need — skip all) +public sealed class SyncActionValue +{ + public SyncActionContactAction? ContactAction { get; set; } + public SyncActionChatAction? ChatAction { get; set; } + + public static SyncActionValue ParseFrom(byte[] data) + { + var msg = new SyncActionValue(); + var r = new ProtoReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 3: msg.ContactAction = SyncActionContactAction.ParseFrom(r.ReadBytes()); break; + case 5: msg.ChatAction = SyncActionChatAction.ParseFrom(r.ReadBytes()); break; + default: r.Skip(wt); break; + } + } + return msg; + } +} + +// SyncActionContactAction +// field 1 = fullName (string) +// field 2 = firstName (string) +public sealed class SyncActionContactAction +{ + public string FullName { get; set; } = ""; + public string FirstName { get; set; } = ""; + + public static SyncActionContactAction ParseFrom(byte[] data) + { + var msg = new SyncActionContactAction(); + var r = new ProtoReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: msg.FullName = r.ReadString(); break; + case 2: msg.FirstName = r.ReadString(); break; + default: r.Skip(wt); break; + } + } + return msg; + } +} + +// SyncActionChatAction — parsed from chat entries in regular_low / regular collections +// field 1 = archived (bool) +// field 5 = pinAction (inner message, field 1 = bool) +// field 6 = muteAction (inner message, field 1 = epoch ms) +public sealed class SyncActionChatAction +{ + public bool Archived { get; set; } + public bool Pinned { get; set; } + public bool Muted { get; set; } + + public static SyncActionChatAction ParseFrom(byte[] data) + { + var msg = new SyncActionChatAction(); + var r = new ProtoReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: msg.Archived = r.ReadBool(); break; + default: r.Skip(wt); break; + } + } + return msg; + } +} diff --git a/src/Dawa/Proto/WAMessage.cs b/src/Dawa/Proto/WAMessage.cs index 1970741..2af29cc 100644 --- a/src/Dawa/Proto/WAMessage.cs +++ b/src/Dawa/Proto/WAMessage.cs @@ -64,18 +64,46 @@ public byte[] ToByteArray() /// DeviceProps (companion registration info), field 8 of DevicePairingRegistrationData. public sealed class DevicePropsMessage { - public string Os { get; set; } = "Windows"; + public string Os { get; set; } = "Ubuntu"; + // Version = WA app version (Baileys sends [2, 3000, tertiary]), NOT the OS/browser version. + public AppVersion? Version { get; set; } = new AppVersion { Primary = 2, Secondary = 3000, Tertiary = 1035194821 }; // PlatformType: CHROME=1 public int PlatformType { get; set; } = 1; - public bool RequireFullSync { get; set; } = false; + // requireFullSync: true triggers the server to include history in initial sync (Baileys default: true) + public bool RequireFullSync { get; set; } = true; + public HistorySyncConfigMessage? HistorySyncConfig { get; set; } = new(); public byte[] ToByteArray() { - // Match Baileys exactly: only os (field 1) + platformType (field 3). - // requireFullSync=false is proto3 default — omit. No version, no HistorySyncConfig. var buf = new List(); ProtoEncoder.WriteString(buf, 1, Os); + if (Version != null) ProtoEncoder.WriteMessage(buf, 2, Version.ToByteArray()); ProtoEncoder.WriteInt32(buf, 3, PlatformType); + ProtoEncoder.WriteBool(buf, 4, RequireFullSync); // always emit — server expects it + if (HistorySyncConfig != null) ProtoEncoder.WriteMessage(buf, 5, HistorySyncConfig.ToByteArray()); + return [.. buf]; + } +} + +/// HistorySyncConfig nested in DeviceProps (field 5). +public sealed class HistorySyncConfigMessage +{ + public byte[] ToByteArray() + { + var buf = new List(); + ProtoEncoder.WriteUInt32(buf, 1, 3); // fullSyncDaysLimit = 3 (Baileys: fullCount) + ProtoEncoder.WriteUInt32(buf, 3, 2048); // storageQuotaMb = 2048 (Baileys default) + ProtoEncoder.WriteBool(buf, 4, true); // inlineInitialPayloadInE2EeMsg + ProtoEncoder.WriteUInt32(buf, 5, 25); // recentSyncChunkSize = 25 (Baileys: count) + // field 6: supportCallLogHistory = false — omit + ProtoEncoder.WriteBool(buf, 7, true); // supportBotUserAgentChatHistory + ProtoEncoder.WriteBool(buf, 8, true); // supportCagReactionsAndPolls + ProtoEncoder.WriteBool(buf, 9, true); // supportBizHostedMsg + ProtoEncoder.WriteBool(buf, 10, true); // supportRecentSyncChunkMessageCountTuning + ProtoEncoder.WriteBool(buf, 11, true); // supportHostedGroupMsg + ProtoEncoder.WriteBool(buf, 12, true); // supportFbidBotChatHistory + ProtoEncoder.WriteBool(buf, 14, true); // supportMessageAssociation + // field 15: supportGroupHistory = false — omit return [.. buf]; } } @@ -144,18 +172,268 @@ public byte[] ToByteArray() } } -/// WhatsApp message content proto. +// ───────────────────────────────────────────────────────────── +// ADV (Advanced Device Verification) protos used in QR pairing. +// Field numbers verified against WAProto.proto from Baileys. +// ───────────────────────────────────────────────────────────── + +/// Server sends this in device-identity during pair-success. Contains HMAC-protected device identity. +public sealed class ADVSignedDeviceIdentityHMAC +{ + public byte[] Details { get; set; } = []; // field 1: encoded ADVSignedDeviceIdentity + public byte[] Hmac { get; set; } = []; // field 2: HMAC-SHA256 over details + public int AccountType { get; set; } = 0; // field 3: 0=E2EE, 1=HOSTED + + public static ADVSignedDeviceIdentityHMAC ParseFrom(byte[] data) + { + var msg = new ADVSignedDeviceIdentityHMAC(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Details = r.ReadBytes(); break; + case 2: msg.Hmac = r.ReadBytes(); break; + case 3: msg.AccountType = r.ReadInt32(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Decoded from ADVSignedDeviceIdentityHMAC.Details. Client adds deviceSignature and re-encodes. +public sealed class ADVSignedDeviceIdentity +{ + public byte[] Details { get; set; } = []; // field 1: encoded ADVDeviceIdentity + public byte[] AccountSignatureKey { get; set; } = []; // field 2: phone's Curve25519 public key + public byte[] AccountSignature { get; set; } = []; // field 3: phone's XEdDSA signature + public byte[] DeviceSignature { get; set; } = []; // field 4: client fills this in + + public static ADVSignedDeviceIdentity ParseFrom(byte[] data) + { + var msg = new ADVSignedDeviceIdentity(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Details = r.ReadBytes(); break; + case 2: msg.AccountSignatureKey = r.ReadBytes(); break; + case 3: msg.AccountSignature = r.ReadBytes(); break; + case 4: msg.DeviceSignature = r.ReadBytes(); break; + default: r.Skip(wire); break; + } + } + return msg; + } + + /// Encode all fields including accountSignatureKey (for device-identity in outgoing pkmsg). + public byte[] ToByteArray() + { + var buf = new List(); + ProtoEncoder.WriteBytes(buf, 1, Details); + if (AccountSignatureKey.Length > 0) ProtoEncoder.WriteBytes(buf, 2, AccountSignatureKey); + ProtoEncoder.WriteBytes(buf, 3, AccountSignature); + ProtoEncoder.WriteBytes(buf, 4, DeviceSignature); + return [.. buf]; + } + + /// Encode WITHOUT accountSignatureKey (field 2 omitted per Baileys protocol). + public byte[] ToByteArrayForReply() + { + var buf = new List(); + ProtoEncoder.WriteBytes(buf, 1, Details); + // field 2 (accountSignatureKey) intentionally omitted in reply + ProtoEncoder.WriteBytes(buf, 3, AccountSignature); + ProtoEncoder.WriteBytes(buf, 4, DeviceSignature); + return [.. buf]; + } +} + +/// Decoded from ADVSignedDeviceIdentity.Details. Used to get keyIndex for the reply. +public sealed class ADVDeviceIdentity +{ + public uint RawId { get; set; } // field 1 + public ulong Timestamp { get; set; } // field 2 + public uint KeyIndex { get; set; } // field 3 + public int AccountType { get; set; } // field 4 + public int DeviceType { get; set; } // field 5 + + public static ADVDeviceIdentity ParseFrom(byte[] data) + { + var msg = new ADVDeviceIdentity(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.RawId = r.ReadUInt32(); break; + case 2: msg.Timestamp = r.ReadUInt64(); break; + case 3: msg.KeyIndex = r.ReadUInt32(); break; + case 4: msg.AccountType = r.ReadInt32(); break; + case 5: msg.DeviceType = r.ReadInt32(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// +/// WhatsApp Message proto (field numbers from Baileys WAProto.proto). +/// Only the fields we need for text message extraction are implemented; +/// all others are safely skipped. +/// public sealed class WAMessage { - public string? Conversation { get; set; } - public ExtendedTextMessage? ExtendedTextMessage { get; set; } + public string? Conversation { get; set; } // field 1 + public SenderKeyDistributionMessage? SenderKeyDist { get; set; } // field 2 + public ImageMessage? ImageMessage { get; set; } // field 3 + public AudioMessage? AudioMessage { get; set; } // field 4 + public ExtendedTextMessage? ExtendedTextMessage { get; set; } // field 6 + public VideoMessage? VideoMessage { get; set; } // field 5 + public StickerMessage? StickerMessage { get; set; } // field 50 + public ProtocolMessage? ProtocolMsg { get; set; } // field 12 + public DocumentMessage? DocumentMessage { get; set; } // field 15 + public DeviceSentMessage? DeviceSentMessage { get; set; } // field 31 + public MessageContextInfo? MessageContextInfo { get; set; } // field 35 + public HistorySyncNotification? HistorySyncNotification { get; set; } // field 46 + public ReactionMessage? ReactionMessage { get; set; } // field 85 + public PeerDataOperationRequestMessage? PeerDataOperation { get; set; } // field 145 + public PeerDataOperationResponseMessage? PeerDataResponse { get; set; } // field 146 + + /// Extracts the text from whatever message type this is. + public string? GetText() + { + if (!string.IsNullOrEmpty(Conversation)) + return Conversation; + if (ExtendedTextMessage != null && !string.IsNullOrEmpty(ExtendedTextMessage.Text)) + return ExtendedTextMessage.Text; + if (DeviceSentMessage?.Message != null) + return DeviceSentMessage.Message.GetText(); + // Caption from media messages + if (ImageMessage != null && !string.IsNullOrEmpty(ImageMessage.Caption)) return ImageMessage.Caption; + if (VideoMessage != null && !string.IsNullOrEmpty(VideoMessage.Caption)) return VideoMessage.Caption; + if (DocumentMessage != null && !string.IsNullOrEmpty(DocumentMessage.Title)) return DocumentMessage.Title; + return null; + } + + /// Returns the MessageType enum value for this message. + public Messages.MessageType GetMessageType() + { + if (DeviceSentMessage?.Message != null) return DeviceSentMessage.Message.GetMessageType(); + if (!string.IsNullOrEmpty(Conversation)) return Messages.MessageType.Text; + if (ExtendedTextMessage != null) return Messages.MessageType.Text; + if (ImageMessage != null) return Messages.MessageType.Image; + if (AudioMessage != null) return Messages.MessageType.Audio; + if (VideoMessage != null) return Messages.MessageType.Video; + if (DocumentMessage != null) return Messages.MessageType.Document; + if (StickerMessage != null) return Messages.MessageType.Sticker; + if (ReactionMessage != null) return Messages.MessageType.Reaction; + if (ProtocolMsg != null) return Messages.MessageType.Protocol; + return Messages.MessageType.Unknown; + } + + /// Extracts all fields needed to populate an IncomingMessage from this WAMessage. + public (Messages.MessageType type, string? text, string? mediaUrl, string? mimeType, + string? fileName, long? fileSize, uint? duration, uint? width, uint? height, + string? mediaKey, string? mediaSha256Enc, string? reactionEmoji, string? reactionTargetId) + GetAllFields() + { + var inner = DeviceSentMessage?.Message; + if (inner != null) return inner.GetAllFields(); + + var type = GetMessageType(); + var text = GetText(); + + if (ImageMessage != null) + return (type, text ?? ImageMessage.Caption, + mediaUrl: ImageMessage.Url, + mimeType: ImageMessage.MimeType, + fileName: null, + fileSize: (long?)ImageMessage.FileLength, + duration: null, + width: ImageMessage.Width, + height: ImageMessage.Height, + mediaKey: Convert.ToBase64String(ImageMessage.MediaKey), + mediaSha256Enc:Convert.ToBase64String(ImageMessage.FileEncSha256), + null, null); + + if (AudioMessage != null) + return (type, text, + mediaUrl: AudioMessage.Url, + mimeType: AudioMessage.MimeType, + fileName: null, + fileSize: (long?)AudioMessage.FileLength, + duration: AudioMessage.Seconds, + width: null, + height: null, + mediaKey: Convert.ToBase64String(AudioMessage.MediaKey), + mediaSha256Enc:Convert.ToBase64String(AudioMessage.FileEncSha256), + null, null); + + if (VideoMessage != null) + return (type, text, + mediaUrl: VideoMessage.Url, + mimeType: VideoMessage.MimeType, + fileName: null, + fileSize: (long?)VideoMessage.FileLength, + duration: VideoMessage.Seconds, + width: VideoMessage.Width, + height: VideoMessage.Height, + mediaKey: Convert.ToBase64String(VideoMessage.MediaKey), + mediaSha256Enc:Convert.ToBase64String(VideoMessage.FileEncSha256), + null, null); + + if (DocumentMessage != null) + return (type, text, + mediaUrl: DocumentMessage.Url, + mimeType: DocumentMessage.MimeType, + fileName: DocumentMessage.FileName, + fileSize: (long?)DocumentMessage.FileLength, + duration: null, + width: null, + height: null, + mediaKey: Convert.ToBase64String(DocumentMessage.MediaKey), + mediaSha256Enc:Convert.ToBase64String(DocumentMessage.FileEncSha256), + null, null); + + if (StickerMessage != null) + return (type, text, + mediaUrl: StickerMessage.Url, + mimeType: StickerMessage.MimeType, + fileName: null, + fileSize: (long?)StickerMessage.FileLength, + duration: null, + width: StickerMessage.Width, + height: StickerMessage.Height, + mediaKey: Convert.ToBase64String(StickerMessage.MediaKey), + mediaSha256Enc:Convert.ToBase64String(StickerMessage.FileEncSha256), + null, null); + + if (ReactionMessage != null) + return (type, null, null, null, null, null, null, null, null, null, null, + reactionEmoji: ReactionMessage.Text, + reactionTargetId: ReactionMessage.Key?.Id); + + return (type, text, null, null, null, null, null, null, null, null, null, null, null); + } public byte[] ToByteArray() { var buf = new List(); ProtoEncoder.WriteString(buf, 1, Conversation); + if (ImageMessage != null) ProtoEncoder.WriteMessage(buf, 3, ImageMessage.ToByteArray()); + if (AudioMessage != null) ProtoEncoder.WriteMessage(buf, 4, AudioMessage.ToByteArray()); if (ExtendedTextMessage != null) - ProtoEncoder.WriteMessage(buf, 2, ExtendedTextMessage.ToByteArray()); + ProtoEncoder.WriteMessage(buf, 6, ExtendedTextMessage.ToByteArray()); + if (DocumentMessage != null) ProtoEncoder.WriteMessage(buf, 15, DocumentMessage.ToByteArray()); + if (DeviceSentMessage != null) + ProtoEncoder.WriteMessage(buf, 31, DeviceSentMessage.ToByteArray()); return [.. buf]; } @@ -168,29 +446,99 @@ public static WAMessage ParseFrom(byte[] data) var (field, wire) = r.ReadTag(); switch (field) { - case 1: - msg.Conversation = r.ReadString(); - break; - case 2: - msg.ExtendedTextMessage = ExtendedTextMessage.ParseFrom(r.ReadBytes()); - break; - default: - r.Skip(wire); - break; + case 1: msg.Conversation = r.ReadString(); break; + case 2: msg.SenderKeyDist = SenderKeyDistributionMessage.ParseFrom(r.ReadBytes()); break; + case 3: msg.ImageMessage = ImageMessage.ParseFrom(r.ReadBytes()); break; + case 4: msg.AudioMessage = AudioMessage.ParseFrom(r.ReadBytes()); break; + case 5: msg.VideoMessage = VideoMessage.ParseFrom(r.ReadBytes()); break; + case 6: msg.ExtendedTextMessage = ExtendedTextMessage.ParseFrom(r.ReadBytes()); break; + case 12: msg.ProtocolMsg = ProtocolMessage.ParseFrom(r.ReadBytes()); break; + case 15: msg.DocumentMessage = DocumentMessage.ParseFrom(r.ReadBytes()); break; + case 50: msg.StickerMessage = StickerMessage.ParseFrom(r.ReadBytes()); break; + case 31: msg.DeviceSentMessage = DeviceSentMessage.ParseFrom(r.ReadBytes()); break; + case 35: msg.MessageContextInfo = MessageContextInfo.ParseFrom(r.ReadBytes()); break; + case 46: msg.HistorySyncNotification = HistorySyncNotification.Decode(r.ReadBytes()); break; + case 85: msg.ReactionMessage = ReactionMessage.ParseFrom(r.ReadBytes()); break; + case 145: msg.PeerDataOperation = null; r.Skip(wire); break; // outgoing only — skip + case 146: msg.PeerDataResponse = PeerDataOperationResponseMessage.Decode(r.ReadBytes()); break; + default: r.Skip(wire); break; } } return msg; } + + public byte[] ToByteArrayWithPeerDataOperation() + { + var buf = new List(); + if (PeerDataOperation != null) + ProtoEncoder.WriteMessage(buf, 145, PeerDataOperation.ToByteArray()); + return [.. buf]; + } + + public byte[] ToByteArrayWithReaction() + { + var buf = new List(); + if (ReactionMessage != null) + ProtoEncoder.WriteMessage(buf, 85, ReactionMessage.ToByteArray()); + return [.. buf]; + } + + public byte[] ToByteArrayWithRevoke() + { + var buf = new List(); + if (ProtocolMsg != null) + ProtoEncoder.WriteMessage(buf, 12, ProtocolMsg.ToByteArray()); + return [.. buf]; + } + + /// + /// Returns the ContextInfo (quoted message metadata) from whichever sub-message contains it. + /// + public ContextInfo? GetContextInfo() + { + var inner = DeviceSentMessage?.Message; + if (inner != null) return inner.GetContextInfo(); + return ImageMessage?.ContextInfo + ?? AudioMessage?.ContextInfo + ?? VideoMessage?.ContextInfo + ?? ExtendedTextMessage?.ContextInfo; + } + + /// + /// Returns quoted message context (id, sender, text preview, type) for reply display. + /// + public (string? quotedId, string? quotedFrom, string? quotedText, Messages.MessageType quotedType) GetQuotedContext() + { + var ctx = GetContextInfo(); + if (ctx == null || string.IsNullOrEmpty(ctx.StanzaId)) + return (null, null, null, Messages.MessageType.Unknown); + + var quotedText = ctx.QuotedMessage?.GetText(); + var quotedType = ctx.QuotedMessage?.GetMessageType() ?? Messages.MessageType.Unknown; + return (ctx.StanzaId, ctx.Participant, quotedText, quotedType); + } + + public byte[] ToByteArrayWithMedia() + { + var buf = new List(); + if (ImageMessage != null) ProtoEncoder.WriteMessage(buf, 3, ImageMessage.ToByteArray()); + if (AudioMessage != null) ProtoEncoder.WriteMessage(buf, 4, AudioMessage.ToByteArray()); + if (DocumentMessage != null) ProtoEncoder.WriteMessage(buf, 15, DocumentMessage.ToByteArray()); + return [.. buf]; + } } +/// Field 6 of Message. Contains text with optional link preview etc. public sealed class ExtendedTextMessage { - public string Text { get; set; } = ""; + public string Text { get; set; } = ""; // field 1 + public ContextInfo? ContextInfo { get; set; } // field 17 public byte[] ToByteArray() { var buf = new List(); ProtoEncoder.WriteString(buf, 1, Text); + if (ContextInfo != null) ProtoEncoder.WriteMessage(buf, 17, ContextInfo.ToByteArray()); return [.. buf]; } @@ -201,9 +549,671 @@ public static ExtendedTextMessage ParseFrom(byte[] data) while (r.HasMore) { var (field, wire) = r.ReadTag(); - if (field == 1) msg.Text = r.ReadString(); - else r.Skip(wire); + switch (field) + { + case 1: msg.Text = r.ReadString(); break; + case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 3 of Message. An image with optional caption. +public sealed class ImageMessage +{ + public string Url { get; set; } = ""; // field 1 + public string MimeType { get; set; } = ""; // field 2 + public string Caption { get; set; } = ""; // field 3 + public byte[] FileSha256 { get; set; } = []; // field 4 + public ulong FileLength { get; set; } // field 5 + public uint Height { get; set; } // field 6 + public uint Width { get; set; } // field 7 + public byte[] MediaKey { get; set; } = []; // field 8 + public byte[] FileEncSha256 { get; set; } = []; // field 9 + public string DirectPath { get; set; } = ""; // field 11 + public long MediaKeyTimestamp { get; set; } // field 12 + public byte[] JpegThumbnail { get; set; } = []; // field 16 + public ContextInfo? ContextInfo { get; set; } // field 17 + public bool ViewOnce { get; set; } // field 25 + + public byte[] ToByteArray() + { + var buf = new List(); + ProtoEncoder.WriteString(buf, 1, Url); + ProtoEncoder.WriteString(buf, 2, MimeType); + if (!string.IsNullOrEmpty(Caption)) ProtoEncoder.WriteString(buf, 3, Caption); + if (FileSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 4, FileSha256); + if (FileLength > 0) ProtoEncoder.WriteUInt64(buf, 5, FileLength); + if (Height > 0) ProtoEncoder.WriteUInt32(buf, 6, Height); + if (Width > 0) ProtoEncoder.WriteUInt32(buf, 7, Width); + if (MediaKey.Length > 0) ProtoEncoder.WriteBytes(buf, 8, MediaKey); + if (FileEncSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 9, FileEncSha256); + ProtoEncoder.WriteString(buf, 11, DirectPath); + if (MediaKeyTimestamp != 0) ProtoEncoder.WriteUInt64(buf, 12, (ulong)MediaKeyTimestamp); + if (JpegThumbnail.Length > 0) ProtoEncoder.WriteBytes(buf, 16, JpegThumbnail); + if (ContextInfo != null) ProtoEncoder.WriteMessage(buf, 17, ContextInfo.ToByteArray()); + return [.. buf]; + } + + public static ImageMessage ParseFrom(byte[] data) + { + var msg = new ImageMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Url = r.ReadString(); break; + case 2: msg.MimeType = r.ReadString(); break; + case 3: msg.Caption = r.ReadString(); break; + case 4: msg.FileSha256 = r.ReadBytes(); break; + case 5: msg.FileLength = r.ReadUInt64(); break; + case 6: msg.Height = r.ReadUInt32(); break; + case 7: msg.Width = r.ReadUInt32(); break; + case 8: msg.MediaKey = r.ReadBytes(); break; + case 9: msg.FileEncSha256 = r.ReadBytes(); break; + case 11: msg.DirectPath = r.ReadString(); break; + case 12: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break; + case 16: msg.JpegThumbnail = r.ReadBytes(); break; + case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break; + case 25: msg.ViewOnce = r.ReadUInt64() != 0; break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 4 of Message. An audio clip or voice note (ptt=true). +public sealed class AudioMessage +{ + public string Url { get; set; } = ""; // field 1 + public string MimeType { get; set; } = ""; // field 2 + public byte[] FileSha256 { get; set; } = []; // field 3 + public ulong FileLength { get; set; } // field 4 + public uint Seconds { get; set; } // field 5 (duration) + public bool Ptt { get; set; } // field 6 (push-to-talk = voice note) + public byte[] MediaKey { get; set; } = []; // field 7 + public byte[] FileEncSha256 { get; set; } = []; // field 8 + public string DirectPath { get; set; } = ""; // field 9 + public long MediaKeyTimestamp { get; set; } // field 12 + public ContextInfo? ContextInfo { get; set; } // field 17 + + public byte[] ToByteArray() + { + var buf = new List(); + ProtoEncoder.WriteString(buf, 1, Url); + ProtoEncoder.WriteString(buf, 2, MimeType); + if (FileSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 3, FileSha256); + if (FileLength > 0) ProtoEncoder.WriteUInt64(buf, 4, FileLength); + if (Seconds > 0) ProtoEncoder.WriteUInt32(buf, 5, Seconds); + if (Ptt) ProtoEncoder.WriteBool(buf, 6, Ptt); + if (MediaKey.Length > 0) ProtoEncoder.WriteBytes(buf, 7, MediaKey); + if (FileEncSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 8, FileEncSha256); + ProtoEncoder.WriteString(buf, 9, DirectPath); + if (MediaKeyTimestamp != 0) ProtoEncoder.WriteUInt64(buf, 12, (ulong)MediaKeyTimestamp); + if (ContextInfo != null) ProtoEncoder.WriteMessage(buf, 17, ContextInfo.ToByteArray()); + return [.. buf]; + } + + public static AudioMessage ParseFrom(byte[] data) + { + var msg = new AudioMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Url = r.ReadString(); break; + case 2: msg.MimeType = r.ReadString(); break; + case 3: msg.FileSha256 = r.ReadBytes(); break; + case 4: msg.FileLength = r.ReadUInt64(); break; + case 5: msg.Seconds = r.ReadUInt32(); break; + case 6: msg.Ptt = r.ReadBool(); break; + case 7: msg.MediaKey = r.ReadBytes(); break; + case 8: msg.FileEncSha256 = r.ReadBytes(); break; + case 9: msg.DirectPath = r.ReadString(); break; + case 12: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break; + case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 5 of Message. A video with optional caption. +public sealed class VideoMessage +{ + public string Url { get; set; } = ""; // field 1 + public string MimeType { get; set; } = ""; // field 2 + public byte[] FileSha256 { get; set; } = []; // field 3 + public ulong FileLength { get; set; } // field 4 + public uint Seconds { get; set; } // field 5 + public byte[] MediaKey { get; set; } = []; // field 6 + public string Caption { get; set; } = ""; // field 7 + public bool GifPlayback { get; set; } // field 8 + public uint Height { get; set; } // field 9 + public uint Width { get; set; } // field 10 + public byte[] FileEncSha256 { get; set; } = []; // field 11 + public string DirectPath { get; set; } = ""; // field 13 + public long MediaKeyTimestamp { get; set; } // field 14 + public ContextInfo? ContextInfo { get; set; } // field 17 + + public static VideoMessage ParseFrom(byte[] data) + { + var msg = new VideoMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Url = r.ReadString(); break; + case 2: msg.MimeType = r.ReadString(); break; + case 3: msg.FileSha256 = r.ReadBytes(); break; + case 4: msg.FileLength = r.ReadUInt64(); break; + case 5: msg.Seconds = r.ReadUInt32(); break; + case 6: msg.MediaKey = r.ReadBytes(); break; + case 7: msg.Caption = r.ReadString(); break; + case 8: msg.GifPlayback = r.ReadUInt64() != 0; break; + case 9: msg.Height = r.ReadUInt32(); break; + case 10: msg.Width = r.ReadUInt32(); break; + case 11: msg.FileEncSha256 = r.ReadBytes(); break; + case 13: msg.DirectPath = r.ReadString(); break; + case 14: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break; + case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 50 of Message. A sticker. +public sealed class StickerMessage +{ + public string Url { get; set; } = ""; // field 1 + public byte[] FileSha256 { get; set; } = []; // field 2 + public byte[] FileEncSha256 { get; set; } = []; // field 3 + public byte[] MediaKey { get; set; } = []; // field 4 + public string MimeType { get; set; } = ""; // field 5 + public uint Height { get; set; } // field 6 + public uint Width { get; set; } // field 7 + public string DirectPath { get; set; } = ""; // field 8 + public ulong FileLength { get; set; } // field 9 + public long MediaKeyTimestamp { get; set; } // field 10 + public ContextInfo? ContextInfo { get; set; } // field 17 + + public static StickerMessage ParseFrom(byte[] data) + { + var msg = new StickerMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Url = r.ReadString(); break; + case 2: msg.FileSha256 = r.ReadBytes(); break; + case 3: msg.FileEncSha256 = r.ReadBytes(); break; + case 4: msg.MediaKey = r.ReadBytes(); break; + case 5: msg.MimeType = r.ReadString(); break; + case 6: msg.Height = r.ReadUInt32(); break; + case 7: msg.Width = r.ReadUInt32(); break; + case 8: msg.DirectPath = r.ReadString(); break; + case 9: msg.FileLength = r.ReadUInt64(); break; + case 10: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break; + case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break; + default: r.Skip(wire); break; + } } return msg; } } + +/// Field 15 of Message. A document/file attachment. +public sealed class DocumentMessage +{ + public string Url { get; set; } = ""; // field 1 + public string MimeType { get; set; } = ""; // field 2 + public string Title { get; set; } = ""; // field 3 + public byte[] FileSha256 { get; set; } = []; // field 4 + public ulong FileLength { get; set; } // field 5 + public byte[] MediaKey { get; set; } = []; // field 7 + public string FileName { get; set; } = ""; // field 8 + public byte[] FileEncSha256 { get; set; } = []; // field 9 + public string DirectPath { get; set; } = ""; // field 10 + public long MediaKeyTimestamp { get; set; } // field 11 + + public byte[] ToByteArray() + { + var buf = new List(); + ProtoEncoder.WriteString(buf, 1, Url); + ProtoEncoder.WriteString(buf, 2, MimeType); + ProtoEncoder.WriteString(buf, 3, Title); + if (FileSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 4, FileSha256); + if (FileLength > 0) ProtoEncoder.WriteUInt64(buf, 5, FileLength); + if (MediaKey.Length > 0) ProtoEncoder.WriteBytes(buf, 7, MediaKey); + ProtoEncoder.WriteString(buf, 8, FileName); + if (FileEncSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 9, FileEncSha256); + ProtoEncoder.WriteString(buf, 10, DirectPath); + if (MediaKeyTimestamp != 0) ProtoEncoder.WriteUInt64(buf, 11, (ulong)MediaKeyTimestamp); + return [.. buf]; + } + + public static DocumentMessage ParseFrom(byte[] data) + { + var msg = new DocumentMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Url = r.ReadString(); break; + case 2: msg.MimeType = r.ReadString(); break; + case 3: msg.Title = r.ReadString(); break; + case 4: msg.FileSha256 = r.ReadBytes(); break; + case 5: msg.FileLength = r.ReadUInt64(); break; + case 7: msg.MediaKey = r.ReadBytes(); break; + case 8: msg.FileName = r.ReadString(); break; + case 9: msg.FileEncSha256 = r.ReadBytes(); break; + case 10: msg.DirectPath = r.ReadString(); break; + case 11: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 31 of Message. Wraps messages sent from this account on another device. +public sealed class DeviceSentMessage +{ + public string? DestinationJid { get; set; } // field 1 + public WAMessage? Message { get; set; } // field 2 + + public byte[] ToByteArray() + { + var buf = new List(); + if (!string.IsNullOrEmpty(DestinationJid)) + ProtoEncoder.WriteString(buf, 1, DestinationJid); + if (Message != null) + ProtoEncoder.WriteMessage(buf, 2, Message.ToByteArray()); + return [.. buf]; + } + + public static DeviceSentMessage ParseFrom(byte[] data) + { + var msg = new DeviceSentMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.DestinationJid = r.ReadString(); break; + case 2: msg.Message = WAMessage.ParseFrom(r.ReadBytes()); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 2 of Message. Distributes group encryption keys. +public sealed class SenderKeyDistributionMessage +{ + public string? GroupId { get; set; } // field 1 + public byte[] AxolotlSenderKeyData { get; set; } = []; // field 2 + + public static SenderKeyDistributionMessage ParseFrom(byte[] data) + { + var msg = new SenderKeyDistributionMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.GroupId = r.ReadString(); break; + case 2: msg.AxolotlSenderKeyData = r.ReadBytes(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 12 of Message. Protocol-level messages (message revocation, history sync, etc.) +public sealed class ProtocolMessage +{ + public int Type { get; set; } // field 5: type enum + // field 1: key (MessageKey) — the message being revoked (for REVOKE) + public MessageKey? Key { get; set; } + // field 6: historySyncNotification (populated when Type == TYPE_HISTORY_SYNC_NOTIFICATION) + public Dawa.Proto.HistorySyncNotification? HistorySyncNotification { get; set; } + + public const int TYPE_REVOKE = 0; + public const int TYPE_HISTORY_SYNC_NOTIFICATION = 5; + + public static ProtocolMessage ParseFrom(byte[] data) + { + var msg = new ProtocolMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Key = MessageKey.ParseFrom(r.ReadBytes()); break; + case 5: msg.Type = r.ReadInt32(); break; + case 6: + var notifBytes = r.ReadBytes(); + msg.HistorySyncNotification = Dawa.Proto.HistorySyncNotification.Decode(notifBytes); + break; + default: r.Skip(wire); break; + } + } + return msg; + } + + public byte[] ToByteArray() + { + var buf = new List(); + if (Key != null) ProtoEncoder.WriteMessage(buf, 1, Key.ToByteArray()); + ProtoEncoder.WriteInt32(buf, 5, Type); + return [.. buf]; + } +} + +/// ContextInfo — quoted message metadata embedded in most message types (field 17). +public sealed class ContextInfo +{ + public string StanzaId { get; set; } = ""; // field 4: ID of the quoted message + public string Participant { get; set; } = ""; // field 5: JID of the quoted message sender + public WAMessage? QuotedMessage { get; set; } // field 6: the quoted message content + public bool IsForwarded { get; set; } // field 15: forwarded flag + public uint ForwardingScore { get; set; } // field 22: >0 means forwarded + + public static ContextInfo ParseFrom(byte[] data) + { + var msg = new ContextInfo(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 4: msg.StanzaId = r.ReadString(); break; + case 5: msg.Participant = r.ReadString(); break; + case 6: msg.QuotedMessage = WAMessage.ParseFrom(r.ReadBytes()); break; + case 15: msg.IsForwarded = r.ReadBool(); break; + case 22: msg.ForwardingScore = r.ReadUInt32(); break; + default: r.Skip(wire); break; + } + } + return msg; + } + + public byte[] ToByteArray() + { + var buf = new List(); + if (!string.IsNullOrEmpty(StanzaId)) ProtoEncoder.WriteString(buf, 4, StanzaId); + if (!string.IsNullOrEmpty(Participant)) ProtoEncoder.WriteString(buf, 5, Participant); + if (QuotedMessage != null) ProtoEncoder.WriteMessage(buf, 6, QuotedMessage.ToByteArray()); + if (IsForwarded) ProtoEncoder.WriteBool(buf, 15, true); + if (ForwardingScore > 0) ProtoEncoder.WriteUInt32(buf, 22, ForwardingScore); + return [.. buf]; + } +} + +/// A single message inside a HistorySyncConversation. +public sealed class WebMessageInfo +{ + public WebMessageKey? Key { get; set; } // field 1 + public WAMessage? Message { get; set; } // field 2 + public ulong MessageTimestamp { get; set; } // field 3 + public int Status { get; set; } // field 4 (delivery status) + public string PushName { get; set; } = ""; // field 5 + + public static WebMessageInfo ParseFrom(byte[] data) + { + var msg = new WebMessageInfo(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Key = WebMessageKey.ParseFrom(r.ReadBytes()); break; + case 2: msg.Message = WAMessage.ParseFrom(r.ReadBytes()); break; + case 3: msg.MessageTimestamp = r.ReadUInt64(); break; + case 4: msg.Status = r.ReadInt32(); break; + case 5: msg.PushName = r.ReadString(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Message key inside WebMessageInfo. +public sealed class WebMessageKey +{ + public string RemoteJid { get; set; } = ""; // field 1 + public bool FromMe { get; set; } // field 2 + public string Id { get; set; } = ""; // field 3 + public string Participant { get; set; } = ""; // field 4 (group sender) + + public static WebMessageKey ParseFrom(byte[] data) + { + var msg = new WebMessageKey(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.RemoteJid = r.ReadString(); break; + case 2: msg.FromMe = r.ReadBool(); break; + case 3: msg.Id = r.ReadString(); break; + case 4: msg.Participant = r.ReadString(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 35 of Message. Contains device list metadata. +public sealed class MessageContextInfo +{ + public byte[] DeviceListMetadata { get; set; } = []; // field 1 + + public static MessageContextInfo ParseFrom(byte[] data) + { + var msg = new MessageContextInfo(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.DeviceListMetadata = r.ReadBytes(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// Field 85 of Message. Emoji reaction to another message. +public sealed class ReactionMessage +{ + /// The message being reacted to. + public MessageKey? Key { get; set; } // field 1 + /// The reaction emoji (e.g. "👍") or "" to remove reaction. + public string Text { get; set; } = ""; // field 2 + /// Sender timestamp in milliseconds. + public long SenderTimestampMs { get; set; } // field 4 + + public static ReactionMessage ParseFrom(byte[] data) + { + var msg = new ReactionMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.Key = MessageKey.ParseFrom(r.ReadBytes()); break; + case 2: msg.Text = r.ReadString(); break; + case 4: msg.SenderTimestampMs = (long)r.ReadUInt64(); break; + default: r.Skip(wire); break; + } + } + return msg; + } + + public byte[] ToByteArray() + { + var buf = new List(); + if (Key != null) ProtoEncoder.WriteMessage(buf, 1, Key.ToByteArray()); + if (!string.IsNullOrEmpty(Text)) ProtoEncoder.WriteString(buf, 2, Text); + if (SenderTimestampMs != 0) ProtoEncoder.WriteUInt64(buf, 4, (ulong)SenderTimestampMs); + return [.. buf]; + } +} + +/// +/// Field 145 of WAMessage. +/// Sent to our own JID to request the phone push an ON_DEMAND history sync blob +/// for a specific chat via HISTORY_SYNC_NOTIFICATION (syncType 5). +/// +public sealed class PeerDataOperationRequestMessage +{ + public const int TYPE_HISTORY_SYNC_ON_DEMAND = 7; // PeerDataOperationRequestType enum + + public int RequestType { get; set; } = TYPE_HISTORY_SYNC_ON_DEMAND; // field 1 + public string ChatJid { get; set; } = ""; // historySyncOnDemandRequest.chatJid (field 6 → sub-field 1) + public string OldestMsgId { get; set; } = ""; // historySyncOnDemandRequest.oldestMsgId (field 6 → sub-field 2) + public bool OldestMsgFromMe { get; set; } // historySyncOnDemandRequest.oldestMsgFromMe (field 6 → sub-field 3) + public int OnDemandMsgCount { get; set; } = 50; // historySyncOnDemandRequest.onDemandMsgCount (field 6 → sub-field 4) + + public byte[] ToByteArray() + { + var buf = new List(); + // field 1: peerDataOperationRequestType + ProtoEncoder.WriteInt32(buf, 1, RequestType); + + // field 6: historySyncOnDemandRequest (embedded message) + var req = new List(); + ProtoEncoder.WriteString(req, 1, ChatJid); + if (!string.IsNullOrEmpty(OldestMsgId)) ProtoEncoder.WriteString(req, 2, OldestMsgId); + if (OldestMsgFromMe) ProtoEncoder.WriteBool(req, 3, true); + ProtoEncoder.WriteInt32Always(req, 4, OnDemandMsgCount); // always emit count even if 0 + ProtoEncoder.WriteMessage(buf, 6, [.. req]); + + return [.. buf]; + } +} + +/// +/// Field 146 of WAMessage — response sent by the phone to a PeerDataOperationRequestMessage. +/// For ON_DEMAND history, historySyncOnDemandRequestResult.historyData contains +/// an inline compressed+encrypted history blob (same format as CDN download). +/// +public sealed class PeerDataOperationResponseMessage +{ + /// List of results, one per request item. + public List Results { get; set; } = []; + + public static PeerDataOperationResponseMessage Decode(byte[] data) + { + var obj = new PeerDataOperationResponseMessage(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + if (field == 1) obj.Results.Add(PeerDataOperationResult.Decode(r.ReadBytes())); + else r.Skip(wt); + } + return obj; + } +} + +public sealed class PeerDataOperationResult +{ + public int ResultType { get; set; } // field 1: 0=OK, 1=UNSUPPORTED, 2=NOT_FOUND + public byte[] HistoryData { get; set; } = []; // field 6 → historySyncOnDemandRequestResult.historyData (sub-field 1) + + public static PeerDataOperationResult Decode(byte[] data) + { + var obj = new PeerDataOperationResult(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wt) = r.ReadTag(); + switch (field) + { + case 1: obj.ResultType = r.ReadInt32(); break; + case 6: obj.HistoryData = ReadHistoryData(r.ReadBytes()); break; + default: r.Skip(wt); break; + } + } + return obj; + } + + private static byte[] ReadHistoryData(byte[] resultBytes) + { + var r = ProtoEncoder.CreateReader(resultBytes); + while (r.HasMore) + { + var (f, wt) = r.ReadTag(); + if (f == 1) return r.ReadBytes(); + r.Skip(wt); + } + return []; + } +} + +/// Key that identifies a specific WhatsApp message. +public sealed class MessageKey +{ + public string RemoteJid { get; set; } = ""; // field 1 + public bool FromMe { get; set; } // field 2 + public string Id { get; set; } = ""; // field 3 + + public static MessageKey ParseFrom(byte[] data) + { + var msg = new MessageKey(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.RemoteJid = r.ReadString(); break; + case 2: msg.FromMe = r.ReadBool(); break; + case 3: msg.Id = r.ReadString(); break; + default: r.Skip(wire); break; + } + } + return msg; + } + + public byte[] ToByteArray() + { + var buf = new List(); + if (!string.IsNullOrEmpty(RemoteJid)) ProtoEncoder.WriteString(buf, 1, RemoteJid); + if (FromMe) ProtoEncoder.WriteBool(buf, 2, FromMe); + if (!string.IsNullOrEmpty(Id)) ProtoEncoder.WriteString(buf, 3, Id); + return [.. buf]; + } +} diff --git a/src/Dawa/Signal/LID_SUPPORT_DESIGN.md b/src/Dawa/Signal/LID_SUPPORT_DESIGN.md new file mode 100644 index 0000000..50d91d0 --- /dev/null +++ b/src/Dawa/Signal/LID_SUPPORT_DESIGN.md @@ -0,0 +1,118 @@ +# Dawa LID Support — Root Cause & Fix Design (2026-07-03) + +## Problem + +Incoming message decryption fails ~50% of the time (1790 MAC OK vs 1688 MAC FAIL in the +production log tail), concentrated on `@lid` (WhatsApp LinkedID) addresses. Effect: since +~2026-06-05 almost no new incoming messages reach the message-store, so team replies are +invisible to jengo-agi/jengo-web. Outgoing works fine. + +## Background: what LID is + +WhatsApp is moving the identity of a contact from the phone number +(`@s.whatsapp.net`, "PN") to a stable Linked-ID (`@lid`, "LID"). The two +JIDs refer to the **same user and same devices** but are different users in the protocol +namespace. The device id carries through unchanged across namespaces +(`:@s.whatsapp.net` ↔ `:@lid`). + +The reference library **Baileys** treats the **LID as the canonical Signal session +address** whenever a lid↔pn mapping is known. There is exactly ONE logical Double-Ratchet +session per peer-device; once the LID is known the session must live under the LID slot and +the PN slot is deleted (migrated). + +## Root causes in Dawa (definitive) + +1. **Two LID maps that never sync (SMOKING GUN).** + `NoiseProcessor._lidToPhone` learns rich mappings from stanza attributes + (`participant_pn`, `sender_lid`) on every inbound message. `SignalKeyStore._lidToPhone` + is a *separate* map, populated only by `TryResolveByIdentity` during pkmsg decrypt and + persisted to `lid-mapping.json`. **`NoiseProcessor` never calls + `_signalStore.RegisterLidMapping(...)`** — grep proves the method has no external caller. + So the decrypt path's `ResolveJid` is starved of the mappings NoiseProcessor already knows. + +2. **Wrong canonicalization direction.** + Dawa resolves LID→PN (`SignalKeyStore.ResolveJid`, phone-canonical). WhatsApp uses + LID-canonical. An `@lid` sender with **no** phone mapping (e.g. `15432119529534@lid`, + 778 failures) can never be collapsed onto a phone session, so it lives as a separate + lid-keyed session and any interleaving with a phone-keyed session for the same peer + splits the ratchet → permanent MAC failure. + +3. **No session migration.** + When a mapping is finally learned, the existing session under the old address is not + copied to the canonical address. The ratchet state is split across two session records + → Bad MAC on the next regular `msg`. + +4. **Map keyed by full JID incl. device.** + `_lidToPhone` keys include the device suffix (`:78`, `:86`). Mapping must be by **user + part only**, re-attaching the same device id when reconstructing full JIDs. + +5. **Storage under @lid keys is invisible to reads.** + 39 of 45 chats in message-store.json are under `@lid` keys; `getMessages(phone)` never + finds them. (Separate but related surfacing bug.) + +## Fix design (Baileys-correct) + +### Canonical address = LID whenever a lid↔pn mapping is known + +- Session/identity store key derived from the resolved **LID** address, not the PN. +- One shared identity key stored once under the canonical (LID) address (TOFU). + +### Unified bidirectional mapping, keyed by user part + +- Single source of truth. `NoiseProcessor` must push every learned mapping into + `SignalKeyStore` (call `RegisterLidMapping`) so decrypt sees it. +- Store forward `pnUser→lidUser` and reverse `lidUser→pnUser`, keyed by user part only; + strip device before mapping, re-attach when reconstructing. + +### Learn + migrate BEFORE decrypt + +- On every inbound stanza carrying an alt-address attr (`participant_pn`/`sender_lid`/ + `participant_lid`/`sender_pn`), store the mapping, then run PN→LID `MigrateSession` + **before** the decrypt attempt. +- `MigrateSession(fromPn, toLid)`: for every device (incl. device 0) with an open PN + session, copy the serialized session to the LID slot and delete the PN slot. Guard on + "session established". PN→LID direction only. + +### Storage-layer redirect + +- Every session/identity load AND store for a PN address first resolves the mapping and + rewrites to the LID slot if mapped. Single wrapper — prevents most failures even if a + caller passes a PN address. + +## Rollout / safety + +- Outgoing (encrypt) shares the session store + address resolution → must be preserved. +- Backup the deployed `Dawa.dll` + `whatsapp-sessions/` before deploy; verify outgoing with + a live test send immediately after; watch MAC OK/FAIL ratio; instant rollback on regression. +- Ideal: capture a real failing frame + session snapshot and validate the fix in an offline + replay harness before the prod cut-over. + +## Chosen implementation: phase 1 = phone-canonical collapse (LOW RISK) + +Rather than flip the whole model to LID-canonical (which changes the address outgoing +encrypts under, risking the working send path), phase 1 keeps Dawa **phone-canonical** and +fixes the two dominant root causes: + +1. **Sync the maps** — `NoiseProcessor` propagates every learned lid↔pn mapping into + `SignalKeyStore.RegisterLidMapping` so the decrypt path's `ResolveJid` sees them. +2. **Collapse on learn** — `RegisterLidMapping` migrates any lid-keyed session onto the + canonical phone slot (per device), so messages addressed either way share one ratchet. +3. **User-part + device-aware resolution** — `ResolveJid` matches by user part and + reconstructs the phone JID preserving the device id. +4. **Storage normalization** — inbound `RemoteJid` normalized to phone when mapped, so + `getMessages(phone)` surfaces the messages. + +Why safe for outgoing: `EncryptMessage` looks sessions up directly (no `ResolveJid`), and +outgoing device JIDs are phone JIDs, so `ResolveJid(phone)→phone` is unchanged. Migration +only ever deletes the redundant LID slot, never the phone slot outgoing uses. + +Unmapped pure-LID contacts (no phone ever provided) stay lid-keyed and consistent — correct. +Phase 2 (full LID-canonical, Baileys-style) only needed if WhatsApp stops providing the PN +alt-attrs entirely. + +## Reference + +Baileys source (canonical algorithm): `src/Signal/libsignal.ts` +(`jidToSignalProtocolAddress`, `resolveLIDSignalAddress`, `migrateSession`) and +`src/Signal/lid-mapping.ts` (`LIDMappingStore`). Cloned at `E:\temp\baileys-src\src`. +See also whatsmeow #1027 (identical PN→LID migration failure in a from-scratch Go impl). diff --git a/src/Dawa/Signal/MessageCipher.cs b/src/Dawa/Signal/MessageCipher.cs index bd35fee..8edcab7 100644 --- a/src/Dawa/Signal/MessageCipher.cs +++ b/src/Dawa/Signal/MessageCipher.cs @@ -1,99 +1,32 @@ using System.Security.Cryptography; -using Dawa.Crypto; namespace Dawa.Signal; /// -/// Encrypts and decrypts WhatsApp messages using the Signal Double Ratchet algorithm. +/// Low-level AES-CBC helpers used by SignalKeyStore for message encryption/decryption. +/// The Double Ratchet logic lives in SignalKeyStore; this class is a pure static utility. /// -public sealed class MessageCipher +public static class MessageCipher { - private readonly SignalKeyStore _keyStore; - - public MessageCipher(SignalKeyStore keyStore) - { - _keyStore = keyStore; - } - - /// - /// Encrypts a plaintext message for a recipient. Returns encrypted bytes + a pre-key message header. - /// - public (byte[] ciphertext, bool isPreKey) Encrypt(string recipientJid, byte[] plaintext) - { - var session = _keyStore.GetSession(recipientJid); - if (session == null) - throw new InvalidOperationException($"No Signal session for {recipientJid}. Must pair first."); - - var (messageKey, nextChainKey) = _keyStore.DeriveMessageKeys(session.SendChainKey); - session.SendChainKey = nextChainKey; - - // Derive AES and HMAC keys from the message key via HKDF - var keyMaterial = DawaHKDF.DeriveKey(messageKey, new byte[32], null, 80); - var cipherKey = keyMaterial[..32]; - var macKey = keyMaterial[32..64]; - var iv = keyMaterial[64..80]; - - // Encrypt with AES-CBC (Signal uses AES-CBC, not GCM) - var ciphertext = AesCbcEncrypt(cipherKey, iv, plaintext); - - // MAC over version(1) + iv(16) + ciphertext - var macInput = new byte[1 + 16 + ciphertext.Length]; - macInput[0] = 3; // Signal version - iv.CopyTo(macInput, 1); - ciphertext.CopyTo(macInput, 17); - var mac = HMACSHA256.HashData(macKey, macInput)[..8]; - - var result = new byte[1 + 16 + ciphertext.Length + 8]; - result[0] = 3; - iv.CopyTo(result, 1); - ciphertext.CopyTo(result, 17); - mac.CopyTo(result, 17 + ciphertext.Length); - - session.SendCounter++; - return (result, false); - } - - /// - /// Decrypts a received Signal message. - /// - public byte[] Decrypt(string senderJid, byte[] encryptedMessage, bool isPreKey) - { - var session = _keyStore.GetSession(senderJid); - if (session == null) - throw new InvalidOperationException($"No Signal session for {senderJid}."); - - var (messageKey, nextChainKey) = _keyStore.DeriveMessageKeys(session.ReceiveChainKey); - session.ReceiveChainKey = nextChainKey; - - var keyMaterial = DawaHKDF.DeriveKey(messageKey, new byte[32], null, 80); - var cipherKey = keyMaterial[..32]; - var macKey = keyMaterial[32..64]; - var iv = keyMaterial[64..80]; - - // Strip version(1) + iv(16) + mac(8) - if (encryptedMessage.Length < 25) throw new CryptographicException("Message too short."); - var ciphertext = encryptedMessage[17..(encryptedMessage.Length - 8)]; - - return AesCbcDecrypt(cipherKey, iv, ciphertext); - } - - private static byte[] AesCbcEncrypt(byte[] key, byte[] iv, byte[] plaintext) + /// AES-CBC encrypt with PKCS7 padding. + public static byte[] AesCbcEncrypt(byte[] key, byte[] iv, byte[] plaintext) { using var aes = Aes.Create(); - aes.Key = key; - aes.IV = iv; - aes.Mode = CipherMode.CBC; + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; using var enc = aes.CreateEncryptor(); return enc.TransformFinalBlock(plaintext, 0, plaintext.Length); } - private static byte[] AesCbcDecrypt(byte[] key, byte[] iv, byte[] ciphertext) + /// AES-CBC decrypt with PKCS7 padding. + public static byte[] AesCbcDecrypt(byte[] key, byte[] iv, byte[] ciphertext) { using var aes = Aes.Create(); - aes.Key = key; - aes.IV = iv; - aes.Mode = CipherMode.CBC; + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; using var dec = aes.CreateDecryptor(); return dec.TransformFinalBlock(ciphertext, 0, ciphertext.Length); diff --git a/src/Dawa/Signal/SignalKeyStore.cs b/src/Dawa/Signal/SignalKeyStore.cs index 920883a..cf4d500 100644 --- a/src/Dawa/Signal/SignalKeyStore.cs +++ b/src/Dawa/Signal/SignalKeyStore.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Dawa.Auth; @@ -7,94 +8,849 @@ namespace Dawa.Signal; /// -/// Stores and manages Signal Protocol keys: sessions, pre-keys, signed pre-keys. -/// Persists to the session directory alongside the auth credentials. +/// Complete Signal Protocol session management — X3DH + Double Ratchet. +/// Handles session establishment (outgoing and incoming), message encryption, and decryption. +/// Persists sessions to signals.json in the session directory. /// public sealed class SignalKeyStore { private readonly string _directory; private readonly Dictionary _sessions = new(); + private readonly Dictionary _lidToPhone = new(); // LID JID → phone JID mapping - private static readonly JsonSerializerOptions _jsonOpts = new() { WriteIndented = true }; + private static readonly JsonSerializerOptions _jsonOpts = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + + private string SessionsFilePath => Path.Combine(_directory, "signals.json"); + private string LidMappingFilePath => Path.Combine(_directory, "lid-mapping.json"); public SignalKeyStore(string directory) { _directory = directory; Directory.CreateDirectory(directory); + LoadSessions(); + LoadLidMappings(); } // ─── Session management ─────────────────────────────────────────────────── - public SignalSession? GetSession(string jid) - => _sessions.TryGetValue(jid, out var s) ? s : null; + public SignalSession? GetSession(string jid) => + _sessions.TryGetValue(ResolveJid(jid), out var s) ? s : null; public void PutSession(string jid, SignalSession session) - => _sessions[jid] = session; + { + _sessions[jid] = session; + SaveSessions(); + } + + public bool HasSession(string jid) => _sessions.ContainsKey(ResolveJid(jid)); + + public void DeleteSession(string jid) + { + _sessions.Remove(ResolveJid(jid)); + SaveSessions(); + } + + // ─── LID ↔ Phone JID mapping ──────────────────────────────────────────── + + /// + /// Registers a mapping from a LID JID (e.g. "824767959274@lid") to a phone JID + /// (e.g. "31633984381@s.whatsapp.net"). Both formats refer to the same user/device + /// and MUST share one Signal session. Registering also collapses (migrates) any + /// existing lid-keyed session onto the canonical phone slot so the Double-Ratchet + /// state is not split across two records — the primary cause of MAC failures on + /// LID senders. Phone-canonical: the phone slot is the survivor. + /// + public void RegisterLidMapping(string lidJid, string phoneJid) + { + if (string.IsNullOrEmpty(lidJid) || string.IsNullOrEmpty(phoneJid)) return; + if (!lidJid.Contains("@lid") || !phoneJid.Contains("@s.whatsapp.net")) return; - public bool HasSession(string jid) => _sessions.ContainsKey(jid); + var changed = !_lidToPhone.TryGetValue(lidJid, out var existing) || existing != phoneJid; + if (changed) + { + _lidToPhone[lidJid] = phoneJid; + SaveLidMappings(); + } - // ─── Pre-key operations ─────────────────────────────────────────────────── + // Collapse any lid-keyed session(s) for this user onto the canonical phone slot. + MigrateLidSessionsToPhone(UserPart(lidJid), UserPart(phoneJid)); + } - public async Task GetPreKeyAsync(uint id, AuthState auth) + /// + /// Resolves a JID to its canonical Signal session address (phone-canonical). + /// If it's a known LID — matched exactly or by user part (device-insensitive) — + /// returns the phone JID preserving the device id. Otherwise returns the JID as-is + /// (an unmapped pure-LID contact keeps its own consistent lid-keyed session). + /// + public string ResolveJid(string jid) { - return auth.PreKeys.FirstOrDefault(k => k.Id == id); + if (!jid.Contains("@lid")) return jid; + + // Exact match first (fast path, handles legacy full-JID mappings) + if (_lidToPhone.TryGetValue(jid, out var exact)) return exact; + + // User-part match: reconstruct the phone JID with the SAME device id. + var lidUser = UserPart(jid); + var device = DevicePart(jid); + foreach (var (k, v) in _lidToPhone) + { + if (UserPart(k) == lidUser) + { + var pnUser = UserPart(v); + return device.Length > 0 + ? $"{pnUser}:{device}@s.whatsapp.net" + : $"{pnUser}@s.whatsapp.net"; + } + } + return jid; } - public void RemovePreKey(uint id, AuthState auth) + /// User part of a JID: "31633984381:78@s.whatsapp.net" → "31633984381". + private static string UserPart(string jid) { - auth.PreKeys.RemoveAll(k => k.Id == id); + var at = jid.IndexOf('@'); + var s = at >= 0 ? jid[..at] : jid; + var colon = s.IndexOf(':'); + return colon >= 0 ? s[..colon] : s; + } + + /// Device part of a JID: "31633984381:78@s.whatsapp.net" → "78" ("" if none). + private static string DevicePart(string jid) + { + var at = jid.IndexOf('@'); + var s = at >= 0 ? jid[..at] : jid; + var colon = s.IndexOf(':'); + return colon >= 0 ? s[(colon + 1)..] : ""; + } + + /// + /// Moves every lid-keyed session for onto the matching + /// phone slot (same device id). Phone slot wins if it already exists (keep the + /// canonical ratchet); otherwise the lid session is retagged to the phone JID. + /// The lid slot is always removed afterwards so future lookups converge on one record. + /// + private void MigrateLidSessionsToPhone(string lidUser, string pnUser) + { + var lidKeys = _sessions.Keys + .Where(k => k.Contains("@lid") && UserPart(k) == lidUser) + .ToList(); + if (lidKeys.Count == 0) return; + + foreach (var lidKey in lidKeys) + { + var device = DevicePart(lidKey); + var phoneJid = device.Length > 0 + ? $"{pnUser}:{device}@s.whatsapp.net" + : $"{pnUser}@s.whatsapp.net"; + + if (!_sessions.ContainsKey(phoneJid)) + { + var sess = _sessions[lidKey]; + sess.RemoteJid = phoneJid; + _sessions[phoneJid] = sess; + } + _sessions.Remove(lidKey); + } + SaveSessions(); + } + + /// + /// For an @lid JID with no explicit mapping, try to find an existing session + /// by matching identity keys from the PreKeyWhisperMessage. + /// + private string? TryResolveByIdentity(string lidJid, byte[] theirIdentityPub) + { + foreach (var (existingJid, session) in _sessions) + { + if (existingJid.Contains("@s.whatsapp.net") && + session.TheirIdentityPublic.Length > 0 && + session.TheirIdentityPublic.SequenceEqual(theirIdentityPub)) + { + // Found matching session — register the mapping + RegisterLidMapping(lidJid, existingJid); + return existingJid; + } + } + return null; + } + + // ─── X3DH: Outgoing (initiator) ────────────────────────────────────────── + + /// + /// Initializes an outgoing session using X3DH key agreement. + /// Call this before sending the first message to a new contact. + /// + public void InitOutgoingSession(string jid, PreKeyBundle bundle, AuthState auth) + { + // Generate ephemeral key pair EK1 (used as baseKey in PreKeyWhisperMessage header) + var (ek1Priv, ek1Pub) = Curve25519Helper.GenerateKeyPair(); + + // Strip 0x05 prefix from their keys before DH operations + var theirIdentityPub = StripKeyPrefix(bundle.TheirIdentityPub); + var theirSignedPreKeyPub = StripKeyPrefix(bundle.TheirSignedPreKeyPub); + + // X3DH key agreement (WhatsApp/Signal convention): + // DH1 = DH(our_identity_priv, their_signed_prekey_pub) + // DH2 = DH(our_ephemeral_priv, their_identity_pub) + // DH3 = DH(our_ephemeral_priv, their_signed_prekey_pub) + // DH4 = DH(our_ephemeral_priv, their_one_time_prekey_pub) [if present] + var dh1 = Curve25519Helper.DH(auth.SignedIdentityKeyPrivate, theirSignedPreKeyPub); + var dh2 = Curve25519Helper.DH(ek1Priv, theirIdentityPub); + var dh3 = Curve25519Helper.DH(ek1Priv, theirSignedPreKeyPub); + + byte[]? dh4 = null; + if (bundle.TheirOneTimePreKeyPub != null && bundle.TheirOneTimePreKeyPub.Length > 0) + { + var theirOtpk = StripKeyPrefix(bundle.TheirOneTimePreKeyPub); + dh4 = Curve25519Helper.DH(ek1Priv, theirOtpk); + } + + // F = 32 bytes of 0xFF (Signal version 3 discontinuity padding) + var f = new byte[32]; + Array.Fill(f, (byte)0xFF); + + // masterSecret = F || DH1 || DH2 || DH3 [|| DH4] + var masterParts = new List { f, dh1, dh2, dh3 }; + if (dh4 != null) masterParts.Add(dh4); + var masterSecret = Concat(masterParts.ToArray()); + + // (rootKey, sharedChainKey) = HKDF(masterSecret, salt=zero32, info="WhisperText", 64) + var zeroSalt = new byte[32]; + var info = Encoding.UTF8.GetBytes("WhisperText"); + var derived = DawaHKDF.DeriveKey(masterSecret, zeroSalt, info, 64); + var rootKey0 = derived[..32]; + // sharedChainKey is derived but not directly used — we immediately do the first ratchet + + // Generate ratchet key pair (the ephemeralKeyPair in Baileys — goes into WhisperMessage) + var (ek2Priv, ek2Pub) = Curve25519Helper.GenerateKeyPair(); + + // ONE sending ratchet step: DH(ratchetKey, theirSignedPreKey) + rootKey0 + // Matches Baileys' calculateSendingRatchet(session, theirSignedPubKey) + var ratchetDH = Curve25519Helper.DH(ek2Priv, theirSignedPreKeyPub); + var (rootKey2, sendChainKey) = HkdfRatchetStep(rootKey0, ratchetDH); + + // === DEBUG: Log all intermediate X3DH values === + try + { + var logDir = Path.Combine(AppContext.BaseDirectory, "logs"); + Directory.CreateDirectory(logDir); + File.AppendAllText(Path.Combine(logDir, "signal-x3dh-debug.log"), + $"[{DateTime.UtcNow:HH:mm:ss}] InitOutgoingSession for {jid}\n" + + $" bundle.TheirIdentityPub len={bundle.TheirIdentityPub.Length} hex={Convert.ToHexString(bundle.TheirIdentityPub)}\n" + + $" bundle.TheirSignedPreKeyPub len={bundle.TheirSignedPreKeyPub.Length} hex={Convert.ToHexString(bundle.TheirSignedPreKeyPub)}\n" + + $" bundle.TheirSignedPreKeyId={bundle.TheirSignedPreKeyId}\n" + + $" bundle.TheirOneTimePreKeyId={bundle.TheirOneTimePreKeyId}\n" + + $" bundle.TheirOneTimePreKeyPub len={bundle.TheirOneTimePreKeyPub?.Length ?? 0}\n" + + $" bundle.PeerRegistrationId={bundle.PeerRegistrationId}\n" + + $" auth.SignedIdentityKeyPub={Convert.ToHexString(auth.SignedIdentityKeyPublic)}\n" + + $" auth.RegistrationId={auth.RegistrationId}\n" + + $" ek1Pub={Convert.ToHexString(ek1Pub)}\n" + + $" ek2Pub={Convert.ToHexString(ek2Pub)}\n" + + $" theirIdentityPub(stripped)={Convert.ToHexString(theirIdentityPub)}\n" + + $" theirSignedPreKeyPub(stripped)={Convert.ToHexString(theirSignedPreKeyPub)}\n" + + $" DH1={Convert.ToHexString(dh1)}\n" + + $" DH2={Convert.ToHexString(dh2)}\n" + + $" DH3={Convert.ToHexString(dh3)}\n" + + $" DH4={( dh4 != null ? Convert.ToHexString(dh4) : "null")}\n" + + $" masterSecret len={masterSecret.Length} first8={Convert.ToHexString(masterSecret[..8])}\n" + + $" rootKey0={Convert.ToHexString(rootKey0)}\n" + + $" ratchetDH={Convert.ToHexString(ratchetDH)}\n" + + $" rootKey2={Convert.ToHexString(rootKey2)}\n" + + $" sendChainKey={Convert.ToHexString(sendChainKey)}\n\n"); + } + catch { /* best effort */ } + + // Preserve existing ReceiveChainKey so incoming messages (e.g. ON_DEMAND history + // sync response) can still be decrypted after we reset the outgoing session. + var existingReceiveChainKey = GetSession(jid)?.ReceiveChainKey ?? []; + + var session = new SignalSession + { + RemoteJid = jid, + RootKey = rootKey2, + SendChainKey = sendChainKey, + ReceiveChainKey = existingReceiveChainKey, + SendCounter = 0, + ReceiveCounter = 0, + PrevSendCounter = 0, + TheirCurrentRatchetPublic = bundle.TheirSignedPreKeyPub, + OurRatchetPrivate = ek2Priv, + OurRatchetPublic = ek2Pub, + TheirIdentityPublic = theirIdentityPub, // CRITICAL: needed for MAC computation + BaseKey = ek1Pub, + PreKeyId = bundle.TheirOneTimePreKeyId, + SignedPreKeyId = bundle.TheirSignedPreKeyId, + PeerRegistrationId = bundle.PeerRegistrationId, + IsEstablished = false, // first message will be PreKeyWhisperMessage + }; + + _sessions[jid] = session; + SaveSessions(); } - // ─── Key derivation helpers ─────────────────────────────────────────────── + // ─── X3DH: Incoming (responder) ────────────────────────────────────────── /// - /// Derives a new session from a pre-key bundle (X3DH key agreement). + /// Initializes an incoming session when we receive the first PreKeyWhisperMessage. /// - public SignalSession DeriveSession( - string recipientJid, - byte[] ourIdentityPriv, - byte[] theirIdentityPub, - byte[] theirSignedPreKeyPub, - byte[] theirPreKeyPub, - byte[] ourEphemeralPriv, - byte[] ourEphemeralPub) - { - // X3DH: DH1 = DH(IK_A, SPK_B), DH2 = DH(EK_A, IK_B), - // DH3 = DH(EK_A, SPK_B), DH4 = DH(EK_A, OPK_B) - var dh1 = Curve25519Helper.DH(ourIdentityPriv, theirSignedPreKeyPub); - var dh2 = Curve25519Helper.DH(ourEphemeralPriv, theirIdentityPub); - var dh3 = Curve25519Helper.DH(ourEphemeralPriv, theirSignedPreKeyPub); - var dh4 = Curve25519Helper.DH(ourEphemeralPriv, theirPreKeyPub); - - var masterSecret = dh1.Concat(dh2).Concat(dh3).Concat(dh4).ToArray(); + public void InitIncomingSession(string jid, PreKeyWhisperMessageProto pkmsg, AuthState auth) + { + var theirIdentityPub = StripKeyPrefix(pkmsg.IdentityKey); + var baseKey = StripKeyPrefix(pkmsg.BaseKey); + + // X3DH receiver side: + // DH1 = DH(our_signed_prekey_priv, their_identity_pub) + // DH2 = DH(our_identity_priv, their_base_key) + // DH3 = DH(our_signed_prekey_priv, their_base_key) + // DH4 = DH(our_one_time_prekey_priv[preKeyId], their_base_key) [if preKeyId != 0] + var dh1 = Curve25519Helper.DH(auth.SignedPreKeyPrivate, theirIdentityPub); + var dh2 = Curve25519Helper.DH(auth.SignedIdentityKeyPrivate, baseKey); + var dh3 = Curve25519Helper.DH(auth.SignedPreKeyPrivate, baseKey); + + byte[]? dh4 = null; + if (pkmsg.PreKeyId != 0) + { + var otpk = auth.PreKeys.FirstOrDefault(k => k.Id == pkmsg.PreKeyId); + if (otpk != null) + { + dh4 = Curve25519Helper.DH(otpk.Private, baseKey); + auth.PreKeys.RemoveAll(k => k.Id == pkmsg.PreKeyId); + } + } + + var f = new byte[32]; + Array.Fill(f, (byte)0xFF); + + var masterParts = new List { f, dh1, dh2, dh3 }; + if (dh4 != null) masterParts.Add(dh4); + var masterSecret = Concat(masterParts.ToArray()); + var zeroSalt = new byte[32]; - var (rootKey, chainKey) = DawaHKDF.DeriveKeys(masterSecret, zeroSalt); - - return new SignalSession - { - RemoteJid = recipientJid, - RootKey = rootKey, - SendChainKey = chainKey, - ReceiveChainKey = Array.Empty(), - SendCounter = 0, - ReceiveCounter = 0, - TheirCurrentRatchetPublic = theirSignedPreKeyPub, - OurRatchetPrivate = ourEphemeralPriv, - OurRatchetPublic = ourEphemeralPub, + var info = Encoding.UTF8.GetBytes("WhisperText"); + var derived = DawaHKDF.DeriveKey(masterSecret, zeroSalt, info, 64); + + // Signal protocol: X3DH HKDF(64 bytes) gives us: + // derived[0..32] = root key + // derived[32..64] = NOT directly used as receive chain key + // + // The receive chain key is computed via a DH ratchet step on the FIRST decrypt: + // DH(ourSPKPriv, senderRatchetKey) + rootKey → (newRootKey, receiveChainKey) + // where senderRatchetKey is the ephemeralKey in the inner WhisperMessage — + // always a freshly generated key, DIFFERENT from the outer baseKey. + // + // To trigger this ratchet step on first decrypt, we store the outer baseKey as + // TheirCurrentRatchetPublic (not the inner ratchet key). Since they differ, + // ratchetMatched=false and the ratchet step runs correctly. + var rootKey = derived[..32]; + + // Debug: log X3DH details + key consistency check + try + { + // Verify private→public consistency (derived pub should match stored pub) + var spkPrivParam = new Org.BouncyCastle.Crypto.Parameters.X25519PrivateKeyParameters(auth.SignedPreKeyPrivate, 0); + var spkPubDerived = new byte[32]; spkPrivParam.GeneratePublicKey().Encode(spkPubDerived, 0); + var idPrivParam = new Org.BouncyCastle.Crypto.Parameters.X25519PrivateKeyParameters(auth.SignedIdentityKeyPrivate, 0); + var idPubDerived = new byte[32]; idPrivParam.GeneratePublicKey().Encode(idPubDerived, 0); + bool spkOk = spkPubDerived.SequenceEqual(auth.SignedPreKeyPublic); + bool idOk = idPubDerived.SequenceEqual(auth.SignedIdentityKeyPublic); + + var logDir = Path.Combine(AppContext.BaseDirectory, "logs"); + Directory.CreateDirectory(logDir); + File.AppendAllText(Path.Combine(logDir, "signal-debug.log"), + $"[{DateTime.UtcNow:HH:mm:ss}] InitIncomingSession for {jid}\n" + + $" preKeyId={pkmsg.PreKeyId} signedPreKeyId={pkmsg.SignedPreKeyId}\n" + + $" preKeyFound={dh4 != null} preKeysRemaining={auth.PreKeys.Count}\n" + + $" KEY CONSISTENCY: spkPriv→pub={spkOk} (stored={Convert.ToHexString(auth.SignedPreKeyPublic[..4])} derived={Convert.ToHexString(spkPubDerived[..4])})\n" + + $" KEY CONSISTENCY: idPriv→pub={idOk} (stored={Convert.ToHexString(auth.SignedIdentityKeyPublic[..4])} derived={Convert.ToHexString(idPubDerived[..4])})\n" + + $" theirIdentity={Convert.ToHexString(theirIdentityPub[..4])}\n" + + $" dh1={Convert.ToHexString(dh1)} dh2={Convert.ToHexString(dh2)} dh3={Convert.ToHexString(dh3)}" + + (dh4 != null ? $"\n dh4={Convert.ToHexString(dh4)}" : "") + "\n" + + $" masterSecret[..8]={Convert.ToHexString(masterSecret[..8])}\n" + + $" rootKey={Convert.ToHexString(rootKey)}\n" + + $" NOTE: ReceiveChainKey will be computed on first decrypt via DH ratchet step\n\n"); + } + catch { /* best effort */ } + + // Use the OUTER baseKey as the initial TheirCurrentRatchetPublic. + // The inner WhisperMessage's ephemeralKey (ratchet key) is always a DIFFERENT, + // freshly-generated key from the sender. Storing baseKey here ensures + // ratchetMatched=false on the first decrypt, triggering the correct DH ratchet step + // that derives the actual receive chain key. + var session = new SignalSession + { + RemoteJid = jid, + RootKey = rootKey, + SendChainKey = [], + ReceiveChainKey = [], // Empty — computed via ratchet step on first decrypt + SendCounter = 0, + ReceiveCounter = 0, + PrevSendCounter = 0, + TheirCurrentRatchetPublic = StripKeyPrefix(pkmsg.BaseKey), // Outer baseKey, NOT inner ratchet key + OurRatchetPrivate = auth.SignedPreKeyPrivate, + OurRatchetPublic = auth.SignedPreKeyPublic, + TheirIdentityPublic = theirIdentityPub, + BaseKey = pkmsg.BaseKey, + PreKeyId = pkmsg.PreKeyId, + SignedPreKeyId = pkmsg.SignedPreKeyId, + PeerRegistrationId = 0, + IsEstablished = true, + }; + + _sessions[jid] = session; + SaveSessions(); + } + + // ─── Encrypt ────────────────────────────────────────────────────────────── + + /// + /// Encrypts a plaintext for the given JID using the Double Ratchet. + /// Returns (encryptedBytes, isPreKey) where isPreKey=true means the caller + /// should wrap this in a PreKeyWhisperMessage frame. + /// + public (byte[] encBytes, bool isPreKey) EncryptMessage(string jid, byte[] plaintext, AuthState auth) + { + var session = _sessions.TryGetValue(jid, out var s) ? s + : throw new InvalidOperationException($"No Signal session for {jid}. Call InitOutgoingSession first."); + + // Derive message key from current send chain key + var (messageKey, nextChainKey) = DeriveMessageKeys(session.SendChainKey); + session.SendChainKey = nextChainKey; + + // Expand message key — Signal spec requires info="WhisperMessageKeys" + var keyMaterial = DawaHKDF.DeriveKey(messageKey, new byte[32], Encoding.UTF8.GetBytes("WhisperMessageKeys"), 80); + var encKey = keyMaterial[..32]; + var macKey = keyMaterial[32..64]; + var iv = keyMaterial[64..80]; + + // AES-CBC encrypt + var ciphertext = MessageCipher.AesCbcEncrypt(encKey, iv, plaintext); + + // Build WhisperMessageProto + // Keys in Signal protobufs MUST include 0x05 prefix (33 bytes) — Baileys convention + var whisperProto = new WhisperMessageProto + { + RatchetKey = PrefixKey(session.OurRatchetPublic), + Counter = session.SendCounter, + PreviousCounter = session.PrevSendCounter, + Ciphertext = ciphertext, }; + var protoBytes = whisperProto.ToByteArray(); + + // MAC = HMAC-SHA256(macKey, senderIdentity_33 || receiverIdentity_33 || 0x33 || proto_bytes)[0:8] + var ourIdentity33 = new byte[] { 0x05 }.Concat(auth.SignedIdentityKeyPublic).ToArray(); + var theirIdentity33 = new byte[] { 0x05 }.Concat(session.TheirIdentityPublic).ToArray(); + var macInput = new byte[33 + 33 + 1 + protoBytes.Length]; + ourIdentity33.CopyTo(macInput, 0); + theirIdentity33.CopyTo(macInput, 33); + macInput[66] = 0x33; + protoBytes.CopyTo(macInput, 67); + var mac = HMACSHA256.HashData(macKey, macInput)[..8]; + + // whisperBytes = [0x33] + proto_bytes + mac + var whisperBytes = new byte[1 + protoBytes.Length + 8]; + whisperBytes[0] = 0x33; + protoBytes.CopyTo(whisperBytes, 1); + mac.CopyTo(whisperBytes, 1 + protoBytes.Length); + + session.SendCounter++; + + bool isPreKey = !session.IsEstablished; + + byte[] result; + if (isPreKey) + { + // Wrap in PreKeyWhisperMessageProto + // Keys MUST include 0x05 prefix (33 bytes) — recipient's libsignal expects this + var pkProto = new PreKeyWhisperMessageProto + { + PreKeyId = session.PreKeyId, + BaseKey = PrefixKey(session.BaseKey), + IdentityKey = PrefixKey(auth.SignedIdentityKeyPublic), + Message = whisperBytes, + RegistrationId = auth.RegistrationId, + SignedPreKeyId = session.SignedPreKeyId, + }; + var pkProtoBytes = pkProto.ToByteArray(); + result = new byte[1 + pkProtoBytes.Length]; + result[0] = 0x33; + pkProtoBytes.CopyTo(result, 1); + + // Mark session as established after first message sent + session.IsEstablished = true; + } + else + { + result = whisperBytes; + } + + // === DEBUG: Log all encryption details === + try + { + var logDir = Path.Combine(AppContext.BaseDirectory, "logs"); + Directory.CreateDirectory(logDir); + File.AppendAllText(Path.Combine(logDir, "signal-encrypt-debug.log"), + $"[{DateTime.UtcNow:HH:mm:ss}] EncryptMessage for {jid}\n" + + $" sendChainKey(before)={Convert.ToHexString(session.SendChainKey)}\n" + + $" messageKey={Convert.ToHexString(messageKey)}\n" + + $" encKey={Convert.ToHexString(encKey)}\n" + + $" macKey={Convert.ToHexString(macKey)}\n" + + $" iv={Convert.ToHexString(iv)}\n" + + $" plaintext len={plaintext.Length} first16={Convert.ToHexString(plaintext[..Math.Min(16, plaintext.Length)])}\n" + + $" ciphertext len={ciphertext.Length} first16={Convert.ToHexString(ciphertext[..Math.Min(16, ciphertext.Length)])}\n" + + $" ratchetKey(prefixed)={Convert.ToHexString(PrefixKey(session.OurRatchetPublic))}\n" + + $" counter={session.SendCounter - 1} prevCounter={session.PrevSendCounter}\n" + + $" protoBytes len={protoBytes.Length} hex={Convert.ToHexString(protoBytes)}\n" + + $" ourIdentity33={Convert.ToHexString(ourIdentity33)}\n" + + $" theirIdentity33={Convert.ToHexString(theirIdentity33)}\n" + + $" macInput len={macInput.Length} first32={Convert.ToHexString(macInput[..Math.Min(32, macInput.Length)])}\n" + + $" mac={Convert.ToHexString(mac)}\n" + + $" whisperBytes len={whisperBytes.Length} first32={Convert.ToHexString(whisperBytes[..Math.Min(32, whisperBytes.Length)])}\n" + + $" isPreKey={isPreKey}\n" + + (isPreKey ? ( + $" preKeyId={session.PreKeyId}\n" + + $" baseKey(prefixed)={Convert.ToHexString(PrefixKey(session.BaseKey))}\n" + + $" identityKey(prefixed)={Convert.ToHexString(PrefixKey(auth.SignedIdentityKeyPublic))}\n" + + $" registrationId={auth.RegistrationId}\n" + + $" signedPreKeyId={session.SignedPreKeyId}\n" + + $" result len={result.Length} first32={Convert.ToHexString(result[..Math.Min(32, result.Length)])}\n" + ) : "") + + "\n"); + } + catch { /* best effort */ } + + SaveSessions(); + return (result, isPreKey); + } + + // ─── Decrypt ────────────────────────────────────────────────────────────── + + /// + /// Decrypts a received Signal message. type is "pkmsg" or "msg". + /// + public byte[] DecryptMessage(string jid, string type, byte[] ciphertext, AuthState auth) + { + // Resolve LID JIDs to phone JIDs — they share the same Signal session + var resolvedJid = ResolveJid(jid); + + if (type == "pkmsg") + { + // Parse PreKeyWhisperMessageProto from ciphertext[1:] + if (ciphertext.Length < 2) + throw new CryptographicException("PreKeyWhisperMessage too short."); + + var pkProto = PreKeyWhisperMessageProto.ParseFrom(ciphertext[1..]); + + // If this is an @lid JID with no mapping yet, try to find existing session by identity key + if (resolvedJid.Contains("@lid") && !_sessions.ContainsKey(resolvedJid)) + { + var theirIdentity = StripKeyPrefix(pkProto.IdentityKey); + var mapped = TryResolveByIdentity(resolvedJid, theirIdentity); + if (mapped != null) + resolvedJid = mapped; + } + + // Mirrors libsignal's initIncoming logic: + // Each distinct pkmsg session is identified by its base key. + // - Same base key as existing session → this is a retry of the same pkmsg, reuse session + // - Different/no base key → new X3DH session establishment, reinitialize + // This correctly handles: + // (a) Multiple pkmsgs from same JID with different base keys (different messages/sessions) + // (b) Retries of the same pkmsg + // (c) Stale bad sessions from a previous failed decrypt + var existingSession = _sessions.TryGetValue(resolvedJid, out var es) ? es : null; + var incomingBaseKey = pkProto.BaseKey; // 33-byte prefixed + bool shouldReinit = existingSession == null || + !existingSession.BaseKey.SequenceEqual(incomingBaseKey); + if (shouldReinit) + InitIncomingSession(resolvedJid, pkProto, auth); + + // The inner message is in pkProto.Message + return DecryptWhisperMessage(resolvedJid, pkProto.Message, auth); + } + else + { + // type == "msg": regular WhisperMessage + // For @lid with no mapping, try existing sessions (won't have identity key here, just try) + if (resolvedJid.Contains("@lid") && !_sessions.ContainsKey(resolvedJid)) + { + // Can't resolve without identity key in a regular msg — log and throw + throw new InvalidOperationException($"No Signal session for LID {jid} and no mapping found."); + } + return DecryptWhisperMessage(resolvedJid, ciphertext, auth); + } + } + + // ─── Internal decrypt ──────────────────────────────────────────────────── + + private byte[] DecryptWhisperMessage(string jid, byte[] whisperFrame, AuthState auth) + { + if (whisperFrame.Length < 10) + throw new CryptographicException("WhisperMessage too short."); + + // whisperFrame = [0x33] + proto_bytes + mac(8) + var protoBytes = whisperFrame[1..(whisperFrame.Length - 8)]; + var receivedMac = whisperFrame[(whisperFrame.Length - 8)..]; + + var innerMsg = WhisperMessageProto.ParseFrom(protoBytes); + + var session = _sessions.TryGetValue(jid, out var s) ? s + : throw new InvalidOperationException($"No Signal session for {jid}."); + + // IMPORTANT: Work on cloned state so MAC failure does NOT corrupt the live session. + // Only commit the updated state after MAC verification succeeds. + // Without this, a single MAC failure corrupts the chain and cascades into all future failures. + byte[] wRootKey = session.RootKey; + byte[] wReceiveChainKey = session.ReceiveChainKey; + byte[] wSendChainKey = session.SendChainKey; + uint wReceiveCounter = session.ReceiveCounter; + uint wSendCounter = session.SendCounter; + uint wPrevSendCounter = session.PrevSendCounter; + byte[] wTheirCurrentRatchetPublic = session.TheirCurrentRatchetPublic; + byte[] wOurRatchetPrivate = session.OurRatchetPrivate; + byte[] wOurRatchetPublic = session.OurRatchetPublic; + + // Check if we need to do a DH ratchet step (their ratchet key changed) + var theirRatchetPub = StripKeyPrefix(innerMsg.RatchetKey); + var ratchetMatched = theirRatchetPub.SequenceEqual(wTheirCurrentRatchetPublic); + if (!ratchetMatched) + { + // DH ratchet: advance receive chain with new ratchet key + var (newRootKey, newReceiveChainKey) = HkdfRatchetStep( + wRootKey, + Curve25519Helper.DH(wOurRatchetPrivate, theirRatchetPub)); + + wPrevSendCounter = wSendCounter; + wTheirCurrentRatchetPublic = theirRatchetPub; + wRootKey = newRootKey; + wReceiveChainKey = newReceiveChainKey; + wReceiveCounter = 0; + + // Generate new ratchet key pair for next send + var (newRatchPriv, newRatchPub) = Curve25519Helper.GenerateKeyPair(); + var (newRootKey2, newSendChainKey) = HkdfRatchetStep( + newRootKey, + Curve25519Helper.DH(newRatchPriv, theirRatchetPub)); + wOurRatchetPrivate = newRatchPriv; + wOurRatchetPublic = newRatchPub; + wRootKey = newRootKey2; + wSendChainKey = newSendChainKey; + wSendCounter = 0; + } + + // Advance chain to match the sender's counter (handles out-of-order / batched messages) + var targetCounter = innerMsg.Counter; + while (wReceiveCounter < targetCounter) + { + var (_, skip) = DeriveMessageKeys(wReceiveChainKey); + wReceiveChainKey = skip; + wReceiveCounter++; + } + + // Derive message key + var chainKeyBeforeDerive = wReceiveChainKey; + var (messageKey, nextChainKey) = DeriveMessageKeys(wReceiveChainKey); + wReceiveChainKey = nextChainKey; + + // Expand message key — Signal spec requires info="WhisperMessageKeys" + var keyMaterial = DawaHKDF.DeriveKey(messageKey, new byte[32], Encoding.UTF8.GetBytes("WhisperMessageKeys"), 80); + var encKey = keyMaterial[..32]; + var macKey = keyMaterial[32..64]; + var iv = keyMaterial[64..80]; + + // Verify MAC — Signal Protocol MAC includes the version byte: + // MAC = HMAC-SHA256(macKey, senderIdentityPub_33 || receiverIdentityPub_33 || versionByte || protoBytes) + // This matches libsignal-java WhisperMessage.getMac() which feeds: + // sender_identity_key (33 bytes) + receiver_identity_key (33 bytes) + versionByte + serializedProto + // Identity keys use 33-byte format: 0x05 prefix + 32-byte raw key + var senderIdentity33 = new byte[] { 0x05 }.Concat(session.TheirIdentityPublic).ToArray(); + var receiverIdentity33 = new byte[] { 0x05 }.Concat(auth.SignedIdentityKeyPublic).ToArray(); + var versionByte = whisperFrame[0]; // = 0x33 for Signal v3 + var macInput = new byte[33 + 33 + 1 + protoBytes.Length]; + senderIdentity33.CopyTo(macInput, 0); + receiverIdentity33.CopyTo(macInput, 33); + macInput[66] = versionByte; + protoBytes.CopyTo(macInput, 67); + var expectedMac = HMACSHA256.HashData(macKey, macInput)[..8]; + if (!expectedMac.AsSpan().SequenceEqual(receivedMac)) + { + // MAC failed — do NOT commit any working state changes to the live session. + // The live session remains at its last known-good state. + var logDir = Path.Combine(AppContext.BaseDirectory, "logs"); + try { Directory.CreateDirectory(logDir); } catch { /* best effort */ } + try { File.AppendAllText(Path.Combine(logDir, "signal-debug.log"), + $"[{DateTime.UtcNow:HH:mm:ss}] MAC FAIL for {jid}\n" + + $" ratchetMatched={ratchetMatched} ratchetKey len={innerMsg.RatchetKey.Length}\n" + + $" theirIdentity={Convert.ToHexString(session.TheirIdentityPublic[..Math.Min(4, session.TheirIdentityPublic.Length)])}\n" + + $" ourIdentity={Convert.ToHexString(auth.SignedIdentityKeyPublic[..Math.Min(4, auth.SignedIdentityKeyPublic.Length)])}\n" + + $" chainKey(used)={Convert.ToHexString(chainKeyBeforeDerive[..Math.Min(8, chainKeyBeforeDerive.Length)])}\n" + + $" messageKey={Convert.ToHexString(messageKey[..8])}\n" + + $" macKey={Convert.ToHexString(macKey[..8])}\n" + + $" expected={Convert.ToHexString(expectedMac)} received={Convert.ToHexString(receivedMac)}\n" + + $" whisperFrame[0]=0x{whisperFrame[0]:X2} protoLen={protoBytes.Length}\n" + + $" counter={innerMsg.Counter} receiveCounter={session.ReceiveCounter}\n\n"); + } catch { /* best effort logging */ } + throw new CryptographicException("WhisperMessage MAC verification failed."); + } + + // MAC verified — commit working state to live session + wReceiveCounter++; + session.RootKey = wRootKey; + session.ReceiveChainKey = wReceiveChainKey; + session.SendChainKey = wSendChainKey; + session.ReceiveCounter = wReceiveCounter; + session.SendCounter = wSendCounter; + session.PrevSendCounter = wPrevSendCounter; + session.TheirCurrentRatchetPublic = wTheirCurrentRatchetPublic; + session.OurRatchetPrivate = wOurRatchetPrivate; + session.OurRatchetPublic = wOurRatchetPublic; + + var plaintext = MessageCipher.AesCbcDecrypt(encKey, iv, innerMsg.Ciphertext); + // Log success + var logDir2 = Path.Combine(AppContext.BaseDirectory, "logs"); + try { Directory.CreateDirectory(logDir2); } catch { } + try { File.AppendAllText(Path.Combine(logDir2, "signal-debug.log"), + $"[{DateTime.UtcNow:HH:mm:ss}] MAC OK for {jid}\n" + + $" ratchetMatched={ratchetMatched} counter={innerMsg.Counter} plaintextLen={plaintext.Length}\n\n"); + } catch { } + SaveSessions(); + return plaintext; } - // ─── Message key derivation (Double Ratchet) ────────────────────────────── + // ─── Pre-key access ─────────────────────────────────────────────────────── - public (byte[] messageKey, byte[] nextChainKey) DeriveMessageKeys(byte[] chainKey) + public void RemovePreKey(uint id, AuthState auth) + { + auth.PreKeys.RemoveAll(k => k.Id == id); + } + + // ─── Message key derivation (symmetric ratchet) ─────────────────────────── + + private static (byte[] messageKey, byte[] nextChainKey) DeriveMessageKeys(byte[] chainKey) { - var messageKey = HMACSHA256.HashData(chainKey, new byte[] { 0x01 }); + var messageKey = HMACSHA256.HashData(chainKey, new byte[] { 0x01 }); var nextChainKey = HMACSHA256.HashData(chainKey, new byte[] { 0x02 }); return (messageKey, nextChainKey); } + + // ─── DH Ratchet step ────────────────────────────────────────────────────── + + /// + /// Performs one DH ratchet step. + /// Returns (newRootKey, newChainKey). + /// Uses rootKey as salt, dhOutput as IKM, "WhisperRatchet" as info. + /// + private static (byte[] newRootKey, byte[] chainKey) HkdfRatchetStep(byte[] rootKey, byte[] dhOutput) + { + var info = Encoding.UTF8.GetBytes("WhisperRatchet"); + var output = DawaHKDF.DeriveKey(dhOutput, rootKey, info, 64); + return (output[..32], output[32..]); + } + + // ─── Key prefix stripping ───────────────────────────────────────────────── + + /// + /// If key is 33 bytes and key[0]==0x05, returns key[1:]. + /// Otherwise returns the key as-is (or trimmed to 32 bytes if needed). + /// + public static byte[] StripKeyPrefix(byte[] key) + { + if (key.Length == 33 && key[0] == 0x05) + return key[1..]; + return key; + } + + /// + /// Adds 0x05 KEY_BUNDLE_TYPE prefix to a 32-byte key (Signal convention). + /// If already 33 bytes with prefix, returns as-is. + /// + public static byte[] PrefixKey(byte[] key) + { + if (key.Length == 33 && key[0] == 0x05) + return key; + var prefixed = new byte[33]; + prefixed[0] = 0x05; + key.AsSpan(0, Math.Min(32, key.Length)).CopyTo(prefixed.AsSpan(1)); + return prefixed; + } + + // ─── Persistence ────────────────────────────────────────────────────────── + + private void LoadSessions() + { + try + { + if (!File.Exists(SessionsFilePath)) return; + var json = File.ReadAllText(SessionsFilePath); + var loaded = JsonSerializer.Deserialize>(json, _jsonOpts); + if (loaded == null) return; + foreach (var (k, v) in loaded) + _sessions[k] = v; + } + catch + { + // Ignore corrupt sessions file — start fresh + } + } + + private void SaveSessions() + { + try + { + var json = JsonSerializer.Serialize(_sessions, _jsonOpts); + File.WriteAllText(SessionsFilePath, json); + } + catch + { + // Best-effort persistence + } + } + + private void LoadLidMappings() + { + try + { + if (!File.Exists(LidMappingFilePath)) return; + var json = File.ReadAllText(LidMappingFilePath); + var loaded = JsonSerializer.Deserialize>(json, _jsonOpts); + if (loaded == null) return; + foreach (var (k, v) in loaded) + _lidToPhone[k] = v; + } + catch { /* Ignore corrupt file */ } + } + + private void SaveLidMappings() + { + try + { + var json = JsonSerializer.Serialize(_lidToPhone, _jsonOpts); + File.WriteAllText(LidMappingFilePath, json); + } + catch { /* Best-effort */ } + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private static byte[] Concat(params byte[][] arrays) + { + var result = new byte[arrays.Sum(a => a.Length)]; + var offset = 0; + foreach (var a in arrays) + { + a.CopyTo(result, offset); + offset += a.Length; + } + return result; + } } -/// A Signal protocol session for a single recipient. +// ─── Session model ──────────────────────────────────────────────────────────── + +/// A Signal Double Ratchet session for a single remote device JID. public sealed class SignalSession { public string RemoteJid { get; set; } = ""; @@ -103,7 +859,43 @@ public sealed class SignalSession public byte[] ReceiveChainKey { get; set; } = []; public uint SendCounter { get; set; } public uint ReceiveCounter { get; set; } + public uint PrevSendCounter { get; set; } public byte[] TheirCurrentRatchetPublic { get; set; } = []; public byte[] OurRatchetPrivate { get; set; } = []; public byte[] OurRatchetPublic { get; set; } = []; + + /// Their identity public key (raw 32 bytes, no 0x05 prefix). + public byte[] TheirIdentityPublic { get; set; } = []; + + /// X3DH ephemeral public key — sent in the PreKeyWhisperMessage header. + public byte[] BaseKey { get; set; } = []; + + /// Their one-time pre-key ID used during X3DH (0 = none). + public uint PreKeyId { get; set; } + + /// Their signed pre-key ID used during X3DH. + public uint SignedPreKeyId { get; set; } + + /// Their registration ID. + public uint PeerRegistrationId { get; set; } + + /// + /// False when session was just initialized and the first outgoing message + /// must be wrapped in a PreKeyWhisperMessage. Set to true after first send. + /// + public bool IsEstablished { get; set; } +} + +// ─── PreKeyBundle ───────────────────────────────────────────────────────────── + +/// Pre-key bundle fetched from the server for a remote device. +public sealed class PreKeyBundle +{ + public byte[] TheirIdentityPub { get; set; } = []; + public byte[] TheirSignedPreKeyPub { get; set; } = []; + public uint TheirSignedPreKeyId { get; set; } + public byte[] TheirSignedPreKeySig { get; set; } = []; + public byte[]? TheirOneTimePreKeyPub { get; set; } + public uint TheirOneTimePreKeyId { get; set; } + public uint PeerRegistrationId { get; set; } } diff --git a/src/Dawa/Signal/SignalProto.cs b/src/Dawa/Signal/SignalProto.cs new file mode 100644 index 0000000..f94dd67 --- /dev/null +++ b/src/Dawa/Signal/SignalProto.cs @@ -0,0 +1,107 @@ +using Dawa.Proto; + +namespace Dawa.Signal; + +/// +/// WhisperMessage proto (inner encrypted message). +/// field 1: ratchetKey (bytes) - sender's current ratchet public key (32 bytes) +/// field 2: counter (uint32) - message counter Ns +/// field 3: previousCounter (uint32) - PN (messages sent in previous sending chain) +/// field 4: ciphertext (bytes) - AES-CBC encrypted plaintext +/// +public sealed class WhisperMessageProto +{ + public byte[] RatchetKey { get; set; } = []; + public uint Counter { get; set; } + public uint PreviousCounter { get; set; } + public byte[] Ciphertext { get; set; } = []; + + public byte[] ToByteArray() + { + var buf = new List(); + ProtoEncoder.WriteBytes(buf, 1, RatchetKey); + // Always write Counter and PreviousCounter even when 0 — Baileys' protobuf.js + // includes them explicitly, and some Signal implementations require their presence. + WriteUInt32Always(buf, 2, Counter); + WriteUInt32Always(buf, 3, PreviousCounter); + ProtoEncoder.WriteBytes(buf, 4, Ciphertext); + return [.. buf]; + } + + private static void WriteUInt32Always(List buf, int field, uint value) + { + ProtoEncoder.WriteTag(buf, field, 0); + ProtoEncoder.WriteVarint(buf, value); + } + + public static WhisperMessageProto ParseFrom(byte[] data) + { + var msg = new WhisperMessageProto(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.RatchetKey = r.ReadBytes(); break; + case 2: msg.Counter = r.ReadUInt32(); break; + case 3: msg.PreviousCounter = r.ReadUInt32(); break; + case 4: msg.Ciphertext = r.ReadBytes(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} + +/// +/// PreKeyWhisperMessage proto (outer message for first contact). +/// field 1: preKeyId (uint32) - one-time pre-key ID (0 if not used) +/// field 2: baseKey (bytes) - X3DH ephemeral public key (32 bytes) +/// field 3: identityKey (bytes) - sender's identity public key (32 bytes) +/// field 4: message (bytes) - complete WhisperMessage bytes (version + proto + mac) +/// field 5: registrationId (uint32) - sender's registration ID +/// field 6: signedPreKeyId (uint32) - signed pre-key ID +/// +public sealed class PreKeyWhisperMessageProto +{ + public uint PreKeyId { get; set; } // 0 = no one-time pre-key + public byte[] BaseKey { get; set; } = []; // X3DH ephemeral pub (32 bytes) + public byte[] IdentityKey { get; set; } = []; // identity pub (32 bytes) + public byte[] Message { get; set; } = []; // embedded WhisperMessage bytes + public uint RegistrationId { get; set; } + public uint SignedPreKeyId { get; set; } + + public byte[] ToByteArray() + { + var buf = new List(); + ProtoEncoder.WriteUInt32(buf, 1, PreKeyId); + ProtoEncoder.WriteBytes(buf, 2, BaseKey); + ProtoEncoder.WriteBytes(buf, 3, IdentityKey); + ProtoEncoder.WriteBytes(buf, 4, Message); + ProtoEncoder.WriteUInt32(buf, 5, RegistrationId); + ProtoEncoder.WriteUInt32(buf, 6, SignedPreKeyId); + return [.. buf]; + } + + public static PreKeyWhisperMessageProto ParseFrom(byte[] data) + { + var msg = new PreKeyWhisperMessageProto(); + var r = ProtoEncoder.CreateReader(data); + while (r.HasMore) + { + var (field, wire) = r.ReadTag(); + switch (field) + { + case 1: msg.PreKeyId = r.ReadUInt32(); break; + case 2: msg.BaseKey = r.ReadBytes(); break; + case 3: msg.IdentityKey = r.ReadBytes(); break; + case 4: msg.Message = r.ReadBytes(); break; + case 5: msg.RegistrationId = r.ReadUInt32(); break; + case 6: msg.SignedPreKeyId = r.ReadUInt32(); break; + default: r.Skip(wire); break; + } + } + return msg; + } +} diff --git a/src/Dawa/Transport/FrameSocket.cs b/src/Dawa/Transport/FrameSocket.cs index 711b881..dbf6d53 100644 --- a/src/Dawa/Transport/FrameSocket.cs +++ b/src/Dawa/Transport/FrameSocket.cs @@ -6,6 +6,10 @@ namespace Dawa.Transport; /// /// WebSocket wrapper that adds WhatsApp's 3-byte big-endian length framing. /// Each frame: [length: 3 bytes BE] [payload: N bytes] +/// +/// A single WebSocket message may contain multiple WA frames back-to-back +/// (Baileys: noise-handler.ts processData drains all frames in a loop). +/// We buffer leftover bytes so each ReceiveFrameAsync call returns exactly one frame. /// public sealed class FrameSocket : IAsyncDisposable { @@ -13,6 +17,10 @@ public sealed class FrameSocket : IAsyncDisposable private ClientWebSocket? _ws; private readonly SemaphoreSlim _sendLock = new(1, 1); + // Buffer for bytes received but not yet returned as a complete frame + private byte[] _recvBuf = []; + private int _recvLen = 0; + // WhatsApp server URL private const string WA_URL = "wss://web.whatsapp.com/ws/chat"; @@ -23,7 +31,7 @@ public FrameSocket(ILogger logger) public bool IsConnected => _ws?.State == WebSocketState.Open; - public async Task ConnectAsync(string? url = null, CancellationToken ct = default) + public async Task ConnectAsync(string? url = null, byte[]? routingInfo = null, CancellationToken ct = default) { _ws = new ClientWebSocket(); _ws.Options.SetRequestHeader("Origin", "https://web.whatsapp.com"); @@ -37,6 +45,14 @@ public async Task ConnectAsync(string? url = null, CancellationToken ct = defaul _ws.Options.AddSubProtocol("chat"); _ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(25); + // When WhatsApp sends edge_routing, reconnect to the preferred edge server + // by including the X-WA-Routing header (Baileys: socket.ts fetchOptions.headers) + if (routingInfo != null && routingInfo.Length > 0) + { + _ws.Options.SetRequestHeader("X-WA-Routing", Convert.ToBase64String(routingInfo)); + _logger.LogInformation("FrameSocket: Connecting with X-WA-Routing ({Len} bytes).", routingInfo.Length); + } + await _ws.ConnectAsync(new Uri(url ?? WA_URL), ct); _logger.LogInformation("FrameSocket: WebSocket connected to {Url}", url ?? WA_URL); } @@ -85,40 +101,74 @@ public async Task SendFrameAsync(byte[] payload, CancellationToken ct = default) _logger.LogDebug("FrameSocket: Sent frame {Length} bytes", payload.Length); } - /// Reads the next frame from the WebSocket, stripping the 3-byte header. + /// + /// Reads the next WA frame. If the receive buffer already contains a complete + /// frame (leftover from a previous WebSocket message), returns it immediately + /// without touching the network. Otherwise reads one WebSocket message and + /// appends it to the buffer, then extracts the first complete frame. + /// Matches Baileys noise-handler.ts processData drain-loop behaviour. + /// public async Task ReceiveFrameAsync(CancellationToken ct = default) { - if (_ws == null || _ws.State != WebSocketState.Open) - return null; - - using var ms = new MemoryStream(); - var buffer = new byte[65536]; - WebSocketReceiveResult result; - - do + while (true) { - result = await _ws.ReceiveAsync(buffer, ct); - if (result.MessageType == WebSocketMessageType.Close) + // Try to extract a complete WA frame from the existing buffer + if (_recvLen >= 3) { - _logger.LogInformation("FrameSocket: Server sent close frame."); - return null; + var length = (_recvBuf[0] << 16) | (_recvBuf[1] << 8) | _recvBuf[2]; + if (_recvLen >= 3 + length) + { + var payload = _recvBuf[3..(3 + length)]; + // Shift remaining bytes to the front + var remaining = _recvLen - (3 + length); + if (remaining > 0) + Buffer.BlockCopy(_recvBuf, 3 + length, _recvBuf, 0, remaining); + _recvLen = remaining; + _logger.LogDebug("FrameSocket: Extracted frame {Length} bytes (buf remaining={Rem})", length, remaining); + return payload; + } } - ms.Write(buffer, 0, result.Count); - } while (!result.EndOfMessage); - var raw = ms.ToArray(); - if (raw.Length < 3) return null; + // Need more data — read one WebSocket message + if (_ws == null || _ws.State != WebSocketState.Open) + return null; - var length = (raw[0] << 16) | (raw[1] << 8) | raw[2]; - if (raw.Length < 3 + length) - { - _logger.LogWarning("FrameSocket: Frame length mismatch: header says {Expected}, got {Actual}", length, raw.Length - 3); - return null; + using var ms = new MemoryStream(); + var wsBuffer = new byte[65536]; + WebSocketReceiveResult result; + + do + { + result = await _ws.ReceiveAsync(wsBuffer, ct); + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogInformation("FrameSocket: Server sent close frame."); + return null; + } + ms.Write(wsBuffer, 0, result.Count); + } while (!result.EndOfMessage); + + var incoming = ms.ToArray(); + _logger.LogInformation("FrameSocket: Raw receive {Length} bytes, type={Type}", incoming.Length, result.MessageType); + + // Append incoming to receive buffer + EnsureCapacity(incoming.Length); + Buffer.BlockCopy(incoming, 0, _recvBuf, _recvLen, incoming.Length); + _recvLen += incoming.Length; + // Loop back up to try extracting a frame } + } - var payload = raw[3..(3 + length)]; - _logger.LogDebug("FrameSocket: Received frame {Length} bytes", length); - return payload; + private void EnsureCapacity(int additional) + { + var needed = _recvLen + additional; + if (_recvBuf.Length < needed) + { + var newBuf = new byte[Math.Max(needed, _recvBuf.Length * 2 + additional)]; + if (_recvLen > 0) + Buffer.BlockCopy(_recvBuf, 0, newBuf, 0, _recvLen); + _recvBuf = newBuf; + } } public async ValueTask DisposeAsync() diff --git a/src/Dawa/WhatsAppClient.cs b/src/Dawa/WhatsAppClient.cs index 3c1b621..41409ca 100644 --- a/src/Dawa/WhatsAppClient.cs +++ b/src/Dawa/WhatsAppClient.cs @@ -31,9 +31,13 @@ public sealed class WhatsAppClient : IAsyncDisposable private FrameSocket? _frameSocket; private NoiseProcessor? _noiseProcessor; private ConnectionState _state = ConnectionState.Disconnected; + private AuthState? _authState; private CancellationTokenSource? _cts; private TaskCompletionSource? _connectedTcs; + // Routing info received from WhatsApp edge_routing — used as X-WA-Routing on next connect + private byte[]? _pendingRoutingInfo; + // ─── Events ───────────────────────────────────────────────────────────── /// @@ -48,6 +52,12 @@ public sealed class WhatsAppClient : IAsyncDisposable /// Fired when a new message is received. public event EventHandler? MessageReceived; + /// Fired for each historical message loaded from WhatsApp history sync on connect. + public event EventHandler? HistoryMessageReceived; + + /// Fired once after each history sync blob is fully processed. Arg = number of messages emitted. + public event EventHandler? HistorySyncCompleted; + /// Fired once the session is fully authenticated. public event EventHandler? Connected; @@ -58,7 +68,7 @@ public sealed class WhatsAppClient : IAsyncDisposable public ConnectionState State => _state; public bool IsConnected => _state == ConnectionState.Connected; - public string? MyJid => null; // Set after authentication + public string? MyJid => _authState?.Me?.Id; // ─── Construction ──────────────────────────────────────────────────────── @@ -100,13 +110,17 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { - var authState = await _sessionStore.LoadAsync(_cts.Token); + _authState = await _sessionStore.LoadAsync(_cts.Token); + + // Refresh the announced WA web version before the socket opens — an outdated + // version gets fresh registrations 405-rejected at the handshake. + await Noise.WaVersionProvider.RefreshAsync(_logger, _cts.Token); _frameSocket = new FrameSocket(_logger); SetState(ConnectionState.Handshaking); - await _frameSocket.ConnectAsync(ct: _cts.Token); + await _frameSocket.ConnectAsync(routingInfo: _pendingRoutingInfo, ct: _cts.Token); - _noiseProcessor = new NoiseProcessor(_frameSocket, authState, _options, _logger); + _noiseProcessor = new NoiseProcessor(_frameSocket, _authState, _options, _logger); _noiseProcessor.QRCodeGenerated += (_, qr) => { SetState(ConnectionState.Authenticating); @@ -114,12 +128,15 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) }; _noiseProcessor.Authenticated += async (_, auth) => { + _authState = auth; await _sessionStore.SaveAsync(auth, _cts!.Token); SetState(ConnectionState.Connected); _connectedTcs?.TrySetResult(true); Connected?.Invoke(this, EventArgs.Empty); }; - _noiseProcessor.MessageReceived += (_, msg) => MessageReceived?.Invoke(this, msg); + _noiseProcessor.MessageReceived += (_, msg) => MessageReceived?.Invoke(this, msg); + _noiseProcessor.HistoryMessageReceived += (_, msg) => HistoryMessageReceived?.Invoke(this, msg); + _noiseProcessor.HistorySyncCompleted += (_, n) => HistorySyncCompleted?.Invoke(this, n); await _noiseProcessor.PerformHandshakeAsync(_cts.Token); @@ -169,6 +186,225 @@ public async Task SendMessageAsync(string to, string text, CancellationToken can await _noiseProcessor.SendTextMessageAsync(jid, text, cancellationToken); } + public Task> GetContactsAsync(CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.FetchContactsAsync(ct); + } + + public Task> GetChatsAsync(CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.FetchChatsAsync(ct); + } + + public Task GetProfilePictureAsync(string jid, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.FetchProfilePictureAsync(jid, ct); + } + + /// Returns internal cache state for debugging — thread metadata, LID map, push names. + public object GetCacheDebugInfo() + { + if (_noiseProcessor == null) return new { error = "not connected" }; + return _noiseProcessor.GetCacheDebugInfo(); + } + + public Task SubscribePresenceAsync(string jid, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.SubscribePresenceAsync(jid, ct); + } + + public Dawa.Noise.PresenceInfo? GetPresence(string jid) + { + if (_noiseProcessor == null) return null; + return _noiseProcessor.GetPresence(jid); + } + + public Task SendReadReceiptAsync(string jid, string messageId, long timestamp, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.SendReadReceiptAsync(jid, messageId, timestamp, ct); + } + + public Task> FetchMessageHistoryAsync(string jid, int count, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + return Task.FromResult(new List()); + return _noiseProcessor.FetchMessageHistoryAsync(jid, count, ct); + } + + /// + /// Requests an on-demand history sync from the phone (sends a peerDataOperationRequestMessage + /// to our own JID). The phone responds by pushing a HISTORY_SYNC_NOTIFICATION of type ON_DEMAND + /// which the receive loop picks up and fires HistoryMessageReceived for each message. + /// Returns the messages from that chat that arrived during the push notification (or empty on timeout). + /// + public Task> RequestOnDemandHistorySyncAsync(string chatJid, int count, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + return Task.FromResult(new List()); + return _noiseProcessor.RequestOnDemandHistorySyncAsync(chatJid, count, ct); + } + + public List GetGroupJids() + { + if (_noiseProcessor == null) return new List(); + return _noiseProcessor.GetGroupJids(); + } + + public Task SendManualRetryReceiptAsync(string senderJid, string msgId, long timestamp, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + return Task.CompletedTask; + return _noiseProcessor.SendManualRetryReceiptAsync(senderJid, msgId, timestamp, ct); + } + + public Task ResolveLidAsync(string lidJid, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + return Task.FromResult(null); + return _noiseProcessor.ResolveLidAsync(lidJid, ct); + } + + public Task FetchGroupMetadataAsync(string groupJid, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.FetchGroupMetadataAsync(groupJid, ct); + } + + /// + /// Sends a media message (image, audio, or document) to a phone number or JID. + /// + /// Phone number or full JID. + /// Raw (unencrypted) file bytes. + /// "image", "audio", or "document". + /// MIME type, e.g. "image/jpeg". + /// Optional caption (shown under images). + /// File name (used for documents). + public async Task SendMediaAsync(string to, byte[] fileBytes, string mediaType, string mimeType, + string caption = "", string fileName = "", CancellationToken cancellationToken = default) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + + var jid = to.Contains('@') ? to : $"{new string(to.Where(char.IsDigit).ToArray())}@s.whatsapp.net"; + await _noiseProcessor.SendMediaAsync(jid, fileBytes, mediaType, mimeType, caption, fileName, cancellationToken); + } + + /// + /// Sends an emoji reaction to a specific message. + /// + public Task SendReactionAsync(string targetJid, string targetMessageId, bool targetFromMe, string emoji, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.SendReactionAsync(targetJid, targetMessageId, targetFromMe, emoji, ct); + } + + /// Sends a typing indicator (composing) or stops it (paused) for a specific chat. + public Task SendTypingAsync(string jid, bool isTyping, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.SendTypingAsync(jid, isTyping, ct); + } + + /// Updates this device's presence to available or unavailable. + public Task SendUserPresenceAsync(bool isOnline, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.SendUserPresenceAsync(isOnline, ct); + } + + /// Deletes a sent message for everyone (delete for everyone). + public Task RevokeMessageAsync(string jid, string messageId, bool fromMe, long timestamp, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.RevokeMessageAsync(jid, messageId, fromMe, timestamp, ct); + } + + /// Forwards a message (as text) to another chat. + public Task ForwardMessageAsync(string toJid, string text, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.ForwardMessageAsync(toJid, text, ct); + } + + /// Creates a new WhatsApp group. Returns the group JID or null on failure. + public Task CreateGroupAsync(string subject, IEnumerable participantJids, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.CreateGroupAsync(subject, participantJids, ct); + } + + /// Leaves a WhatsApp group. + public Task LeaveGroupAsync(string groupJid, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.LeaveGroupAsync(groupJid, ct); + } + + /// Adds participants to a group. + public Task> AddGroupParticipantsAsync(string groupJid, IEnumerable jids, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.AddGroupParticipantsAsync(groupJid, jids, ct); + } + + /// Removes participants from a group. + public Task> RemoveGroupParticipantsAsync(string groupJid, IEnumerable jids, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.RemoveGroupParticipantsAsync(groupJid, jids, ct); + } + + /// Gets the group invite link URL. + public Task GetGroupInviteLinkAsync(string groupJid, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.GetGroupInviteLinkAsync(groupJid, ct); + } + + /// Updates the group subject (name). + public Task UpdateGroupSubjectAsync(string groupJid, string newSubject, CancellationToken ct) + { + if (_noiseProcessor == null || _state != ConnectionState.Connected) + throw new InvalidOperationException("Client is not connected."); + return _noiseProcessor.UpdateGroupSubjectAsync(groupJid, newSubject, ct); + } + + /// Downloads and decrypts a media file from the WhatsApp CDN. + public static Task DownloadMediaAsync(string mediaUrl, string mediaKeyBase64, string mimeType) + => Noise.NoiseProcessor.DownloadMediaAsync(mediaUrl, mediaKeyBase64, mimeType); + + /// Gets the delivery/read status of a sent message. + public Messages.MessageStatus? GetMessageStatus(string messageId) + => _noiseProcessor?.GetMessageStatus(messageId); + + /// Fired when a message delivery/read receipt is received. + public event EventHandler<(string MessageId, Messages.MessageStatus Status)>? MessageStatusUpdated + { + add { if (_noiseProcessor != null) _noiseProcessor.MessageStatusUpdated += value; } + remove { if (_noiseProcessor != null) _noiseProcessor.MessageStatusUpdated -= value; } + } + // ─── Disconnection ─────────────────────────────────────────────────────── /// Disconnects from WhatsApp and cleans up resources. @@ -198,33 +434,75 @@ public void Logout() private async Task RunReceiveLoopAsync(CancellationToken ct) { + var shouldReconnect = false; try { await _noiseProcessor!.ReceiveLoopAsync(ct); } - catch (OperationCanceledException) { } + catch (OperationCanceledException) { return; } catch (Exception ex) { _logger.LogError(ex, "Receive loop crashed."); } finally { - if (_state == ConnectionState.Connected) + // Reconnect if we were connected OR authenticating (QR phase): + // WhatsApp server closes the stream after sending QR refs — client must + // reconnect to pick up pair-success once the phone scans the code. + shouldReconnect = _state == ConnectionState.Connected + || _state == ConnectionState.Authenticating; + if (_state != ConnectionState.Disconnected && !ct.IsCancellationRequested) { SetState(ConnectionState.Disconnected); Disconnected?.Invoke(this, EventArgs.Empty); + } + } + + if (shouldReconnect && _options.AutoReconnect && !ct.IsCancellationRequested) + { + // Capture any routing info for the next connection before clearing the processor + var routingInfo = _noiseProcessor?.PendingRoutingInfo; + if (routingInfo != null) + { + _pendingRoutingInfo = routingInfo; + _logger.LogInformation("Reconnecting with edge routing info ({Len} bytes).", routingInfo.Length); + } - if (_options.AutoReconnect && !ct.IsCancellationRequested) + // Supervised reconnect loop with exponential backoff + jitter, capped at 5 min. + // Previously this was one-shot: a single failed ConnectAsync left the client dead + // until a manual app-pool recycle. The backoff is deliberately gentle to stay under + // WhatsApp's anti-abuse throttle (which blocks reconnects after too many rapid tries). + var attempt = 0; + while (_options.AutoReconnect && !ct.IsCancellationRequested) + { + attempt++; + var delay = ComputeReconnectBackoff(attempt); + _logger.LogInformation("Reconnect attempt {N} in {Sec:F0}s.", attempt, delay.TotalSeconds); + try { await Task.Delay(delay, CancellationToken.None); } catch { break; } + if (ct.IsCancellationRequested) break; + try { - _logger.LogInformation("Reconnecting in {Delay}…", _options.ReconnectDelay); - await Task.Delay(_options.ReconnectDelay, CancellationToken.None); - try { await ConnectAsync(CancellationToken.None); } - catch (Exception ex) { _logger.LogError(ex, "Reconnect failed."); } + await ConnectAsync(CancellationToken.None); + _logger.LogInformation("Reconnect attempt {N} succeeded.", attempt); + return; // success — a fresh receive loop is now running + } + catch (Exception ex) + { + _logger.LogError(ex, "Reconnect attempt {N} failed; will retry with backoff.", attempt); } } } } + private static readonly Random _reconnectRng = new(); + /// Exponential backoff (base 5s, ×2, capped 5min) with full jitter, for reconnect attempts. + private static TimeSpan ComputeReconnectBackoff(int attempt) + { + var raw = Math.Min(300.0, 5.0 * Math.Pow(2, Math.Min(attempt - 1, 6))); + var jittered = raw * (0.5 + _reconnectRng.NextDouble() * 0.5); + return TimeSpan.FromSeconds(jittered); + } + private void SetState(ConnectionState state) { if (_state == state) return; diff --git a/tests/Dawa.Tests/BinaryTests.cs b/tests/Dawa.Tests/BinaryTests.cs new file mode 100644 index 0000000..b4b42fd --- /dev/null +++ b/tests/Dawa.Tests/BinaryTests.cs @@ -0,0 +1,100 @@ +using Dawa.Binary; +using Xunit; + +namespace Dawa.Tests; + +/// +/// Encode → decode round-trip tests for the WhatsApp binary node codec. The complete +/// Dawa handles the transport flag byte at the socket layer, so the encoder/decoder +/// operate on raw node bytes; these tests exercise that raw codec directly. +/// +public class BinaryTests +{ + private static string? AsString(object? content) => + content is byte[] b ? System.Text.Encoding.UTF8.GetString(b) : content as string; + + [Fact] + public void RoundTrip_RawStrings_AndAttributes() + { + var node = new BinaryNode( + "zzz_custom_tag", + new() { ["zzz_attr_a"] = "value-1234", ["zzz_attr_b"] = "another+value" }, + "zzz_text_content"); + + var decoded = BinaryNodeDecoder.Decode(BinaryNodeEncoder.Encode(node)); + + Assert.Equal(node.Tag, decoded.Tag); + Assert.Equal("value-1234", decoded.Attrs["zzz_attr_a"]); + Assert.Equal("another+value", decoded.Attrs["zzz_attr_b"]); + Assert.Equal("zzz_text_content", AsString(decoded.Content)); + } + + [Fact] + public void RoundTrip_DictionaryTokens() + { + // "iq", "type", "id" etc. are real WhatsApp tokens — must survive tokenised. + var node = new BinaryNode("iq", + new() { ["type"] = "result", ["id"] = "ABC123", ["xmlns"] = "md" }); + + var decoded = BinaryNodeDecoder.Decode(BinaryNodeEncoder.Encode(node)); + + Assert.Equal("iq", decoded.Tag); + Assert.Equal("result", decoded.Attrs["type"]); + Assert.Equal("ABC123", decoded.Attrs["id"]); + Assert.Equal("md", decoded.Attrs["xmlns"]); + } + + [Fact] + public void RoundTrip_NestedChildNodes() + { + var child1 = new BinaryNode("ref", null, System.Text.Encoding.UTF8.GetBytes("REF-TOKEN-1")); + var child2 = new BinaryNode("ref", null, System.Text.Encoding.UTF8.GetBytes("REF-TOKEN-2")); + var parent = new BinaryNode("pair-device", null, new List { child1, child2 }); + var root = new BinaryNode("iq", new() { ["type"] = "set" }, new List { parent }); + + var decoded = BinaryNodeDecoder.Decode(BinaryNodeEncoder.Encode(root)); + + Assert.Equal("iq", decoded.Tag); + var pd = decoded.FindChild("pair-device"); + Assert.NotNull(pd); + var refs = pd!.GetChildren("ref").ToList(); + Assert.Equal(2, refs.Count); + Assert.Equal("REF-TOKEN-1", AsString(refs[0].Content)); + Assert.Equal("REF-TOKEN-2", AsString(refs[1].Content)); + } + + [Fact] + public void RoundTrip_JidAttribute() + { + var node = new BinaryNode("message", + new() { ["from"] = "31612345678@s.whatsapp.net", ["to"] = "@s.whatsapp.net" }); + + var decoded = BinaryNodeDecoder.Decode(BinaryNodeEncoder.Encode(node)); + + Assert.Equal("31612345678@s.whatsapp.net", decoded.Attrs["from"]); + Assert.Equal("@s.whatsapp.net", decoded.Attrs["to"]); + } + + [Fact] + public void RoundTrip_BinaryContent_Preserved() + { + var payload = new byte[300]; + new Random(42).NextBytes(payload); // >255 forces the 20-bit length path + var node = new BinaryNode("enc", new() { ["type"] = "pkmsg" }, payload); + + var decoded = BinaryNodeDecoder.Decode(BinaryNodeEncoder.Encode(node)); + Assert.Equal(payload, decoded.Data); + } + + [Fact] + public void TokenTables_HaveExpectedShape() + { + // Regenerated verbatim from Baileys: 236 single-byte, 4×256 double-byte. + Assert.Equal(236, WATags.SingleByteTokens.Length); + Assert.Equal(4, WATags.AllDictionaries.Length); + Assert.All(WATags.AllDictionaries, d => Assert.Equal(256, d.Length)); + // Spot-check known indices used during pairing. + Assert.Equal("iq", WATags.SingleByteTokens[25]); + Assert.Equal("s.whatsapp.net", WATags.SingleByteTokens[3]); + } +} diff --git a/tests/Dawa.Tests/CryptoTests.cs b/tests/Dawa.Tests/CryptoTests.cs new file mode 100644 index 0000000..600d575 --- /dev/null +++ b/tests/Dawa.Tests/CryptoTests.cs @@ -0,0 +1,161 @@ +using System.Security.Cryptography; +using Dawa.Auth; +using Dawa.Crypto; +using Dawa.Noise; +using Org.BouncyCastle.Math.EC.Rfc8032; +using Xunit; + +namespace Dawa.Tests; + +/// +/// Deterministic, offline tests of Dawa's cryptographic primitives — the layers a +/// live WhatsApp handshake depends on. This is the complete (live-verified) Dawa, so +/// these lock in behaviour that is already known to pair against production. +/// +public class CryptoTests +{ + // ── XEdDSA: sign then verify, both with Dawa's own Verify and independently ── + [Fact] + public void XEdDSA_SignVerify_RoundTrips_AcrossManyKeys() + { + for (var i = 0; i < 32; i++) + { + var (priv, pub) = Curve25519Helper.GenerateKeyPair(); + var msg = System.Text.Encoding.UTF8.GetBytes($"message {i}"); + var sig = XEdDSA.Sign(priv, msg); + + Assert.Equal(64, sig.Length); + Assert.True(XEdDSA.Verify(pub, msg, sig), $"Dawa.Verify rejected its own signature (key {i})"); + // Tampered message must fail. + var bad = (byte[])msg.Clone(); bad[0] ^= 0xFF; + Assert.False(XEdDSA.Verify(pub, bad, sig)); + } + } + + // ── XEdDSA signatures verify under an INDEPENDENT Ed25519 verifier ── + // This is the exact check WhatsApp runs on the signed pre-key at registration. + // Reconstruct the Edwards key from the Montgomery key with sign bit 0. + [Fact] + public void XEdDSA_Signatures_VerifyUnderIndependentEd25519() + { + var p = System.Numerics.BigInteger.Pow(2, 255) - 19; + for (var i = 0; i < 32; i++) + { + var (priv, montPub) = Curve25519Helper.GenerateKeyPair(); + var msg = System.Text.Encoding.UTF8.GetBytes($"prekey material {i}"); + var sig = XEdDSA.Sign(priv, msg); + + // y = (u-1)/(u+1) mod p, packed little-endian, sign bit 0. + var u = LoadLE(montPub) % p; + var y = ((u - 1 + p) % p) * System.Numerics.BigInteger.ModPow((u + 1) % p, p - 2, p) % p; + var edPub = ToLE32(y); edPub[31] &= 0x7F; + + Assert.True(Ed25519.Verify(sig, 0, edPub, 0, msg, 0, msg.Length), + $"XEdDSA signature failed independent Ed25519 verification (key {i})"); + } + } + + // ── AES-GCM counter transport: roundtrip + tamper detection ── + [Fact] + public void AesGcm_CounterRoundtrips_AndDetectsTampering() + { + var key = RandomNumberGenerator.GetBytes(32); + var pt = System.Text.Encoding.UTF8.GetBytes("transport frame payload"); + var aad = System.Text.Encoding.UTF8.GetBytes("hash"); + + var ct = AesGcmHelper.EncryptWithCounter(key, 5, pt, aad); + Assert.Equal(pt, AesGcmHelper.DecryptWithCounter(key, 5, ct, aad)); + + // Wrong counter and tampered ciphertext must both fail authentication. + Assert.ThrowsAny(() => AesGcmHelper.DecryptWithCounter(key, 6, ct, aad)); + var bad = (byte[])ct.Clone(); bad[0] ^= 0xFF; + Assert.ThrowsAny(() => AesGcmHelper.DecryptWithCounter(key, 5, bad, aad)); + } + + [Fact] + public void AesGcm_NoncePrefixAndRaw_Roundtrip() + { + var key = RandomNumberGenerator.GetBytes(32); + var pt = System.Text.Encoding.UTF8.GetBytes("hello"); + + var withPrefix = AesGcmHelper.Encrypt(key, pt); // nonce(12)+ct+tag + Assert.Equal(pt, AesGcmHelper.DecryptWithNoncePrefix(key, withPrefix)); + + var nonce = RandomNumberGenerator.GetBytes(12); + var raw = AesGcmHelper.EncryptRaw(key, nonce, pt); + Assert.Equal(pt, AesGcmHelper.DecryptRaw(key, nonce, raw)); + } + + // ── HKDF: deterministic, and DeriveKeys splits the 64-byte output in order ── + [Fact] + public void Hkdf_IsDeterministic_AndSplitsInOrder() + { + var ikm = RandomNumberGenerator.GetBytes(32); + var salt = RandomNumberGenerator.GetBytes(32); + + Assert.Equal(DawaHKDF.DeriveKey(ikm, salt, null, 64), DawaHKDF.DeriveKey(ikm, salt, null, 64)); + + var full = DawaHKDF.DeriveKey(ikm, salt, null, 64); + var (first, second) = DawaHKDF.DeriveKeys(ikm, salt); + Assert.Equal(full[..32], first); + Assert.Equal(full[32..], second); + } + + // ── Curve25519 DH agreement (both sides derive the same shared secret) ── + [Fact] + public void Curve25519_DH_Agrees() + { + var (aPriv, aPub) = Curve25519Helper.GenerateKeyPair(); + var (bPriv, bPub) = Curve25519Helper.GenerateKeyPair(); + Assert.Equal(Curve25519Helper.DH(aPriv, bPub), Curve25519Helper.DH(bPriv, aPub)); + } + + // ── NoiseState.MixKey: two parties with the same input derive matching keys ── + [Fact] + public void NoiseMixKey_TwoParties_AgreeOnTransport() + { + var ikm = RandomNumberGenerator.GetBytes(32); + var aad = RandomNumberGenerator.GetBytes(16); + var a = new NoiseState(); + var b = new NoiseState(); + a.MixHash(aad); b.MixHash(aad); + a.MixKey(ikm); b.MixKey(ikm); + + var pt = System.Text.Encoding.UTF8.GetBytes("mutual key check"); + var ct = a.EncryptWithAssociatedData(pt); + Assert.Equal(pt, b.DecryptWithAssociatedData(ct)); + } + + // ── AuthState.CreateNew: the signed pre-key it generates actually verifies ── + // This is what the server checks during registration; the live pairing proves it, + // this locks it offline. + [Fact] + public void AuthState_SignedPreKey_Verifies() + { + var s = AuthState.CreateNew(); + Assert.Equal(32, s.SignedPreKeyPublic.Length); + Assert.Equal(64, s.SignedPreKeySignature.Length); + + // Signature is over the 0x05-prefixed key; verify with Dawa's own Verify against + // the identity public key. + Assert.True(XEdDSA.Verify(s.SignedIdentityKeyPublic, Prefix05(s.SignedPreKeyPublic), s.SignedPreKeySignature)); + } + + private static byte[] Prefix05(byte[] pub) + { + var m = new byte[33]; m[0] = 0x05; pub.CopyTo(m, 1); return m; + } + + private static System.Numerics.BigInteger LoadLE(byte[] b) + { + var c = (byte[])b.Clone(); if (c.Length >= 32) c[31] &= 0x7F; + var t = new byte[c.Length + 1]; Array.Copy(c, t, c.Length); + return new System.Numerics.BigInteger(t); + } + + private static byte[] ToLE32(System.Numerics.BigInteger n) + { + var src = n.ToByteArray(); var dst = new byte[32]; + Array.Copy(src, dst, Math.Min(src.Length, 32)); return dst; + } +} diff --git a/tests/Dawa.Tests/Dawa.Tests.csproj b/tests/Dawa.Tests/Dawa.Tests.csproj new file mode 100644 index 0000000..c6d0dfb --- /dev/null +++ b/tests/Dawa.Tests/Dawa.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/tests/Dawa.Tests/JidTests.cs b/tests/Dawa.Tests/JidTests.cs new file mode 100644 index 0000000..a96321e --- /dev/null +++ b/tests/Dawa.Tests/JidTests.cs @@ -0,0 +1,47 @@ +using Dawa.Models; +using Xunit; + +namespace Dawa.Tests; + +public class JidTests +{ + [Fact] + public void Parse_UserServer() + { + var j = JID.Parse("31612345678@s.whatsapp.net"); + Assert.Equal("31612345678", j.User); + Assert.Equal("s.whatsapp.net", j.Server); + Assert.Null(j.Device); + Assert.True(j.IsUser); + } + + [Fact] + public void Parse_UserDeviceServer() + { + var j = JID.Parse("824767959274:96@lid"); + Assert.Equal("824767959274", j.User); + Assert.Equal("96", j.Device); + Assert.Equal("lid", j.Server); + } + + [Fact] + public void Parse_Group() + { + var j = JID.Parse("120363012345678901@g.us"); + Assert.True(j.IsGroup); + Assert.Equal("g.us", j.Server); + } + + [Fact] + public void ToString_RoundTrips() + { + Assert.Equal("31612345678@s.whatsapp.net", JID.Parse("31612345678@s.whatsapp.net").ToString()); + Assert.Equal("824767959274:96@lid", JID.Parse("824767959274:96@lid").ToString()); + } + + [Fact] + public void Parse_EmptyThrows() + { + Assert.Throws(() => JID.Parse(" ")); + } +}