Compare commits

..

151 Commits

Author SHA1 Message Date
AsamK
dac64f773d Add support for recovering account with recovery key 2026-09-20 21:54:03 +02:00
AsamK
926cd256e6 Reformat files 2026-09-20 21:14:45 +02:00
AsamK
3d7109eb01 Remove numberless flag in accounts file
This is an edge case and keeping the ACI only account discoverable is
actually the better way to handle it.
2026-09-20 21:14:45 +02:00
Mariusz Woloszyn
d0724fb5b5
Support linking phone-numberless Signal accounts (#2123)
* Support linking phone-numberless Signal accounts

* Align numberless account support with project style

* Support numberless accounts over D-Bus

* Keep superseded accounts out of numberless account discovery

Record numberless accounts explicitly instead of treating every retained ACI
as a discoverable account. Resolve older index entries against saved account
state, preserving ACI lookup and retrying unavailable state on later reads.

The optional index field remains compatible with version 2 readers. Update
native-image reflection metadata and cover replacement, legacy migration,
locked and missing state, and mismatched ACIs in regression tests.

* Address numberless linking review cleanup

Reuse AccountApiV2 through SignalDependencies and use the shared suspend response helper while preserving linking error handling. Remove the unused account-number accessor and unsupported D-Bus version fallback. Keep numberless linking documentation in the man page and point the README to the wiki.

Validate linking errors through HTTP response fixtures. All 196 tests pass on JDK 25 with an isolated D-Bus session; build and JSON schema generation also pass. Account-index classification is unchanged pending review discussion.
2026-09-20 17:51:57 +02:00
nimble-turtle
60649041c8
man: link prints a QR code itself, don't point at qrencode first (#2126) 2026-09-20 09:35:55 +02:00
AsamK
e1d7bc5587 Return actual error of NonSuccess request result 2026-09-18 22:37:36 +02:00
Dmytro Lomako
c146224493
Add --attachment-dimensions and --attachment-blurhash to send (#2120)
* Accept caller-supplied attachment dimensions

Forward positional WIDTHxHEIGHT metadata supplied by the caller. Validate dimensions before sending and retain automatic image detection for omitted entries.

* Accept caller-supplied attachment BlurHashes

Forward positional BlurHashes supplied by the caller alongside attachment dimensions. Reject BlurHashes whose length doesn't match their size digit.

* Skip image probing when dimensions are supplied

Caller-supplied dimensions replace the probed values, so don't buffer and decode the image to measure it.
2026-09-17 16:58:40 +02:00
AsamK
a255a7fecf Update graalvm metadata
Fixes #2118
Fixes #2113
Fixes #2108
Fixes #2101
Fixes #2086
Fixes #2044
2026-09-14 14:19:27 +02:00
AsamK
752f63a5e6 Update graalvm metadata 2026-09-14 13:16:56 +02:00
AsamK
52a0f6415e Prepare next release 2026-09-10 19:58:04 +02:00
AsamK
22b028bbb9 Bump version to 0.14.8 2026-09-10 19:56:41 +02:00
AsamK
ff689b577b Fix storage sync edge cases 2026-09-10 19:26:02 +02:00
AsamK
ccd7c77a9d Mark --poll-author parameter as required
Fixes #2111
2026-09-10 17:35:10 +02:00
AsamK
76fee5dda2 Fix handling of unknown storage id types that become known
Fixes #2112
2026-09-10 17:31:00 +02:00
AsamK
45cbe82d7f Update dependencies 2026-09-10 16:59:23 +02:00
AsamK
089139909b Update libsignal-service 2026-09-10 16:53:14 +02:00
AsamK
f9dbf6bfff Suppress unchecked warnings 2026-09-10 16:34:10 +02:00
AsamK
6b7b94fe1f Update gradle 2026-09-10 16:34:00 +02:00
AsamK
2c03e5fd8d Reformat files 2026-09-10 16:26:57 +02:00
AsamK
7cc0cc3e0e Prevent storage service loops on ACI identity key changes 2026-09-10 16:26:41 +02:00
AsamK
48731286eb Prevent storage sync loop on PNI-only records with different identity keys 2026-09-10 16:19:21 +02:00
AsamK
3c17e01c37 Fix storage sync issues 2026-08-21 21:39:51 +02:00
AsamK
9605c94f09 Improve contact merging 2026-08-21 20:47:35 +02:00
AsamK
d2f5696df1 Update libsignal-service 2026-08-21 20:35:42 +02:00
AsamK
81e513d6b7 Implement storage sync loop detector 2026-08-21 18:46:03 +02:00
AsamK
dbf11fd006 Update graalvm build tools 2026-08-21 18:37:35 +02:00
Bazyli Brzóska
7e802166a3
fix(json): expose data message metadata (#2104) 2026-08-17 12:40:27 +02:00
Bazyli Brzóska
0f88410465
feat(json): expose voice-note attachment metadata (#2100)
Include isVoiceNote in receive JSON and JSON-RPC attachment payloads. Add regression coverage for true and false serialization.
2026-08-10 10:38:27 +02:00
Javair Ratliff
13819c8f09
Support the GroupsV2 "end group" (terminate) feature (#2098)
* Show and enforce the terminated state of a group

A group admin can permanently terminate a group. Afterwards the group is
read-only for everyone, not even an admin can send messages or start calls;
members keep access to the existing history. Other clients silently drop
messages sent to a terminated group.

Read the DecryptedGroup.terminated flag through GroupInfo.isTerminated() and:

- surface it in listGroups (json and plain text) and as the DBus IsTerminated
  group property
- refuse to send to a terminated group locally (messages, reactions, typing and
  group stories) rather than send one that other clients ignore
- drop incoming messages addressed to a terminated group

* Add terminateGroup command to terminate a group for everyone

Let a group admin permanently terminate a GroupV2 group via the
TerminateGroupAction (Groups.proto field 28, change epoch 7) the pinned library
already exposes as GroupsV2Operations.createTerminateGroup().

- GroupV2Helper.terminateGroup builds and commits the change
- GroupHelper.terminateGroup resolves/refreshes the group (v2 only), sends the
  update to members and syncs storage, with the same conflict-retry as
  updateGroup
- exposed through Manager.terminateGroup, the terminateGroup CLI/JSON-RPC command
  and the DBus Group.terminateGroup method

---------

Co-authored-by: FailSpy <FailSpy@users.noreply.github.com>
2026-08-08 17:16:49 +02:00
AsamK
85580d1ff6 Fix graalvm issue with image dimension probing
Fixes #2096
2026-08-04 13:16:58 +02:00
AsamK
f8ea8ed314 Update graalvm build tools 2026-08-04 13:14:29 +02:00
AsamK
472784e614 Replace unmaintained release actions 2026-08-02 08:45:09 +02:00
AsamK
db97731f63 Update redhat-actions in workflow 2026-08-02 08:45:09 +02:00
AsamK
ff9f2028ae Prepare next release 2026-08-02 08:45:09 +02:00
AsamK
9ee5262876 Prepare next release 2026-08-01 10:07:44 +02:00
AsamK
b01b6b370d Fix build pipeline 2026-08-01 09:38:01 +02:00
AsamK
890f798a03 Bump version to 0.14.7 2026-08-01 09:30:18 +02:00
AsamK
3c319f8b4a Reformat files 2026-08-01 09:24:09 +02:00
AsamK
fd3f572499 Use nickname for displaying recipient names 2026-08-01 09:21:52 +02:00
AsamK
854cb35d15 Explicitly store pni signature verified 2026-07-31 13:20:34 +02:00
AsamK
e78521bb5c Always set e164 in sync if available 2026-07-31 13:09:00 +02:00
AsamK
86ad7bf53f Always set the read receipts setting during provisioning if available 2026-07-31 13:08:13 +02:00
AsamK
de5daf0ad4 Cleanup incomplete account data after unsuccessful link
Fixes #2080
2026-07-31 09:01:08 +02:00
AsamK
ecc76ec52b Improve handling of pni change number messages 2026-07-30 22:44:06 +02:00
AsamK
6fa1d1bf90 Fix marking sender as needs pni signature 2026-07-30 22:44:06 +02:00
AsamK
d29f6c1641 Fix removing self from pending group invite via PNI 2026-07-30 22:44:06 +02:00
AsamK
b3e3eda4f7 Use upstream provisioning functionality 2026-07-30 22:44:06 +02:00
AsamK
1e59c6f6cc Readd job to update github dependency graph 2026-07-30 17:39:40 +02:00
AsamK
457658aca0 Update dependencies 2026-07-29 22:33:49 +02:00
AsamK
2cf5e25e9e Implement sticker pack sync 2026-07-29 22:33:49 +02:00
AsamK
151b42c6e9 Update libsignal-service 2026-07-29 22:33:48 +02:00
AsamK
91138452bb Update graalvm build tools 2026-07-29 19:20:31 +02:00
AsamK
304ffb0e33 Add special handling for file input stream attachments 2026-07-29 19:20:22 +02:00
mikesimone
987832099d
Set width/height on outgoing image attachments (#2093)
createAttachmentStream() never populated SignalServiceAttachmentStream's
width/height fields, even though the Builder has supported them since at
least the currently pinned libsignal-service version. Without them, other
Signal clients render outgoing image attachments as a square-cropped
thumbnail instead of their actual aspect ratio.

Probe dimensions via ImageIO for image/* attachments up to 20MiB (buffered
in memory only for that check) and pass them through to the builder.
Falls back to width/height 0 (today's behavior) for non-images, oversized
files, or anything ImageIO can't parse.
2026-07-29 19:07:06 +02:00
Arnout Engelen
64a629002d
docs: clarify register vs link (#2094)
This wasn't obvious to me, happy I asked in #2092 :)
2026-07-26 18:47:30 +02:00
AsamK
c26e0632c5 Prepare next release 2026-07-13 09:12:11 +02:00
AsamK
21dd00d96a Bump version to 0.14.6 2026-07-13 00:16:14 +02:00
AsamK
95c6dae927 Replace deprecated native-image-configure 2026-07-13 00:16:14 +02:00
AsamK
bb2409a7f2 Improve tests 2026-07-13 00:16:14 +02:00
AsamK
89756bb7a7 Refactor group story send 2026-07-12 17:04:32 +02:00
tonycpsu
6808b66897
Add group story support via --group-id (#2083)
* Add group story support to core library layer

Extend Manager.sendStory() with an optional GroupId parameter and add
SendHelper.sendGroupStoryMessage() for endorsement-aware group story
delivery, laying the groundwork for group story support (task 1 of 4).
The existing My Story code path is unchanged.

* Add --group-id support to SendStoryCommand

Passes an optional GroupId through to Manager.sendStory so stories can
be posted to a group instead of only My Story, and surfaces
GroupNotFoundException / NotAGroupMemberException as user errors.

* Update stubs for 3-parameter sendStory and add empty-recipient guard

Updates DbusManagerImpl and StubManager (in SubscribeCallEventsTest) to
match the new 3-parameter sendStory signature: (String attachment,
boolean allowsReplies, Optional<GroupId> groupId).

Also includes the empty-recipient guard added after Task 1 review to
prevent stories from being sent to groups where the user is the only member.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

* Document group story support in man page and changelog

- Add --group-id (-g) option to sendStory command in man page
- Update CHANGELOG to mention group story support via --group-id
- Maintain alphabetical order of options in sendStory section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

* Address review findings: endorsement safety and error handling

- Filter group story recipients by ACI type and endorsement availability
  to prevent ClassCastException and NPE on edge cases
- Add empty-recipient guard after endorsement filtering
- Skip known-unregistered recipients before address resolution, matching
  the pattern from sendGroupMessageInternal
- Add debug logging when recipients are filtered out
- Fix redundant error message prefixing in SendStoryCommand
- Add .superpowers/ to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 16:32:40 +02:00
AsamK
14f98602d2 Update README 2026-07-12 15:35:50 +02:00
AsamK
fd98cce8b3 Don't send read receipt if disabled in configuration
Only send sync read message in this case
2026-07-12 10:50:43 +02:00
AsamK
4f4a4b9fb4 Refactor manager mock 2026-07-12 10:21:37 +02:00
AsamK
e15c29e4e8 Remove old svr2 enclave 2026-07-12 10:10:36 +02:00
dependabot[bot]
2e367644d9
Bump com.fasterxml.jackson.core:jackson-databind from 2.20.2 to 2.21.5 (#2085)
Bumps [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) from 2.20.2 to 2.21.5.
- [Commits](https://github.com/FasterXML/jackson/commits)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson.core:jackson-databind
  dependency-version: 2.21.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 10:03:21 +02:00
tonycpsu
78dba20a59
Bound SessionStore session cache to prevent unbounded memory growth (#2087)
The cachedSessions HashMap grows with every unique (address, deviceId)
pair seen during message processing and is never evicted. In a
long-running daemon handling group messages, this causes linear memory
growth (~47 MB/hour observed) as SessionRecord objects accumulate for
every contact/device the daemon has ever communicated with.

Replace the unbounded HashMap with an LRU-bounded LinkedHashMap (access
order, max 1000 entries). Evicted sessions are reloaded from SQLite on
next access, so correctness is preserved.


Claude-Session: https://claude.ai/code/session_01HHzM2XLKQoX9iraEdhoh3h

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 08:12:30 +02:00
tonycpsu
b48ccf2605
Bound RecipientStore address cache to prevent unbounded memory growth (#2088)
The recipientAddressCache grows with every unique ServiceId resolved via
findByServiceId() and entries are never evicted. In a long-running daemon
handling group messages from many contacts, this map grows monotonically.

Replace the unbounded HashMap with an LRU-bounded LinkedHashMap (access
order, max 2000 entries). Evicted entries are reloaded from SQLite on
next access via an indexed lookup, so correctness is preserved.


Claude-Session: https://claude.ai/code/session_01HHzM2XLKQoX9iraEdhoh3h

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 08:11:24 +02:00
AsamK
54d713ea7d Improve loading accounts by aci 2026-07-11 19:03:48 +02:00
AsamK
4dda7582a3 Add getSelfACI to manager 2026-07-11 18:52:52 +02:00
tilllt
248c0c0dab
Fix HTTP handler to accept ACI/UUID account parameter in SSE endpoint (#2079)
* Fix HTTP handler to accept ACI/UUID account parameter in SSE endpoint

MultiAccountManagerImpl.getManager() only looked up accounts by phone
number, causing HTTP 400 errors when the SSE events endpoint was called
with ?account=<ACI-UUID> (e.g., cc528f93-527e-4566-8c62-d12dc99dbce0).

Changes:
- SignalAccountFiles: Add initManagerByAci() method for ACI-based lookup
- MultiAccountManagerImpl: getManager() now tries ACI lookup when
  phone number lookup fails
- HttpServerHandler: getManagerFromQuery() falls back to returning all
  managers when the specific account identifier is not found (instead of
  HTTP 400)

* Fix SSE endpoint for UUID account parameter & preserve '+' in phone numbers

Three interrelated fixes for the HTTP SSE endpoint:

1. **SignalAccountFiles** — Replace ACI.parseOrThrow() with UUID-string
   lookup from accountsStore.getAllAccounts(). The old approach failed when
   a raw UUID string (from URL query param) was passed. Added
   getAccountNumberByAci() helper to reduce duplication.

2. **MultiAccountManagerImpl** — Catch IllegalArgumentException in
   getManager() for both phone number and ACI lookup paths. Also check if
   the UUID corresponds to an already-loaded manager before trying to
   initByAci(), preventing OverlappingFileLockException when SSE requests
   arrive with a UUID for an account that was loaded at startup.

3. **Util.getQueryMap()** — Preserve '+' characters in query parameter
   values by escaping them before URLDecoder.decode(). Without this,
   URLDecoder converts '+' to space, breaking phone numbers like
   '+4915422389' which become ' 4915422389'.

* fix: address AsamK's review comments

- HttpServerHandler.getManagerFromQuery(): return null when account not
  found instead of falling back to all managers (AsamK: 'should stay
  return null here')
- MultiAccountManagerImpl.getManager(): use UuidUtil.isUuid() to branch
  early on ACI vs phone number, eliminating the try-number-then-fallback
  pattern (AsamK: 'check if identifier is a uuid first')

---------

Co-authored-by: Till L T <tilllt@users.noreply.github.com>
2026-07-11 18:19:15 +02:00
Gara Dorta
e70bddd790
feat: add schemas to the release CI (#2040) 2026-07-11 16:47:41 +02:00
tonycpsu
ac5ed431d3
Add sendStory command for posting file attachment stories (#2082)
* Add sendStory method for posting file attachment stories to My Story

Adds Manager.sendStory(attachment, allowsReplies), which uploads a file
attachment, builds a SignalServiceStoryMessage, and sends it to all
registered, non-blocked, non-hidden contacts that haven't opted out of
seeing the user's story (Contact.hideStory), excluding self. SendHelper
gains sendStoryMessage(), which resolves recipient addresses and
unidentified access and delegates to
SignalServiceMessageSender.sendGroupStory() against
DistributionId.MY_STORY, following the same address/access resolution
pattern used for group sends. A sync transcript is sent afterwards via
sendStorySyncMessage() so linked devices see the story was posted.

This is core library plumbing only; no CLI command, stub
implementations, or documentation are added yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

* Add sendStory command for posting stories via CLI and JSON-RPC

Implements SendStoryCommand to allow users to post stories through the
CLI and JSON-RPC interfaces. Command accepts an attachment file path
(required) and optional --no-replies flag to disable replies on the story.

Handles AttachmentInvalidException and IOException appropriately and
outputs results using SendMessageResultUtils.

Registered in Commands.java in alphabetical order.

* Add sendStory stubs to DbusManagerImpl and StubManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

* Document sendStory in man page and changelog

* Validate story attachment MIME type and fix D-Bus stub

- Reject non-image/video attachments before uploading, since stories
  only support image and video content
- Change DbusManagerImpl.sendStory to throw UnsupportedOperationException
  to match the pattern used by all other unimplemented D-Bus methods

* Resolve recipients before uploading story attachment

Move recipient resolution ahead of the attachment upload so that an
empty contact list is caught early without wasting bandwidth on an
upload that would reach nobody.

* Address review feedback: fix hideStory filter and remove redundant sync

- Fix hideStory filter to require contact exists (!=null &&) instead of
  permitting null contacts (==null ||), matching the intent of filtering
  to contacts who haven't hidden stories
- Remove manual sendStorySyncMessage call, as the library's sendStory
  already handles sync internally

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHzM2XLKQoX9iraEdhoh3h

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-11 16:34:58 +02:00
AsamK
fb10e1a501 Fix sync issue 2026-07-11 14:37:20 +02:00
AsamK
2a744edd43 Only sync e164 if pni is also present 2026-07-11 14:00:43 +02:00
AsamK
c95a67862e Update libsignal-service 2026-07-11 13:18:45 +02:00
AsamK
5a525f51f6 Update gradle 2026-07-11 12:58:35 +02:00
AsamK
eab6ffbd8c Fix gradle deprecation warning 2026-07-11 12:58:04 +02:00
AsamK
8809b90b72 Update graalvm build tools 2026-07-11 12:57:42 +02:00
AsamK
98d9960e66 Update graalvm build tools 2026-07-02 10:31:14 +02:00
AsamK
254efba643 Prepare next release 2026-07-02 10:30:43 +02:00
AsamK
bb433ebe59 Print hangup message details 2026-07-02 10:29:51 +02:00
KritBlade
5ac06a5ccd
Skip known-unregistered recipients when sending to groups (#2077)
Group sends built the recipient list from the full group membership, so
members already known to be unregistered were retried on every send via
the legacy 1:1 fan-out. On large groups this made a single send take
tens of seconds and could time out, leaving the message undelivered.

Filter out recipients whose unregistered timestamp is set before
sending, returning an unregisteredFailure result for each (so callers
and CLI output are unchanged) without the network attempt. The flag is
maintained independently by profile/CDS discovery and is cleared when a
recipient registers again, so skipped recipients are re-included
automatically.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:56:22 +02:00
AsamK
debb8a20e6 Send HangupMessage with deviceId 0 like Signal-Android
Closes #2068
2026-06-11 20:12:34 +02:00
AsamK
6bef205b3f Bump version 0.14.5 2026-06-11 12:53:41 +02:00
AsamK
443b4da8f8 Update graalvm build tools 2026-06-11 12:34:09 +02:00
AsamK
afe8c24665 Update user agent 2026-06-11 12:00:14 +02:00
AsamK
9f0676d563 Disable host validation when binding on 0.0.0.0
Fixes #2056
2026-06-11 11:43:49 +02:00
AsamK
b3c1b6a4f6 Update svr2 mrenclave 2026-06-10 23:05:39 +02:00
AsamK
bf1376d74d Update libsignal-service 2026-06-10 23:03:11 +02:00
AsamK
de678596c6 Update test script 2026-05-25 17:39:53 +02:00
AsamK
29e65a5c8e Add missing graalvm metadata
Fixes #2050
2026-05-25 11:51:08 +02:00
AsamK
60779c91c6 Add missing graalvm metadata
Fixes #2051
2026-05-25 11:40:46 +02:00
AsamK
bda4e7fc0f Prepare next release 2026-05-24 01:20:23 +02:00
AsamK
12ffc34967 Bump version to 0.14.4.1 2026-05-24 00:51:21 +02:00
AsamK
9e3585dcce Fix cli default values 2026-05-24 00:51:12 +02:00
AsamK
46c61c5aac Bump version to 0.14.4 2026-05-23 23:01:29 +02:00
AsamK
8d6264e02e Update dependencies 2026-05-23 22:56:09 +02:00
AsamK
f057c5031c Fix use of deprecated API 2026-05-23 22:55:54 +02:00
AsamK
40b1928844 Fix removal of local only unregistered accounts in storage sync 2026-05-23 22:39:59 +02:00
AsamK
2a827f1285 Don't log empty alerts 2026-05-23 21:34:33 +02:00
AsamK
ced9560040 Update libsignal-service 2026-05-23 20:54:42 +02:00
AsamK
44d54b3215 Add support for a global configuration file 2026-05-23 17:56:35 +02:00
AsamK
393e1efcd1 Create temp file with limited permissions 2026-05-23 14:30:33 +02:00
AsamK
f34b552054 Validate host header for http daemon 2026-05-23 14:21:25 +02:00
AsamK
46ce552589 Normalize attachment ids 2026-05-23 14:17:28 +02:00
AsamK
6da5c37504 Prevent attaching files from the signal-cli data directory 2026-05-23 13:50:39 +02:00
AsamK
4601e60118 Adapt containerfile to older apt versions 2026-05-16 11:14:17 +02:00
AsamK
fcf82b9318 Adapt containerfile to older apt versions 2026-05-16 10:48:12 +02:00
AsamK
9c8137fafa Update dependencies 2026-05-15 17:06:21 +02:00
AsamK
0a1531dcce Improve destination/source checks for incoming messages 2026-05-13 18:29:26 +02:00
AsamK
c10f618a3e Update gradle 2026-05-13 18:26:12 +02:00
AsamK
4a3d9d90a6 Update libsignal-service 2026-05-13 18:25:35 +02:00
AsamK
b4275414e1 Pass correct serviceId to SignalServiceCipher
Fixes #2036
2026-05-13 17:39:44 +02:00
Connor Lanigan
5f94b7b6d1
fix: Attempted immutable list modification causes runtime exception (#2038) 2026-05-05 10:15:21 +02:00
legacycode
dc43e44020
fix: flush SSE response headers immediately on connect (#2034)
Without an initial flush(), the JVM HttpServer buffers all
output until the first flush() in the 15-second keep-alive loop.
Clients with shorter timeouts (e.g. 10 s) abort before receiving
any data.

Add a flush() call directly after creating ServerSentEventSender,
before the wait loop, so the HTTP 200 response and headers reach the
client immediately upon connection.

Adds regression test SseInitialFlushTest that verifies at least one
byte arrives within 2 seconds of connecting to GET /api/v1/events.
2026-04-29 22:52:34 +02:00
AsamK
251bd2d87a Refactor profile key extraction 2026-04-27 16:36:22 +02:00
AsamK
a3fcda7598 Update kotlin jvm version for buildSrc 2026-04-27 16:27:15 +02:00
Patrick Dattilio
c9e2504349
Store profile keys from group requesting members (#2031)
When filling or updating a V2 group, profile keys were copied from
DecryptedGroup.members into the local profile store but not from
requestingMembers. Admins who never had a prior session with a user in
the join queue then lacked profile keys and could not decrypt profiles
(e.g. for listContacts).

Also process DecryptedRequestingMember entries the same way as full
members, using DecryptedMember / DecryptedRequestingMember types so the
lib module does not require a direct protobuf dependency.

Made-with: Cursor
2026-04-27 16:25:47 +02:00
dependabot[bot]
9b09df5f17
Bump rustls-webpki from 0.103.12 to 0.103.13 in /client (#2030)
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.12 to 0.103.13.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.12...v/0.103.13)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 19:55:35 +02:00
AsamK
5fe94ff44a Temporarily disable 26 build due to container issue 2026-04-23 21:58:10 +02:00
AsamK
6286a054eb Update libsignal-service 2026-04-23 20:37:46 +02:00
AsamK
e6635d1bb0 Prepare next release 2026-04-23 20:37:46 +02:00
AsamK
056878fad7 Bump version to 0.14.3 2026-04-22 23:16:58 +02:00
AsamK
da214817be Reduce number of installed packages 2026-04-22 22:43:13 +02:00
AsamK
d6edaf3be2 Update reproducible build container images 2026-04-22 22:42:49 +02:00
AsamK
47e50988b5 Make SOURCE_DATE_EPOCH configurable 2026-04-22 22:24:26 +02:00
AsamK
aa446619f2 Add script to update pinned container versions 2026-04-22 22:08:10 +02:00
AsamK
6405655127 Only build native/client containers if required 2026-04-22 21:43:26 +02:00
AsamK
b8d990b0f9 Adapt workflows to use previous naming scheme 2026-04-22 21:36:08 +02:00
AsamK
417d2ce971 Keep websocket connection alive during call 2026-04-16 21:04:20 +02:00
AsamK
33b2b563b3 Don't send busy call response to allow linked devices to accept call 2026-04-16 20:54:19 +02:00
AsamK
740cd6f89b Update dependencies 2026-04-16 20:53:58 +02:00
AsamK
7887ed408d Update libsignal-service 2026-04-16 20:47:39 +02:00
tonycpsu
ddfad2c4ce
Add distinct JSON-RPC error code for captcha rejection (#2021)
* Add distinct JSON-RPC error code for captcha rejection

Previously submitRateLimitChallenge mapped CaptchaRejectedException to
the generic USER_ERROR code (-1), making it indistinguishable from any
other user error (bad params, unknown command, etc.).

Introduce CaptchaRejectedErrorException and wire it to a new error code
(-6 / CAPTCHA_REJECTED_ERROR) throughout the JSON-RPC layer. Callers can
now reliably distinguish a rejected captcha token (user must obtain a
fresh token) from a network failure (transient, worth retrying) or a
generic argument error.

The CLI exit code for this path becomes 6, consistent with the existing
per-error-type exit code convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add exit code 6 to man page

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:00:39 +02:00
AsamK
7e95ea7403 Log identifier of failed profile download
Fixes #2003
2026-04-15 21:18:26 +02:00
BarbossHack
2991cdafe7
Add reproducible builds (#1954) 2026-04-15 21:10:42 +02:00
AsamK
561dfc373f Refactor retry after handling 2026-04-15 21:01:29 +02:00
Gara Dorta
5bfb044245
JSON Schema for JSON-RPC (#1952)
* Add OpenAPIDocs

* Remove the json prefix from the names

* Format file

* Rename models to schemas

* Add required = true to all the required attributes

* Add missing required = true schemas

* Deprecated fields are not required

* switch to micronout json generation

* Fix generator for JsonUnwrapped files

* Fix layout of manual schemas

* Pretty print the json files

* Remove @JsonProperty(required = true)

* Make references local

* Updated the readme

* Removed uneeded import

* Remove extra empty lines

* Clean readme

* Add docs depedency only when needed

* Revert uneeded changes

* Revert more changes

* Better formatting

* Simplified name

* fix: remove jsonunwrapped workaround by upgrading to micronaut-json-schema version 2.0.0-M6

* Simplified jsonSchemas task definition

* Updated readme with the new schemas path

* typo fixing

* Remove empty space from merge
2026-04-15 20:57:36 +02:00
tonycpsu
e1b17bf863
Surface server Retry-After for rate-limit send failures (#2016)
* Surface retry-after seconds for plain rate-limit failures

libsignal-service's RateLimitException exposes retryAfterMilliseconds
for HTTP 413 responses, but signal-cli only forwarded retry-after for
ProofRequired (428) failures. Clients had no signal for when it was
safe to retry plain rate-limited sends, so every failed retry
potentially extended the server-side window.

SendMessageResult now carries an optional rateLimitRetryAfterSeconds,
populated from the upstream Optional<Long>. JsonSendMessageResult
exposes it for RATE_LIMIT_FAILURE type. Text output includes the
window when known. Aggregate RateLimitErrorException now carries the
real nextAttemptTimestamp (was hardcoded to 0).

Closes #1996.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address review: include proof-required retry-after and ceiling-round millis

Codex adversarial review flagged two issues in the phase 1 retry-after
plumbing:

* Aggregate retry-after ignored proof-required failures. Because
  isRateLimitFailure is true for proof-required cases but
  rateLimitRetryAfterSeconds was only populated from plain 413s, an
  all-proof-required batch (or a mixed batch where the proof-required
  delay was longer) could flow into outputResult() and produce a
  RateLimitException(0), telling callers to retry immediately.

* Millisecond Retry-After values were truncated by integer division,
  so 1..999ms became 0 and non-second-aligned values lost up to 999ms.
  A retry suggested from the floored value can land before the
  server's real deadline and re-trigger the limit.

SendMessageResult.from(...) now populates rateLimitRetryAfterSeconds
from either the proof-required seconds or the plain rate-limit ms
(converted via ceiling division), giving maxRateLimitRetryAfterSeconds
a single source of truth. JsonSendMessageResult.from(...) reads the
unified field. New millisToCeilingSeconds helper plus boundary test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Preserve source compat and document retry-after field change

Add a non-canonical 8-arg SendMessageResult constructor that delegates
to the canonical form with null retry-after. This keeps source
compatibility for any downstream code that constructs the record
directly (tests, mocks) without changing the canonical shape. Records
permit additional constructors alongside the canonical one.

Document the retryAfterSeconds meaning change in the CHANGELOG. The
field was previously populated only for proof-required failures; it
is now populated whenever the server sends a Retry-After header. The
canonical proof-required discriminator is still token != null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:17:54 +02:00
AsamK
aafb40fd94 Increase connection disconnect duration to match android app
Related #2018
2026-04-15 00:17:44 +02:00
Stefan Meinecke
7dc55eba81
Fix sender key re-distribution on every group message (#2019)
* Fix sender key re-distribution on every group message in daemon mode

sendGroupMessageInternalWithSenderKey() calls sender.send() which handles
distribution and delivery, but never calls markSenderKeySharedWith() on
success. SenderKeySharedStore therefore has no record that the distribution
was sent, causing it to re-distribute to all recipients on every subsequent
sendGroupMessage call.

This results in a fresh unidentified TLS connection being opened for each
group message (~6s delay per send), even for back-to-back sends to the
same group. All send modes are affected: DBus daemon, JSON-RPC socket/http,
and CLI send command all share the same code path.

The fix mirrors the existing pattern in resendMessage() (line 307): after
a successful send, record each successful recipient's address+device in
the sender key shared store.

* Fix sender key re-distribution on every group message

SenderKeySharedStore.markSenderKeysSharedWith() stored the address using
entry.toString() instead of entry.address(). Since SenderKeySharedEntry is
a Java record, toString() returns the full record representation:

  SenderKeySharedEntry[address=<uuid>, deviceId=1]

instead of just the UUID. When signal-service-java later calls
getSenderKeySharedWith() and compares the retrieved addresses against the
current group member UUIDs, the comparison always fails — causing the
distribution message to be re-sent to all recipients on every
sendGroupMessage call.

This results in a fresh unidentified TLS connection being opened for each
group message (~6s delay per send), even for immediate consecutive sends
to the same group. All send modes are affected: DBus daemon, JSON-RPC
socket/http, and the CLI send command all share the same code path.

The fix is a one-character change: entry.address() instead of
entry.toString().
2026-04-15 00:06:19 +02:00
AsamK
a03d17a9e4 Use padded and encrypted attachment size for upload spec
Fixes #2014
2026-04-12 22:34:59 +02:00
AsamK
364f89f1d0 Prepare next release 2026-04-12 22:09:36 +02:00
AsamK
d0ee90dbbc Reformat files 2026-04-11 12:29:16 +02:00
AsamK
398faa50b0 Make address cache synchronized 2026-04-11 12:26:41 +02:00
AsamK
e9eabbeeb5 Add commits in early returns 2026-04-11 12:26:24 +02:00
tonycpsu
132dfb95dc
Fix SQLiteException in resolveRecipient by checking cache before opening connection (#2011) 2026-04-11 12:23:15 +02:00
AsamK
2651823d4d Fix add group member handling for already members 2026-04-11 11:56:50 +02:00
AsamK
4709cfacc7 Update multiple member roles in one change
Fixes #2009
2026-04-11 11:55:59 +02:00
AsamK
9bc4c0ecd8 Update libsignal-service 2026-04-10 18:24:57 +02:00
218 changed files with 8682 additions and 2021 deletions

57
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: build
on:
push:
branches:
- "**"
pull_request:
workflow_call:
permissions: {}
jobs:
build:
strategy:
matrix:
# java="25" is the LTS Java version used in reproducible builds script (default in Containerfile).
# More Java versions can be added to test compatibility, eg. "26".
java: ["25", "26"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build
run: |
if [ "${{ matrix.java }}" != "25" ]; then
export OVERRIDE_JAVA_VERSION="${{ matrix.java }}"
fi
./reproducible-builds/build.sh
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-archive-${{ matrix.java }}
path: dist/*
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v6
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

@ -1,96 +0,0 @@
name: signal-cli CI
on:
push:
branches:
- '**'
pull_request:
workflow_call:
permissions:
contents: write # to fetch code (actions/checkout) and submit dependency graph (gradle/gradle-build-action)
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '25', '26' ]
steps:
- uses: actions/checkout@v6
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: ${{ matrix.java }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
dependency-graph: generate-and-submit
- name: Install asciidoc
run: sudo apt update && sudo apt --no-install-recommends install -y asciidoc-base
- name: Build with Gradle
run: ./gradlew --no-daemon build
- name: Build man page
run: |
cd man
make install
- name: Add man page to archive
run: |
version=$(tar tf build/distributions/signal-cli-*.tar | head -n1 | sed 's|signal-cli-\([^/]*\)/.*|\1|')
echo $version
tar --transform="flags=r;s|man|signal-cli-${version}/man|" -rf build/distributions/signal-cli-${version}.tar man/man{1,5}
- name: Compress archive
run: gzip -n -9 build/distributions/signal-cli-*.tar
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-archive-${{ matrix.java }}
path: build/distributions/signal-cli-*.tar.gz
build-graalvm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: graalvm/setup-graalvm@v1
with:
distribution: 'graalvm'
java-version: '25'
cache: 'gradle'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build with Gradle
run: ./gradlew --no-daemon nativeCompile
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-native
path: build/native/nativeCompile/signal-cli
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v6
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

27
.github/workflows/dependency-graph.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: dependency-graph
on:
push:
branches:
- "**"
permissions: {}
jobs:
submit-dependency-graph:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
dependency-graph: generate-and-submit
- name: Generate dependency graph
run: ./gradlew --no-daemon dependencies

View File

@ -5,182 +5,54 @@ on:
tags:
- v*
permissions:
contents: write # to fetch code (actions/checkout) and create release
permissions: {}
env:
IMAGE_NAME: signal-cli
IMAGE_REGISTRY: ghcr.io/asamk
REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ github.token }}
ARCHIVE_JAVA_VERSION: 25
jobs:
build:
uses: ./.github/workflows/build.yml
ci_wf:
permissions:
contents: write
uses: AsamK/signal-cli/.github/workflows/ci.yml@master
# ${{ github.repository }} not accepted here
lib_to_jar:
needs: ci_wf
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
signal_cli_version: ${{ steps.cli_ver.outputs.version }}
release_id: ${{ steps.create_release.outputs.id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
id: version
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* .
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Extract archive
- name: Create draft release and upload assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tree .
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar -xzf ./"${ARCHIVE_DIR}"/*.tar.gz
mv ./"${ARCHIVE_DIR}"/*.tar.gz signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
rm -rf signal-cli-archive-*/
# - name: Get signal-client jar version
# id: lib_ver
# run: |
# JAR_PREFIX=libsignal-client-
# jar_file=$(find ./signal-cli-*/lib/ -name "$JAR_PREFIX*.jar")
# jar_version=$(echo "$jar_file" | xargs basename | sed "s/$JAR_PREFIX//; s/.jar//")
# echo "$jar_version"
# echo "signal_client_version=${jar_version}" >> $GITHUB_OUTPUT
#
# - name: Download signal-client builds
# env:
# RELEASES_URL: https://github.com/signalapp/libsignal/releases/download/
# FILE_NAMES: signal_jni.dll libsignal_jni.dylib
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# for file_name in $FILE_NAMES; do
# curl -sOL "${RELEASES_URL}/v${SIGNAL_CLIENT_VER}/${file_name}" # note: added v
# done
# tree .
- name: Compress native app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-native/signal-cli
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-native.tar.gz -C signal-cli-native signal-cli
rm -rf signal-cli-native/
- name: Compress client app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-client-ubuntu/signal-cli-client
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-client.tar.gz -C signal-cli-client-ubuntu signal-cli-client
rm -rf signal-cli-client-ubuntu/
# - name: Replace Windows lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# mv signal_jni.dll libsignal_jni.so
# zip -u ./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar ./libsignal_jni.so
# tar -czf signal-cli-${SIGNAL_CLI_VER}-Windows.tar.gz signal-cli-*/
#
# - name: Replace macOS lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# jar_file=./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar
# zip -d $jar_file libsignal_jni.so
# zip $jar_file libsignal_jni.dylib
# tar -czf signal-cli-${SIGNAL_CLI_VER}-macOS.tar.gz signal-cli-*/
- name: Create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.cli_ver.outputs.version }} # note: added `v`
release_name: v${{ steps.cli_ver.outputs.version }} # note: added `v`
draft: true
- name: Upload archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload Linux archive
# uses: actions/upload-release-asset@v1
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# upload_url: ${{ steps.create_release.outputs.upload_url }}
# asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux native archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux client archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload windows archive
# uses: actions/upload-release-asset@v1
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# upload_url: ${{ steps.create_release.outputs.upload_url }}
# asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Windows.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Windows.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
#
# - name: Upload macos archive
# uses: actions/upload-release-asset@v1
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# upload_url: ${{ steps.create_release.outputs.upload_url }}
# asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-macOS.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-macOS.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
gh release create "v${{ steps.version.outputs.version }}" \
--repo "${{ github.repository }}" \
--title "v${{ steps.version.outputs.version }}" \
--draft \
--verify-tag \
"signal-cli-${{ steps.version.outputs.version }}.tar.gz" \
"signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz" \
"signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz" \
"signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz"
build-container:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -188,32 +60,23 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar xf ./"${ARCHIVE_DIR}"/*.tar.gz
rm -r signal-cli-archive-* signal-cli-native
tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}.tar.gz
mkdir -p build/install/
mv ./signal-cli-"${GITHUB_REF_NAME#v}"/ build/install/signal-cli
mv ./signal-cli-"${{ needs.release.outputs.version }}"/ build/install/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
uses: redhat-actions/buildah-build@v3
with:
image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ steps.cli_ver.outputs.version }}
containerfiles:
./Containerfile
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
containerfiles: ./Containerfile
oci: true
- name: Push To GHCR
uses: redhat-actions/push-to-registry@v2
uses: redhat-actions/push-to-registry@v3
id: push
with:
image: ${{ steps.build_image.outputs.image }}
@ -227,10 +90,9 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}"
build-container-native:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -238,30 +100,24 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}-Linux-native.tar.gz
mkdir -p build/native/nativeCompile/
chmod +x ./signal-cli-native/signal-cli
mv ./signal-cli-native/signal-cli build/native/nativeCompile/
mv signal-cli build/native/nativeCompile/
chmod +x build/native/nativeCompile/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
uses: redhat-actions/buildah-build@v3
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ steps.cli_ver.outputs.version }}-native
containerfiles:
./native.Containerfile
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
containerfiles: ./native.Containerfile
oci: true
- name: Push To GHCR
uses: redhat-actions/push-to-registry@v2
uses: redhat-actions/push-to-registry@v3
id: push
with:
image: ${{ steps.build_image.outputs.image }}
@ -275,10 +131,9 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}"
build-container-client:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -286,30 +141,24 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}-Linux-client.tar.gz
mkdir -p client/target/release/
chmod +x ./signal-cli-client-ubuntu/signal-cli-client
mv ./signal-cli-client-ubuntu/signal-cli-client client/target/release/
mv signal-cli-client client/target/release/
chmod +x client/target/release/signal-cli-client
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
uses: redhat-actions/buildah-build@v3
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ steps.cli_ver.outputs.version }}-client
containerfiles:
./client.Containerfile
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
containerfiles: ./client.Containerfile
oci: true
- name: Push To GHCR
uses: redhat-actions/push-to-registry@v2
uses: redhat-actions/push-to-registry@v3
id: push
with:
image: ${{ steps.build_image.outputs.image }}

8
.gitignore vendored
View File

@ -1,4 +1,5 @@
.gradle/
.kotlin/
.idea/*
!.idea/codeStyles/
build/
@ -13,3 +14,10 @@ out/
.DS_Store
/bin/
/test-config/
/dist/
/github/
man/*.1
man/*.5
man/man1
man/man5
.superpowers/

View File

@ -1,5 +1,104 @@
# Changelog
## [Unreleased]
### Added
- `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded
## [0.14.8] - 2026-09-10
### Added
- Add terminateGroup command to terminate a group for everyone
- Include isVoiceNote in receive JSON and JSON-RPC attachment payloads
### Improved
- Prevent more storage sync loops
### Fixed
- Fix graalvm issue with image dimension probing
- Fix issue with sticker storage sync
## [0.14.7] - 2026-08-01
### Added
- Sync installed sticker packs via storage sync
### Improved
- Set width/height for outgoing image attachments (Thanks @mikesimone)
- Update to new registration/provisioning API
### Fixed
- Removing self from pending group invite now works if invited via PNI
- Correctly set needs PNI signature for recipients
- Fix linking after previous unsuccessful link attempt
## [0.14.6] - 2026-07-12
### Added
- New `sendStory` command to post file attachment stories to "My Story" or to a group via `--group-id`
### Improved
- The account parameter `-a` now supports ACI in addition to phone number
- Disabling read receipts in configuration now prevents sending read receipts (only sync message to linked devices is still sent)
### Fixed
- Sending to large groups is no longer slowed down by members that are already known to be unregistered; they are skipped instead of being retried via the legacy 1:1 send path on every send.
## [0.14.5] - 2026-06-11
### Changed
- Disable host validation when binding on 0.0.0.0
- Use new SVR2 enclave for PINs
### Fixed
- Receiving unidentified sender messages after signal server change
## [0.14.4] - 2026-05-23
### Added
- Support for a global configuration file to set system-wide defaults
### Fixed
- Group admins can now see profile information for users requesting to join groups.
- Storage sync with unregistered contacts fixed
- Incoming messages are validated more accurately, fixing receiving messages from new contacts
### Improved
- Some security and stability improvements, including HTTP HOST header validation and safer temporary file handling.
## [0.14.3] - 2026-04-22
### Fixed
- Fix sender key re-distribution on every group message (Thanks @meinecke)
### Improved
- Performance improvement when assigning admin role to multiple group members
- Increase disconnect timeout for websocket connections
- Release builds are now reproducible
### Changed
- Send message results now surface server-advised retry time for plain rate-limit (HTTP 413) failures, not only for proof-required challenges. The `retryAfterSeconds` field in JSON-RPC `SendMessageResult` is populated whenever the server sends a `Retry-After` header. The canonical way to distinguish proof-required failures remains `token != null`. Text output includes "retry after N seconds" when known.
- Add distinct JSON-RPC error code (6) for captcha rejection (Thanks @tonycpsu)
- No longer sends busy call response to allow linked devices to accept call
## [0.14.2] - 2026-04-04
### Added

View File

@ -3,8 +3,9 @@
signal-cli is a commandline interface for the [Signal messenger](https://signal.org/).
It supports registering, verifying, sending and receiving messages.
signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java),
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/libsignal-service).
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service).
For registering you need a phone number where you can receive SMS or incoming calls.
Existing accounts without a phone number can be linked from a compatible Signal mobile app.
signal-cli is primarily intended to be used on servers to notify admins of important events.
For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc))
@ -60,12 +61,26 @@ For a complete usage overview please read
the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and
the [wiki](https://github.com/AsamK/signal-cli/wiki).
Important: The ACCOUNT is your phone number in international format and must include the country calling code. Hence it
For a numbered account, ACCOUNT is your phone number in international format and must include the country calling code. Hence it
should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list
of all country codes.)
For a linked account without a phone number, use its ACI (Account ID) instead.
See the [wiki](https://github.com/AsamK/signal-cli/wiki) for further documentation.
* Link to an existing account
If you have an existing Signal account, with or without a phone number,
you can link signal-cli to it with:
signal-cli link
* Register a number (with SMS verification)
Alternatively, if you don't have an existing Signal
account, you can register one from signal-cli. Note
that this will unregister any existing client
associated with the same number.
signal-cli -a ACCOUNT register
You can register Signal using a landline number. In this case, you need to follow the procedure below:
@ -148,6 +163,16 @@ version installed, you can replace `./gradlew` with `gradle` in the following st
./gradlew run --args="--help"
```
### JSON Schemas for the JSON-RPC mode
1. Generate [JSON Schema](https://json-schema.org/) files for all the JSON-RPC data classes (`src/main/java/org/asamk/signal/json`):
```sh
./gradlew jsonSchemas
```
2. The generated files can be found in the `build/generated/META-INF/schemas` folder.
### Building a native binary with GraalVM (EXPERIMENTAL)
It is possible to build a native binary with [GraalVM](https://www.graalvm.org). This is still experimental and will not

View File

@ -1,14 +1,16 @@
import groovy.json.JsonOutput
plugins {
java
application
eclipse
`check-lib-versions`
id("org.graalvm.buildtools.native") version "1.0.0"
id("org.graalvm.buildtools.native") version "1.1.12"
}
allprojects {
group = "org.asamk"
version = "0.14.2"
version = "0.14.9-SNAPSHOT"
}
java {
@ -72,6 +74,11 @@ val excludePatterns = mapOf(
)
)
val schemaAnnotationProcessor = configurations.create("schemaAnnotationProcessor") {
isCanBeConsumed = false
isCanBeResolved = true
}
dependencies {
registerTransform(JarFileExcluder::class) {
from.attribute(minified, false).attribute(artifactType, "jar")
@ -82,6 +89,8 @@ dependencies {
}
}
schemaAnnotationProcessor(libs.micronaut.json.schema.processor)
schemaAnnotationProcessor(libs.micronaut.inject.java)
implementation(libs.bouncycastle)
implementation(libs.jackson.databind)
implementation(libs.argparse4j)
@ -90,6 +99,10 @@ dependencies {
implementation(libs.slf4j.jul)
implementation(libs.logback)
implementation(libs.zxing)
implementation(libs.micronaut.json.schema.annotations)
if (gradle.startParameter.taskNames.any { it.contains("jsonSchemas") }) {
implementation(libs.micronaut.json.schema.generator)
}
implementation(project(":libsignal-cli"))
testImplementation(libs.junit.jupiter)
@ -160,3 +173,30 @@ tasks.register("writeLibsignalVersion") {
}
}
}
tasks.register<JavaCompile>("jsonSchemas") {
dependsOn(tasks.compileJava)
val schemaBaseUri = "http://localhost:8080/schemas/"
source = sourceSets.main.get().java
include("org/asamk/signal/json/**/*.java")
classpath = sourceSets.main.get().compileClasspath + files(sourceSets.main.get().java.destinationDirectory)
destinationDirectory.set(layout.buildDirectory.dir("generated"))
options.annotationProcessorPath = schemaAnnotationProcessor
options.compilerArgs.addAll(
listOf(
"-Amicronaut.processing.group=org.asamk",
"-Amicronaut.processing.module=signal-cli",
"-Amicronaut.processing.annotations=org.asamk.signal.json.*",
"-Amicronaut.jsonschema.baseUri=$schemaBaseUri",
)
)
doLast {
fileTree(destinationDirectory.get().dir("META-INF/schemas").asFile) {
include("*.schema.json")
}.forEach { schemaFile ->
val normalized = schemaFile.readText().replace("\"$schemaBaseUri/", "\"")
val prettyJson = JsonOutput.prettyPrint(normalized)
schemaFile.writeText("$prettyJson\n")
}
}
}

View File

@ -7,11 +7,11 @@ plugins {
}
tasks.named<KotlinCompilationTask<KotlinJvmCompilerOptions>>("compileKotlin").configure {
compilerOptions.jvmTarget.set(JvmTarget.JVM_24)
compilerOptions.jvmTarget.set(JvmTarget.JVM_25)
}
java {
targetCompatibility = JavaVersion.VERSION_24
targetCompatibility = JavaVersion.VERSION_25
}
repositories {

534
client/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "signal-cli-client"
version = "0.0.1"
version = "0.14.8"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -45,6 +45,24 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<releases>
<release version="0.14.8" date="2026-09-10">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.8</url>
</release>
<release version="0.14.7" date="2026-08-01">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.7</url>
</release>
<release version="0.14.6" date="2026-07-12">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.6</url>
</release>
<release version="0.14.5" date="2026-06-11">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.5</url>
</release>
<release version="0.14.4" date="2026-05-23">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.4</url>
</release>
<release version="0.14.3" date="2026-04-22">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.3</url>
</release>
<release version="0.14.2" date="2026-04-04">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.2</url>
</release>

View File

@ -1,20 +1,29 @@
[versions]
slf4j = "2.0.17"
junit = "6.0.2"
slf4j = "2.0.19"
coroutines = "1.11.0"
junit = "6.1.3"
micronaut-json-schema = "2.2.0"
micronaut-core = "5.2.0"
signal-service = "2.15.3_unofficial_153"
[libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.83"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.20.2"
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.85.2"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.2"
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4"
micronaut-json-schema-annotations = { module = "io.micronaut.jsonschema:micronaut-json-schema-annotations", version.ref = "micronaut-json-schema" }
micronaut-json-schema-processor = { module = "io.micronaut.jsonschema:micronaut-json-schema-processor", version.ref = "micronaut-json-schema" }
micronaut-json-schema-generator = { module = "io.micronaut.jsonschema:micronaut-json-schema-generator", version.ref = "micronaut-json-schema" }
micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.5.32"
logback = "ch.qos.logback:logback-classic:1.6.3"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_142"
sqlite = "org.xerial:sqlite-jdbc:3.51.2.0"
hikari = "com.zaxxer:HikariCP:7.0.2"
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
sqlite = "org.xerial:sqlite-jdbc:3.53.4.0"
hikari = "com.zaxxer:HikariCP:7.1.0"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" }

Binary file not shown.

View File

@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

6
gradlew vendored
View File

@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.

35
gradlew.bat vendored
View File

@ -19,12 +19,12 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@ -65,29 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View File

@ -18,9 +18,9 @@ val libsignalClientPath = project.findProperty("libsignal_client_path")?.toStrin
dependencies {
if (libsignalClientPath == null) {
implementation(libs.signalservice)
implementation(libs.signalnetwork)
} else {
implementation(libs.signalservice) {
implementation(libs.signalnetwork) {
exclude(group = "org.signal", module = "libsignal-client")
}
implementation(files(libsignalClientPath))
@ -30,6 +30,7 @@ dependencies {
implementation(libs.slf4j.api)
implementation(libs.sqlite)
implementation(libs.hikari)
compileOnly(libs.kotlinx.coroutines.core)
testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.jupiter.bom))

View File

@ -54,6 +54,7 @@ import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.api.UsernameStatus;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -74,6 +75,10 @@ public interface Manager extends Closeable {
return PhoneNumberUtil.getInstance().isPossibleNumber(e164Number, countryCode);
}
static boolean isValidAci(final String aci) {
return UuidUtil.INSTANCE.isUuid(aci);
}
static boolean isSignalClientAvailable() {
final Logger logger = LoggerFactory.getLogger(Manager.class);
try {
@ -91,6 +96,16 @@ public interface Manager extends Closeable {
String getSelfNumber();
String getSelfACI();
/**
* Returns the phone number for numbered accounts, or the ACI for numberless accounts.
*/
default String getSelfIdentifier() {
final var number = getSelfNumber();
return number != null ? number : getSelfACI();
}
/**
* This is used for checking a set of phone numbers for registration on Signal
*
@ -177,6 +192,10 @@ public interface Manager extends Closeable {
void deleteGroup(GroupId groupId) throws IOException;
SendGroupMessageResults terminateGroup(
GroupId groupId
) throws IOException, GroupNotFoundException, NotAGroupMemberException;
Pair<GroupId, SendGroupMessageResults> createGroup(
String name,
Set<RecipientIdentifier.Single> members,
@ -213,6 +232,19 @@ public interface Manager extends Closeable {
long editTargetTimestamp
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException;
/**
* Post a file attachment story to "My Story" or to a group.
*
* @param attachment path to the file to upload and post as a story
* @param allowsReplies whether other users are allowed to reply to this story
* @param groupId if present, post the story to this group instead of "My Story"
*/
SendMessageResults sendStory(
String attachment,
boolean allowsReplies,
Optional<GroupId> groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException;
SendMessageResults sendRemoteDeleteMessage(
long targetSentTimestamp,
Set<RecipientIdentifier> recipients
@ -259,7 +291,7 @@ public interface Manager extends Closeable {
RecipientIdentifier.Single recipient
) throws IOException;
SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException;
void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException;
SendMessageResults sendMessageRequestResponse(
MessageEnvelope.Sync.MessageRequestResponse.Type type,

View File

@ -8,8 +8,6 @@ import java.util.function.Consumer;
public interface MultiAccountManager extends AutoCloseable {
List<String> getAccountNumbers();
List<Manager> getManagers();
void addOnManagerAddedHandler(Consumer<Manager> handler);

View File

@ -10,5 +10,8 @@ public interface ProvisioningManager {
URI getDeviceLinkUri() throws TimeoutException, IOException;
/**
* Completes linking and returns the account's phone number, or ACI if it has no number.
*/
String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException;
}

View File

@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.TotpRequiredException;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import java.io.Closeable;
@ -17,13 +18,19 @@ public interface RegistrationManager extends Closeable {
boolean voiceVerification,
String captcha,
final boolean forceRegister
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException;
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException;
void verifyAccount(
String verificationCode,
String pin
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
void registerWithRecoveryKey(
String recoveryKey,
boolean forceRegister,
Integer totp
) throws IOException, RateLimitException, TotpRequiredException;
void deleteLocalAccountData() throws IOException;
boolean isRegistered();

View File

@ -15,6 +15,7 @@ import org.asamk.signal.manager.internal.RegistrationManagerImpl;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.accounts.AccountsStore;
import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
@ -24,6 +25,7 @@ import java.io.IOException;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
public class SignalAccountFiles {
@ -64,15 +66,26 @@ public class SignalAccountFiles {
return accountsStore.getAllNumbers();
}
public Set<String> getAllLocalAccountIdentifiers() throws IOException {
return accountsStore.getAllAccounts()
.stream()
.map(a -> a.number() != null ? a.number() : a.uuid())
.collect(Collectors.toSet());
}
public MultiAccountManager initMultiAccountManager() throws IOException {
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
final var identifier = a.number() != null ? a.number() : a.uuid();
try {
return new Pair<Manager, Throwable>(initManager(a.number(), a.path()), null);
final var manager = a.number() != null
? initManagerByNumber(a.number(), a.path())
: initManagerByAci(ACI.parseOrThrow(a.uuid()), a.path());
return new Pair<Manager, Throwable>(manager, null);
} catch (NotRegisteredException e) {
logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
logger.warn("Ignoring {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName());
return null;
} catch (AccountCheckException | IOException e) {
logger.error("Failed to load {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
logger.error("Failed to load {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName());
return new Pair<Manager, Throwable>(null, e);
}
}).filter(Objects::nonNull).toList();
@ -90,15 +103,31 @@ public class SignalAccountFiles {
return new MultiAccountManagerImpl(managers, this);
}
public Manager initManager(String number) throws IOException, NotRegisteredException, AccountCheckException {
public Manager initManagerByNumber(String number) throws IOException, NotRegisteredException, AccountCheckException {
final var accountPath = accountsStore.getPathByNumber(number);
return this.initManager(number, accountPath);
return this.initManagerByNumber(number, accountPath);
}
private Manager initManager(
public Manager initManagerByAci(String aciStr) throws IOException, NotRegisteredException, AccountCheckException {
final var aci = ACI.parseOrThrow(aciStr);
final var accountPath = accountsStore.getPathByAci(aci);
return this.initManagerByAci(aci, accountPath);
}
private Manager initManagerByNumber(
String number,
String accountPath
) throws IOException, NotRegisteredException, AccountCheckException {
final var account = loadAccount(accountPath);
if (!number.equals(account.getNumber())) {
account.close();
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
}
return initManagerFromAccount(number, accountPath, account);
}
private SignalAccount loadAccount(final String accountPath) throws NotRegisteredException, IOException {
if (accountPath == null) {
throw new NotRegisteredException();
}
@ -106,12 +135,27 @@ public class SignalAccountFiles {
throw new NotRegisteredException();
}
var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
if (!number.equals(account.getNumber())) {
return SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
}
private Manager initManagerByAci(
ACI aci,
String accountPath
) throws IOException, NotRegisteredException, AccountCheckException {
final var account = loadAccount(accountPath);
if (!aci.equals(account.getAci())) {
account.close();
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
}
return initManagerFromAccount(aci.toString(), accountPath, account);
}
private ManagerImpl initManagerFromAccount(
final String identifier,
final String accountPath,
final SignalAccount account
) throws NotRegisteredException, IOException, AccountCheckException {
if (!account.isRegistered()) {
account.close();
throw new NotRegisteredException();
@ -136,7 +180,7 @@ public class SignalAccountFiles {
throw new IOException("signal-cli version is too old for the Signal-Server, please update.");
} catch (IOException e) {
manager.close();
throw new AccountCheckException("Error while checking account " + number + ": " + e.getMessage(), e);
throw new AccountCheckException("Error while checking account " + identifier + ": " + e.getMessage(), e);
}
if (account.getServiceEnvironment() == null) {
@ -167,6 +211,11 @@ public class SignalAccountFiles {
String number,
Consumer<Manager> newManagerListener
) throws IOException {
final var aci = ACI.parseOrNull(number);
if (aci != null) {
return initRegistrationManager(aci, newManagerListener);
}
final var accountPath = accountsStore.getPathByNumber(number);
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
@ -206,4 +255,43 @@ public class SignalAccountFiles {
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, accountPath));
}
private RegistrationManager initRegistrationManager(
final ACI aci,
final Consumer<Manager> newManagerListener
) throws IOException {
final var accountPath = accountsStore.getPathByAci(aci);
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
final var newAccountPath = accountPath == null ? accountsStore.addAccount(null, aci) : accountPath;
final var account = SignalAccount.create(pathConfig.dataPath(),
newAccountPath,
null,
aci,
serviceEnvironment,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
settings);
account.initDatabase();
return new RegistrationManagerImpl(account,
pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, newAccountPath));
}
final var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
if (!aci.equals(account.getAci())) {
account.close();
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
}
account.initDatabase();
return new RegistrationManagerImpl(account,
pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, accountPath));
}
}

View File

@ -0,0 +1,19 @@
package org.asamk.signal.manager.api;
import org.signal.libsignal.net.BadRequestError;
import java.io.IOException;
public class BadRequestException extends IOException {
private final BadRequestError error;
public BadRequestException(final BadRequestError error) {
super(error.toString());
this.error = error;
}
public BadRequestError getError() {
return error;
}
}

View File

@ -1,9 +1,7 @@
package org.asamk.signal.manager.api;
public record CallOffer(
long callId,
Type type,
byte[] opaque
long callId, Type type, byte[] opaque
) {
public enum Type {

View File

@ -2,11 +2,11 @@ package org.asamk.signal.manager.api;
public class CaptchaRequiredException extends Exception {
private long nextAttemptTimestamp;
private long nextVerificationAttemptMilliseconds;
public CaptchaRequiredException(final long nextAttemptTimestamp) {
public CaptchaRequiredException(final long nextVerificationAttemptMilliseconds) {
super("Captcha required");
this.nextAttemptTimestamp = nextAttemptTimestamp;
this.nextVerificationAttemptMilliseconds = nextVerificationAttemptMilliseconds;
}
public CaptchaRequiredException(final String message) {
@ -17,7 +17,7 @@ public class CaptchaRequiredException extends Exception {
super(message, cause);
}
public long getNextAttemptTimestamp() {
return nextAttemptTimestamp;
public long getNextVerificationAttemptMilliseconds() {
return nextVerificationAttemptMilliseconds;
}
}

View File

@ -15,6 +15,7 @@ public record Contact(
long muteUntil,
boolean hideStory,
boolean isBlocked,
long blockedAt,
boolean isArchived,
boolean isProfileSharingEnabled,
boolean isHidden,
@ -34,6 +35,7 @@ public record Contact(
builder.muteUntil,
builder.hideStory,
builder.isBlocked,
builder.blockedAt,
builder.isArchived,
builder.isProfileSharingEnabled,
builder.isHidden,
@ -58,6 +60,7 @@ public record Contact(
builder.muteUntil = copy.muteUntil();
builder.hideStory = copy.hideStory();
builder.isBlocked = copy.isBlocked();
builder.blockedAt = copy.blockedAt();
builder.isArchived = copy.isArchived();
builder.isProfileSharingEnabled = copy.isProfileSharingEnabled();
builder.isHidden = copy.isHidden();
@ -80,6 +83,21 @@ public record Contact(
return givenName + " " + familyName;
}
public String getDisplayNickname() {
final var noNickGivenName = Util.isEmpty(nickNameGivenName);
final var noNickFamilyName = Util.isEmpty(nickNameFamilyName);
if (noNickGivenName && noNickFamilyName) {
return null;
} else if (noNickGivenName) {
return nickNameFamilyName;
} else if (noNickFamilyName) {
return nickNameGivenName;
}
return nickNameGivenName + " " + nickNameFamilyName;
}
public static final class Builder {
private String givenName;
@ -94,6 +112,7 @@ public record Contact(
private long muteUntil;
private boolean hideStory;
private boolean isBlocked;
private long blockedAt;
private boolean isArchived;
private boolean isProfileSharingEnabled;
private boolean isHidden;
@ -162,10 +181,20 @@ public record Contact(
}
public Builder withIsBlocked(final boolean val) {
if (val && !isBlocked) {
blockedAt = System.currentTimeMillis();
} else if (!val) {
blockedAt = 0;
}
isBlocked = val;
return this;
}
public Builder withBlockedAt(final long val) {
blockedAt = val;
return this;
}
public Builder withIsArchived(final boolean val) {
isArchived = val;
return this;

View File

@ -22,7 +22,8 @@ public record Group(
GroupPermission permissionEditDetails,
GroupPermission permissionSendMessage,
boolean isMember,
boolean isAdmin
boolean isAdmin,
boolean isTerminated
) {
public static Group from(
@ -59,6 +60,7 @@ public record Group(
groupInfo.getPermissionEditDetails(),
groupInfo.getPermissionSendMessage(),
groupInfo.isMember(selfRecipientId),
groupInfo.isAdmin(selfRecipientId));
groupInfo.isAdmin(selfRecipientId),
groupInfo.isTerminated());
}
}

View File

@ -6,6 +6,8 @@ import java.util.Optional;
public record Message(
String messageText,
List<String> attachments,
List<AttachmentDimensions> attachmentDimensions,
List<String> attachmentBlurHashes,
boolean viewOnce,
boolean voiceNote,
List<Mention> mentions,
@ -17,6 +19,8 @@ public record Message(
boolean urgent
) {
public record AttachmentDimensions(int width, int height) {}
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
public record Quote(

View File

@ -4,7 +4,6 @@ import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.metadata.ProtocolException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@ -157,7 +156,7 @@ public record MessageEnvelope(
dataMessage.getExpiresInSeconds(),
dataMessage.isExpirationUpdate(),
dataMessage.isViewOnce(),
dataMessage.isEndSession(),
false,
dataMessage.isProfileKeyUpdate(),
dataMessage.getProfileKey().isPresent(),
dataMessage.getReaction().map(r -> Reaction.from(r, recipientResolver, addressResolver)),
@ -740,7 +739,10 @@ public record MessageEnvelope(
null,
d.getE164(),
null))
.toList(), blockedListMessage.groupIds.stream().map(GroupId::unknownVersion).toList());
.toList(),
blockedListMessage.groups.stream()
.map(group -> GroupId.unknownVersion(group.getGroupId()))
.toList());
}
}
@ -1028,7 +1030,7 @@ public record MessageEnvelope(
final AttachmentFileProvider fileProvider,
Exception exception
) {
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null);
final var serviceId = envelope.getSourceServiceId();
final var source = !envelope.isUnidentifiedSender() && serviceId != null
? recipientResolver.resolveRecipient(serviceId)
: envelope.isUnidentifiedSender() && content != null

View File

@ -10,12 +10,19 @@ public class ProofRequiredException extends Exception {
private final String token;
private final Set<Option> options;
private final long retryAfterSeconds;
private final long retryAfterMilliseconds;
public ProofRequiredException(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
this.token = e.getToken();
this.options = e.getOptions().stream().map(Option::from).collect(Collectors.toSet());
this.retryAfterSeconds = e.getRetryAfterSeconds();
public ProofRequiredException(final String token, final Set<Option> options, final long retryAfterMilliseconds) {
super("Rate limit");
this.token = token;
this.options = options;
this.retryAfterMilliseconds = retryAfterMilliseconds;
}
public static ProofRequiredException from(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
return new ProofRequiredException(e.getToken(),
e.getOptions().stream().map(Option::from).collect(Collectors.toSet()),
e.getRetryAfterSeconds() * 1000L);
}
public String getToken() {
@ -26,8 +33,8 @@ public class ProofRequiredException extends Exception {
return options;
}
public long getRetryAfterSeconds() {
return retryAfterSeconds;
public long getRetryAfterMilliseconds() {
return retryAfterMilliseconds;
}
public enum Option {

View File

@ -2,14 +2,18 @@ package org.asamk.signal.manager.api;
public class RateLimitException extends Exception {
private final long nextAttemptTimestamp;
private final Long retryAfterMilliseconds;
public RateLimitException(final long nextAttemptTimestamp) {
public RateLimitException(final Long retryAfterMilliseconds) {
super("Rate limit");
this.nextAttemptTimestamp = nextAttemptTimestamp;
this.retryAfterMilliseconds = retryAfterMilliseconds;
}
public long getNextAttemptTimestamp() {
return nextAttemptTimestamp;
public static RateLimitException from(org.whispersystems.signalservice.api.push.exceptions.RateLimitException e) {
return new RateLimitException(e.getRetryAfterMilliseconds().orElse(null));
}
public Long getRetryAfterMilliseconds() {
return retryAfterMilliseconds;
}
}

View File

@ -1,3 +1,9 @@
package org.asamk.signal.manager.api;
public record ReceiveConfig(boolean ignoreAttachments, boolean ignoreStories, boolean ignoreAvatars, boolean ignoreStickers, boolean sendReadReceipts) {}
public record ReceiveConfig(
boolean ignoreAttachments,
boolean ignoreStories,
boolean ignoreAvatars,
boolean ignoreStickers,
boolean sendReadReceipts
) {}

View File

@ -9,13 +9,13 @@ public record SendMessageResult(
boolean isNetworkFailure,
boolean isUnregisteredFailure,
boolean isIdentityFailure,
boolean isRateLimitFailure,
RateLimitException rateLimitException,
ProofRequiredException proofRequiredFailure,
boolean isInvalidPreKeyFailure
) {
public static SendMessageResult unregisteredFailure(RecipientAddress address) {
return new SendMessageResult(address, false, false, true, false, false, null, false);
return new SendMessageResult(address, false, false, true, false, null, null, false);
}
public static SendMessageResult from(
@ -23,16 +23,30 @@ public record SendMessageResult(
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
final var rateLimitFailure = sendMessageResult.getRateLimitFailure();
final var proofRequiredFailure = sendMessageResult.getProofRequiredFailure();
return new SendMessageResult(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
sendMessageResult.getAddress())).toApiRecipientAddress(),
sendMessageResult.isSuccess(),
sendMessageResult.isNetworkFailure(),
sendMessageResult.isUnregisteredFailure(),
sendMessageResult.getIdentityFailure() != null,
sendMessageResult.getRateLimitFailure() != null || sendMessageResult.getProofRequiredFailure() != null,
sendMessageResult.getProofRequiredFailure() == null
? null
: new ProofRequiredException(sendMessageResult.getProofRequiredFailure()),
rateLimitFailure == null ? null : RateLimitException.from(rateLimitFailure),
proofRequiredFailure == null ? null : ProofRequiredException.from(proofRequiredFailure),
sendMessageResult.isInvalidPreKeyFailure());
}
public boolean isRateLimitFailure() {
return this.rateLimitException != null || this.proofRequiredFailure != null;
}
public Long rateLimitRetryAfterMilliseconds() {
if (proofRequiredFailure != null) {
return proofRequiredFailure.getRetryAfterMilliseconds();
} else if (rateLimitException != null) {
return rateLimitException.getRetryAfterMilliseconds();
} else {
return null;
}
}
}

View File

@ -2,6 +2,7 @@ package org.asamk.signal.manager.api;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<SendMessageResult>> results) {
@ -26,4 +27,18 @@ public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<S
.flatMap(res -> res.stream().map(SendMessageResult::isRateLimitFailure))
.allMatch(r -> r) && results.values().stream().mapToInt(List::size).sum() > 0;
}
/**
* Longest rate-limit retry-after window across all rate-limited recipients, in milliseconds.
* Null when no recipient reported one (server omitted Retry-After, or no rate-limit failures).
*/
public Long maxRateLimitRetryAfterMilliseconds() {
return results.values()
.stream()
.flatMap(List::stream)
.map(SendMessageResult::rateLimitRetryAfterMilliseconds)
.filter(Objects::nonNull)
.max(Long::compareTo)
.orElse(null);
}
}

View File

@ -0,0 +1,10 @@
package org.asamk.signal.manager.api;
import java.io.IOException;
public class TotpRequiredException extends IOException {
public TotpRequiredException() {
super("A TOTP token is required or the supplied token is incorrect");
}
}

View File

@ -3,8 +3,5 @@ package org.asamk.signal.manager.api;
import java.util.List;
public record TurnServer(
String username,
String password,
List<String> urls
) {
}
String username, String password, List<String> urls
) {}

View File

@ -3,15 +3,15 @@ package org.asamk.signal.manager.config;
import org.signal.libsignal.net.Network.Environment;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.whispersystems.signalservice.api.push.TrustStore;
import org.whispersystems.signalservice.internal.configuration.HttpProxy;
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl;
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url;
import org.signal.network.config.HttpProxy;
import org.signal.network.config.SignalCdnUrl;
import org.signal.network.config.SignalCdsiUrl;
import org.signal.network.config.SignalProxy;
import org.signal.network.config.SignalServiceConfiguration;
import org.signal.network.config.SignalServiceUrl;
import org.signal.network.config.SignalStorageUrl;
import org.signal.network.config.SignalSvr2Url;
import org.signal.network.config.TrustStore;
import java.util.Base64;
import java.util.List;
@ -30,7 +30,8 @@ class LiveConfig {
private static final byte[] UNIDENTIFIED_SENDER_TRUST_ROOT2 = Base64.getDecoder()
.decode("BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6");
private static final String CDSI_MRENCLAVE = "0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57";
private static final String SVR2_MRENCLAVE = "29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb";
private static final String SVR2_MRENCLAVE = "fdbbacdc0c043d0d53fe1440f62728de0386f45ab0a275bd8f99e03a02af355e";
private static final String SVR2_MRENCLAVE_LEGACY = "ced8217b26228e4b210c985786999d095c4958a94faf37b14acaf25c4cbb02a4";
private static final String URL = "https://chat.signal.org";
private static final String CDN_URL = "https://cdn.signal.org";
@ -48,10 +49,10 @@ class LiveConfig {
private static final byte[] zkGroupServerPublicParams = Base64.getDecoder()
.decode("AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==");
private static final byte[] genericServerPublicParams = Base64.getDecoder()
.decode("AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN");
.decode("AeCO67P9mIv1yUHkdeZ9JF789GDbox61GvTqq3S4kYc1ADUWxWHQygU390tv1oRWt9WjkdZlU7mKkifF59ftjE+2ZlMmxns6I+ySiLpR8FEmfu+TGpVp3zYTjNV93obJJTyBCCsSHVETCyQRbKdCyb5TMa6LGrvcZaX0Q/VAavhuNA/m4kSiRMgSnYrUjGhVekdDnF+7xioo4wvFnxjIDh7uJQrYOWD6MloNGX7St5gbysTuQQ7i/HI38b9V8x8mKazuDSXxB//BKGZx/XHkK8cHX+QK1MPxYUVM1/CBI5oW");
private static final byte[] backupServerPublicParams = Base64.getDecoder()
.decode("AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O");
.decode("AZwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1Y4pyiScu0/S10n647ipo+iq97JZQv+UOlwH8ThyNlGT5DfxXCwTqivxHuXvZpuezPgHk5Gxl5aC6xuNxOnwmFlmu4CeSgdhW8+Pp0vAJOQ1MsU2D0+/kzI+tU94nB3tybY/Ao1AcGW2q41uKQbnOJUWwmQaFT6s+xTISgzsg7CPox6oORGX8rnyk/9lic3DbGsUHctIVpMAl/ogJBb4aYC");
private static final Environment LIBSIGNAL_NET_ENV = Environment.PRODUCTION;
@ -93,7 +94,7 @@ class LiveConfig {
createDefaultServiceConfiguration(interceptors),
getUnidentifiedSenderTrustRoots(),
CDSI_MRENCLAVE,
List.of(SVR2_MRENCLAVE));
List.of(SVR2_MRENCLAVE, SVR2_MRENCLAVE_LEGACY));
}
private LiveConfig() {

View File

@ -28,10 +28,20 @@ public class ServiceConfig {
public static final int MAXIMUM_ONE_OFF_REQUEST_SIZE = 3;
public static final long UNREGISTERED_LIFESPAN = TimeUnit.DAYS.toMillis(30);
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
public static AccountAttributes.Capabilities getCapabilities(
final boolean isPrimaryDevice,
final boolean hasPhoneNumber
) {
final var attachmentBackfill = !isPrimaryDevice;
final var spqr = true;
return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr);
final var usernameSyncChangeMessage = !isPrimaryDevice;
final var optionalPhoneNumber = !isPrimaryDevice || !hasPhoneNumber;
return new AccountAttributes.Capabilities(true,
true,
attachmentBackfill,
spqr,
usernameSyncChangeMessage,
optionalPhoneNumber);
}
public static ServiceEnvironmentConfig getServiceEnvironmentConfig(

View File

@ -3,7 +3,7 @@ package org.asamk.signal.manager.config;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.signal.network.config.SignalServiceConfiguration;
import java.util.List;

View File

@ -3,15 +3,15 @@ package org.asamk.signal.manager.config;
import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.whispersystems.signalservice.api.push.TrustStore;
import org.whispersystems.signalservice.internal.configuration.HttpProxy;
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl;
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url;
import org.signal.network.config.HttpProxy;
import org.signal.network.config.SignalCdnUrl;
import org.signal.network.config.SignalCdsiUrl;
import org.signal.network.config.SignalProxy;
import org.signal.network.config.SignalServiceConfiguration;
import org.signal.network.config.SignalServiceUrl;
import org.signal.network.config.SignalStorageUrl;
import org.signal.network.config.SignalSvr2Url;
import org.signal.network.config.TrustStore;
import java.util.Base64;
import java.util.List;
@ -30,7 +30,8 @@ class StagingConfig {
private static final byte[] UNIDENTIFIED_SENDER_TRUST_ROOT2 = Base64.getDecoder()
.decode("BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm");
private static final String CDSI_MRENCLAVE = "0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57";
private static final String SVR2_MRENCLAVE = "a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036";
private static final String SVR2_MRENCLAVE = "0ff2d7d4efbe7cfc24ac069a16fba898928dbe6c40d500c8b6da55733c727d6e";
private static final String SVR2_MRENCLAVE_LEGACY = "3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302";
private static final String URL = "https://chat.staging.signal.org";
private static final String CDN_URL = "https://cdn-staging.signal.org";
@ -48,10 +49,10 @@ class StagingConfig {
private static final byte[] zkGroupServerPublicParams = Base64.getDecoder()
.decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==");
private static final byte[] genericServerPublicParams = Base64.getDecoder()
.decode("AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N");
.decode("AYhaw+NbxtNLo/RlGFEsHd904hW38LpPJ59jYJlNmT4wwtyOq4xzCs/MyXsfRbIsAYhQjDnpE0rhFtWkMcn/kV740SISwFfpPHunrtZ9h0YWz5QNNbI5I3DRGUjhKXgMU7J7s7qOr0fdms+g0e+L9FMSjJLobDkOngp/m0B5TsxTyqLscJ5VyU69Cj8txImTfHMCKrYphYfRHO78RwPoz2g2tGUAzEbKHm12OgDna2qutkE5TvYqwZczvgZyLVHdHXpvdyOlEdv4afVyWkI7u/S0XYDonIJoHlxqJoTSepZR");
private static final byte[] backupServerPublicParams = Base64.getDecoder()
.decode("AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8");
.decode("AXYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszS6h3nZ6vRdM/IYGK+cLynw3ucWo7idf3zjOG3b6JnGT/z7XYCr6HuOGkWH4DQWCH98hxVZMGOgmT8DCQoqebQb3oK1yrwEglRWmtI01KhRg9RGUKoQiwuej1JZEY8uaG4Uz9n1cVODJ1iuByhNqGHo+KfI4iWhjtx2AnhYqHViQ3CMd4ASGBJtic9UTFVk/4vegVIy0wfYsAmViftzK6t4");
private static final Network.Environment LIBSIGNAL_NET_ENV = Network.Environment.STAGING;
@ -93,7 +94,7 @@ class StagingConfig {
createDefaultServiceConfiguration(interceptors),
getUnidentifiedSenderTrustRoots(),
CDSI_MRENCLAVE,
List.of(SVR2_MRENCLAVE));
List.of(SVR2_MRENCLAVE, SVR2_MRENCLAVE_LEGACY));
}
private StagingConfig() {

View File

@ -1,6 +1,6 @@
package org.asamk.signal.manager.config;
import org.whispersystems.signalservice.api.push.TrustStore;
import org.signal.network.config.TrustStore;
import java.io.InputStream;

View File

@ -1,5 +1,6 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.IncorrectPinException;
@ -17,19 +18,24 @@ import org.asamk.signal.manager.util.Utils;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.Base64;
import org.signal.core.util.crypto.DeviceNameCipher;
import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.NoSessionException;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.protocol.state.KyberPreKeyRecord;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.protocol.util.KeyHelper;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.signal.network.api.AccountApiV2.SetAccountAttributesError;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse;
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
@ -37,8 +43,6 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
@ -46,7 +50,9 @@ import org.whispersystems.signalservice.internal.push.SyncMessage;
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
@ -57,6 +63,7 @@ import okio.ByteString;
import static org.asamk.signal.manager.config.ServiceConfig.PREKEY_MAXIMUM_ID;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public class AccountHelper {
@ -91,10 +98,10 @@ public class AccountHelper {
} else {
context.getPreKeyHelper().refreshPreKeysIfNecessary();
}
if (account.getPni() == null) {
if (account.getPni() == null && account.getNumber() != null) {
checkWhoAmiI();
}
if (!account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
if (!account.isPrimaryDevice() && account.getPni() != null && account.getPniIdentityKeyPair() == null) {
throw new IOException("Missing PNI identity key, relinking required");
}
if (account.getPreviousStorageVersion() < 10
@ -133,8 +140,9 @@ public class AccountHelper {
final var whoAmI = dependencies.getAccountManager().getWhoAmI();
final var number = whoAmI.getNumber();
final var aci = ACI.parseOrThrow(whoAmI.getAci());
final var pni = PNI.parseOrThrow(whoAmI.getPni());
if (number.equals(account.getNumber()) && aci.equals(account.getAci()) && pni.equals(account.getPni())) {
final var pni = whoAmI.getPni() == null ? null : PNI.parseOrThrow(whoAmI.getPni());
if (Objects.equals(number, account.getNumber()) && aci.equals(account.getAci()) && Objects.equals(pni,
account.getPni())) {
return;
}
@ -145,7 +153,7 @@ public class AccountHelper {
account.setNumber(number);
account.setAci(aci);
account.setPni(pni);
if (account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
if (pni != null && account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
}
account.getRecipientTrustedResolver().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
@ -280,7 +288,7 @@ public class AccountHelper {
final var message = messageSender.getEncryptedSyncPniInitializeDeviceMessage(deviceId,
pniChangeNumber);
encryptedDeviceMessages.add(message);
} catch (UntrustedIdentityException | IOException | InvalidKeyException e) {
} catch (UntrustedIdentityException | IOException | InvalidKeyException | NoSessionException e) {
throw new RuntimeException(e);
}
}
@ -320,10 +328,14 @@ public class AccountHelper {
return;
}
handlePniChangeNumberMessage(selfChangeNumber, updatePni);
handlePniChangeNumberMessage(selfChangeNumber, updatePni, false);
}
public void handlePniChangeNumberMessage(final SyncMessage.PniChangeNumber pniChangeNumber, final PNI updatedPni) {
public boolean handlePniChangeNumberMessage(
final SyncMessage.PniChangeNumber pniChangeNumber,
final PNI updatedPni,
final boolean forcePniPreKeyRotation
) {
if (pniChangeNumber.identityKeyPair != null
&& pniChangeNumber.registrationId != null
&& pniChangeNumber.signedPreKey != null) {
@ -337,10 +349,19 @@ public class AccountHelper {
pniChangeNumber.lastResortKyberPreKey != null
? new KyberPreKeyRecord(pniChangeNumber.lastResortKyberPreKey.toByteArray())
: null);
if (forcePniPreKeyRotation) {
try {
context.getPreKeyHelper().forceRefreshPreKeys(ServiceIdType.PNI);
} catch (IOException e) {
logger.warn("Failed to force refresh PNI pre keys after PNI change sync", e);
}
}
return true;
} catch (Exception e) {
logger.warn("Failed to handle change number message", e);
}
}
return false;
}
public static final int USERNAME_MIN_LENGTH = 3;
@ -382,13 +403,17 @@ public class AccountHelper {
}
private void reserveUsername(final List<Username> candidates) throws IOException {
final var candidateHashes = new ArrayList<String>();
final var candidateHashes = new ArrayList<byte[]>();
for (final var candidate : candidates) {
candidateHashes.add(Base64.encodeUrlSafeWithoutPadding(candidate.getHash()));
candidateHashes.add(candidate.getHash());
}
final var response = handleResponseException(dependencies.getAccountApi().reserveUsername(candidateHashes));
final var hashIndex = candidateHashes.indexOf(response.getUsernameHash());
final var usernameHash = handleResponseException(dependencies.getAccountApi().reserveUsername(candidateHashes));
final var hashIndex = candidateHashes.stream()
.filter(candidateHash -> Arrays.equals(candidateHash, usernameHash))
.findFirst()
.map(candidateHashes::indexOf)
.orElse(-1);
if (hashIndex == -1) {
logger.warn("[reserveUsername] The response hash could not be found in our set of candidateHashes.");
throw new IOException("Unexpected username response");
@ -481,8 +506,7 @@ public class AccountHelper {
final var usernameLink = account.getUsernameLink();
if (usernameLink == null) {
handleResponseException(dependencies.getAccountApi()
.reserveUsername(List.of(Base64.encodeUrlSafeWithoutPadding(username.getHash()))));
handleResponseException(dependencies.getAccountApi().reserveUsername(List.of(username.getHash())));
logger.debug("[reserveUsername] Successfully reserved existing username.");
final var linkComponents = confirmUsernameAndCreateNewLink(username);
account.setUsernameLink(linkComponents);
@ -507,28 +531,34 @@ public class AccountHelper {
}
}
@SuppressWarnings("unchecked")
public void deleteUsername() throws IOException {
handleResponseException(dependencies.getAccountApi().deleteUsername());
handleResponseException(dependencies.getAccountApi().deleteUsernameHash());
account.setUsernameLink(null);
account.setUsername(null);
logger.debug("[deleteUsername] Successfully deleted the username.");
}
public void setDeviceName(String deviceName) {
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey();
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);
account.setEncryptedDeviceName(encryptedDeviceName);
final var encryptedDeviceName = getEncryptedDeviceName(deviceName);
account.setEncryptedDeviceName(Base64.encodeWithoutPadding(encryptedDeviceName));
}
public void setDeviceName(int deviceId, String deviceName) throws IOException {
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey();
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);
handleResponseException(dependencies.getLinkDeviceApi().setDeviceName(encryptedDeviceName, deviceId));
final var encryptedDeviceName = getEncryptedDeviceName(deviceName);
handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
.setDeviceName(encryptedDeviceName, deviceId, cont));
context.getSyncHelper().sendDeviceNameChange(deviceId);
}
private byte[] getEncryptedDeviceName(final String deviceName) {
final var identityKey = account.getAciIdentityKeyPair();
return DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), identityKey);
}
public void refreshDeviceName() throws IOException {
final var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
.getDevices(cont));
final var deviceId = account.getDeviceId();
final var device = devices.stream().filter(d -> d.id == deviceId).findFirst();
if (device.isPresent()) {
@ -537,7 +567,20 @@ public class AccountHelper {
}
public void updateAccountAttributes() throws IOException {
handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(null)));
if (account.getNumber() != null) {
handleResponseException(dependencies.getAccountApi()
.setAccountAttributes(account.getAccountAttributes(null)));
return;
}
try {
handleResponseExceptionSuspend(cont -> dependencies.getAccountApiV2()
.setAccountAttributes(account.getAccountAttributesV2(), cont));
} catch (BadRequestException e) {
if (e.getError() instanceof SetAccountAttributesError.Unauthorized) {
throw new AuthorizationFailedException(401, "Authorization failed!");
}
throw new IOException("Account attribute update rate limited; try again later");
}
}
public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException {
@ -562,36 +605,39 @@ public class AccountHelper {
account.getOrCreatePinMasterKey(),
account.getOrCreateMediaRootBackupKey(),
verificationCode.getVerificationCode(),
null));
null,
account.getAuthCredentialSalt()));
account.setMultiDevice(true);
context.getJobExecutor().enqueueJob(new SyncStorageJob());
}
public void removeLinkedDevices(int deviceId) throws IOException {
handleResponseException(dependencies.getLinkDeviceApi().removeDevice(deviceId));
var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi().removeDevice(deviceId, cont));
final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
.getDevices(cont));
account.setMultiDevice(devices.size() > 1);
}
@SuppressWarnings("unchecked")
public void migrateRegistrationPin() throws IOException {
var masterKey = account.getOrCreatePinMasterKey();
context.getPinHelper().migrateRegistrationLockPin(account.getRegistrationLockPin(), masterKey);
handleResponseException(dependencies.getAccountApi()
.enableRegistrationLock(masterKey.deriveRegistrationLock()));
handleResponseException(dependencies.getAccountApi().enableRegistrationLock(masterKey));
}
@SuppressWarnings("unchecked")
public void setRegistrationPin(String pin) throws IOException {
var masterKey = account.getOrCreatePinMasterKey();
context.getPinHelper().setRegistrationLockPin(pin, masterKey);
handleResponseException(dependencies.getAccountApi()
.enableRegistrationLock(masterKey.deriveRegistrationLock()));
handleResponseException(dependencies.getAccountApi().enableRegistrationLock(masterKey));
account.setRegistrationLockPin(pin);
updateAccountAttributes();
}
@SuppressWarnings("unchecked")
public void removeRegistrationPin() throws IOException {
// Remove KBS Pin
context.getPinHelper().removeRegistrationLockPin();
@ -604,7 +650,7 @@ public class AccountHelper {
// When setting an empty GCM id, the Signal-Server also sets the fetchesMessages property to false.
// If this is the primary device, other users can't send messages to this number anymore.
// If this is a linked device, other users can still send messages, but this device doesn't receive them anymore.
handleResponseException(dependencies.getAccountApi().clearFcmToken());
handleResponseExceptionSuspend(cont -> dependencies.getAccountApi().clearFcmToken(cont));
account.setRegistered(false);
unregisteredListener.call();

View File

@ -1,20 +1,25 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.AttachmentStore;
import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.File;
import java.io.IOException;
@ -30,8 +35,10 @@ public class AttachmentHelper {
private final SignalDependencies dependencies;
private final AttachmentStore attachmentStore;
private final Context context;
public AttachmentHelper(final Context context) {
this.context = context;
this.dependencies = context.getDependencies();
this.attachmentStore = context.getAttachmentStore();
}
@ -44,8 +51,13 @@ public class AttachmentHelper {
return attachmentStore.retrieveAttachment(id);
}
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments, boolean voiceNote) throws AttachmentInvalidException, IOException {
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
public List<SignalServiceAttachment> uploadAttachments(
final List<String> attachments,
final List<AttachmentDimensions> dimensions,
final List<String> blurHashes,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
final var attachmentStreams = createAttachmentStreams(attachments, dimensions, blurHashes, voiceNote);
try {
// Upload attachments here, so we only upload once even for multiple recipients
@ -62,24 +74,73 @@ public class AttachmentHelper {
}
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
return uploadAttachments(attachments, false);
return uploadAttachments(attachments, List.of(), List.of(), false);
}
private List<SignalServiceAttachmentStream> createAttachmentStreams(List<String> attachments, boolean voiceNote) throws AttachmentInvalidException, IOException {
private List<SignalServiceAttachmentStream> createAttachmentStreams(
List<String> attachments,
List<AttachmentDimensions> dimensions,
List<String> blurHashes,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
if (attachments == null) {
return null;
}
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
for (var attachment : attachments) {
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
signalServiceAttachments.add(AttachmentUtils.createAttachmentStream(attachment, voiceNote, uploadSpec));
for (var i = 0; i < attachments.size(); i++) {
final var size = i < dimensions.size() ? dimensions.get(i) : null;
final var blurHash = i < blurHashes.size() && !blurHashes.get(i).isEmpty() ? blurHashes.get(i) : null;
signalServiceAttachments.add(getAttachmentStream(attachments.get(i), size, blurHash, voiceNote));
}
return signalServiceAttachments;
}
private SignalServiceAttachmentStream getAttachmentStream(
final String attachment,
final AttachmentDimensions dimensions,
final String blurHash,
final boolean voiceNote
) throws AttachmentInvalidException {
try {
// Reject local files that point into the signal-cli data directory
if (attachment != null && !attachment.startsWith("data:")) {
try {
final var file = new File(attachment);
final var canonical = file.getCanonicalFile();
final var dataPath = context.getAccount().getDataPath().getCanonicalFile();
if (canonical.toPath().startsWith(dataPath.toPath())) {
throw new AttachmentInvalidException(attachment,
new IOException("Attaching files from the signal-cli data directory is not allowed"));
}
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
}
}
final var streamDetailsAndFileName = Utils.createStreamDetails(attachment);
final var streamDetails = streamDetailsAndFileName.first();
final var uploadSpec = getResumableUploadSpec(streamDetails);
return AttachmentUtils.createAttachmentStream(streamDetails,
streamDetailsAndFileName.second(),
voiceNote,
dimensions,
blurHash,
uploadSpec);
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
}
}
public ResumableUploadSpec getResumableUploadSpec(final StreamDetails streamDetails) throws IOException {
final var streamLength = streamDetails.getLength();
final var ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(
streamLength));
return dependencies.getCdnService().getResumableUploadSpecBlocking(ciphertextLength);
}
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
var attachmentStream = AttachmentUtils.createAttachmentStream(attachment, uploadSpec);
final var attachmentStream = getAttachmentStream(attachment, null, null, false);
return uploadAttachment(attachmentStream);
}

View File

@ -105,6 +105,8 @@ public class CallManager implements AutoCloseable {
recipientAddress,
recipientId);
activeCalls.put(callId, state);
dependencies.getAuthenticatedSignalWebSocket().registerKeepAliveToken("call" + callId);
dependencies.getUnauthenticatedSignalWebSocket().registerKeepAliveToken("call" + callId);
fireCallEvent(state, null);
// Spawn call tunnel binary and connect control channel
@ -197,11 +199,6 @@ public class CallManager implements AutoCloseable {
if (callEventListeners.isEmpty()) {
logger.debug("Ignoring incoming offer for call {}: no call event listeners registered",
callIdUnsigned(callId));
final var result = sendBusyMessage(callId, recipientId, deviceId);
if (!result.isSuccess()) {
logger.warn("Failed to send busy for unhandled call {}", callIdUnsigned(callId));
}
return;
}
@ -652,7 +649,7 @@ public class CallManager implements AutoCloseable {
case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY;
default -> HangupMessage.Type.NORMAL;
};
var hangupMessage = new HangupMessage(state.callId, type, state.deviceId);
var hangupMessage = new HangupMessage(state.callId, type, 0);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId));
@ -701,6 +698,8 @@ public class CallManager implements AutoCloseable {
private void endCall(final long callId, final String reason) {
var state = activeCalls.remove(callId);
dependencies.getAuthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId);
dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId);
if (state == null) return;
state.state = CallInfo.State.ENDED;
@ -712,7 +711,7 @@ public class CallManager implements AutoCloseable {
&& !"rejected".equals(reason)
&& !"remote_busy".equals(reason)
&& !"ringrtc_hangup".equals(reason)) {
var hangupMessage = new HangupMessage(callId, HangupMessage.Type.NORMAL, state.deviceId);
var hangupMessage = new HangupMessage(callId, HangupMessage.Type.NORMAL, 0);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
if (!result.isSuccess()) {

View File

@ -85,12 +85,18 @@ public class ContactHelper {
}
public void setContactBlocked(RecipientId recipientId, boolean blocked) {
setContactBlocked(recipientId, blocked, blocked ? System.currentTimeMillis() : 0);
}
public void setContactBlocked(RecipientId recipientId, boolean blocked, long blockedAt) {
var contact = account.getContactStore().getContact(recipientId);
final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
if (blocked) {
builder.withIsProfileSharingEnabled(false);
}
account.getContactStore().storeContact(recipientId, builder.withIsBlocked(blocked).build());
account.getContactStore()
.storeContact(recipientId,
builder.withIsBlocked(blocked).withBlockedAt(blocked ? blockedAt : 0).build());
}
public void setContactProfileSharing(RecipientId recipientId, boolean profileSharing) {

View File

@ -123,7 +123,7 @@ public class GroupHelper {
return Optional.empty();
}
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec));
}
@ -363,6 +363,28 @@ public class GroupHelper {
return results;
}
public SendGroupMessageResults terminateGroup(final GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException {
final var group = getGroupForUpdating(groupId);
if (!(group instanceof GroupInfoV2)) {
throw new IOException("Terminating a group is only supported for Signal group v2 groups.");
}
SendGroupMessageResults results;
try {
results = terminateGroupV2((GroupInfoV2) group);
} catch (ConflictException e) {
// Detected conflicting update, refreshing group and trying again
results = terminateGroupV2((GroupInfoV2) getGroup(groupId, true));
}
context.getJobExecutor().enqueueJob(new SyncStorageJob());
return results;
}
private SendGroupMessageResults terminateGroupV2(final GroupInfoV2 group) throws IOException {
final var groupGroupChangePair = context.getGroupV2Helper().terminateGroup(group);
return sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
var group = getGroupForUpdating(groupId);
@ -437,12 +459,21 @@ public class GroupHelper {
}
public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
setGroupBlocked(groupId, blocked, blocked ? System.currentTimeMillis() : 0);
}
public void setGroupBlocked(
final GroupId groupId,
final boolean blocked,
final long blockedAt
) throws GroupNotFoundException {
var group = getGroup(groupId);
if (group == null) {
throw new GroupNotFoundException(groupId);
}
group.setBlocked(blocked);
group.setBlockedAt(blocked ? blockedAt : 0);
account.getGroupStore().updateGroup(group);
context.getJobExecutor().enqueueJob(new SyncStorageJob());
}
@ -558,16 +589,24 @@ public class GroupHelper {
private void storeProfileKeysFromMembers(final DecryptedGroup group) {
for (var member : group.members) {
final var serviceId = ServiceId.parseOrThrow(member.aciBytes);
final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
final var profileStore = account.getProfileStore();
if (profileStore.getProfileKey(recipientId) != null) {
// We already have a profile key, not updating it from a non-authoritative source
continue;
}
try {
profileStore.storeProfileKey(recipientId, new ProfileKey(member.profileKey.toByteArray()));
} catch (InvalidInputException ignored) {
}
storeProfileKeyIfMissing(serviceId, member.profileKey.toByteArray());
}
for (var member : group.requestingMembers) {
final var serviceId = ServiceId.parseOrThrow(member.aciBytes);
storeProfileKeyIfMissing(serviceId, member.profileKey.toByteArray());
}
}
private void storeProfileKeyIfMissing(final ServiceId serviceId, final byte[] profileKeyBytes) {
final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
final var profileStore = account.getProfileStore();
if (profileStore.getProfileKey(recipientId) != null) {
// We already have a profile key, not updating it from a non-authoritative source
return;
}
try {
profileStore.storeProfileKey(recipientId, new ProfileKey(profileKeyBytes));
} catch (InvalidInputException ignored) {
}
}
@ -726,7 +765,7 @@ public class GroupHelper {
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
final var newMembers = new HashSet<>(members);
newMembers.removeAll(group.getMembers());
newMembers.removeAll(group.getMemberRecipientIds());
newMembers.removeAll(group.getRequestingMembers());
if (!newMembers.isEmpty()) {
var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
@ -768,12 +807,8 @@ public class GroupHelper {
newAdmins.retainAll(group.getMemberRecipientIds());
newAdmins.removeAll(group.getAdminMemberRecipientIds());
if (!newAdmins.isEmpty()) {
for (var admin : newAdmins) {
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
result = sendUpdateGroupV2Message(group,
groupGroupChangePair.first(),
groupGroupChangePair.second());
}
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, newAdmins, true);
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
}
@ -781,12 +816,8 @@ public class GroupHelper {
final var existingRemoveAdmins = new HashSet<>(removeAdmins);
existingRemoveAdmins.retainAll(group.getAdminMemberRecipientIds());
if (!existingRemoveAdmins.isEmpty()) {
for (var admin : existingRemoveAdmins) {
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
result = sendUpdateGroupV2Message(group,
groupGroupChangePair.first(),
groupGroupChangePair.second());
}
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, existingRemoveAdmins, false);
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
}

View File

@ -21,6 +21,7 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.GroupChange;
import org.signal.storageservice.storage.protos.groups.GroupChangeResponse;
@ -43,7 +44,6 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
import java.io.IOException;
@ -136,7 +136,7 @@ class GroupV2Helper {
int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
ByteString aciBytes = getSelfAci().toByteString();
ByteString pniBytes = getSelfPni().toByteString();
ByteString pniBytes = getSelfPni() == null ? null : getSelfPni().toByteString();
for (DecryptedMember decryptedMember : partialDecryptedGroup.members) {
if (decryptedMember.aciBytes.equals(aciBytes) || decryptedMember.pniBytes.equals(pniBytes)) {
return decryptedMember.joinedAtRevision;
@ -264,6 +264,9 @@ class GroupV2Helper {
var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
final var selfAci = getSelfAci();
var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
if (selfPendingMember.isEmpty() && getSelfPni() != null) {
selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni());
}
if (selfPendingMember.isPresent()) {
return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
@ -501,18 +504,25 @@ class GroupV2Helper {
Pair<DecryptedGroup, GroupChangeResponse> setMemberAdmin(
GroupInfoV2 groupInfoV2,
RecipientId recipientId,
Set<RecipientId> recipientIds,
boolean admin
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
if (address.getServiceId() instanceof ACI aci) {
final var change = groupOperations.createChangeMemberRole(aci, newRole);
return commitChange(groupInfoV2, change);
} else {
final var change = new GroupChange.Actions.Builder();
final var memberRoles = recipientIds.stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress)
.map(SignalServiceAddress::getServiceId)
.filter(m -> m instanceof ACI)
.map(m -> (ACI) m)
.map(aci -> new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(groupOperations.encryptServiceId(
aci)).role(newRole).build())
.toList();
if (memberRoles.size() < recipientIds.size()) {
throw new IllegalArgumentException("Can't make a PNI a group admin.");
}
change.modifyMemberRoles(memberRoles);
return commitChange(groupInfoV2, change);
}
Pair<DecryptedGroup, GroupChangeResponse> setMessageExpirationTimer(
@ -533,6 +543,14 @@ class GroupV2Helper {
return commitChange(groupInfoV2, change);
}
Pair<DecryptedGroup, GroupChangeResponse> terminateGroup(
GroupInfoV2 groupInfoV2
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var change = groupOperations.createTerminateGroup();
return commitChange(groupInfoV2, change);
}
Pair<DecryptedGroup, GroupChangeResponse> setMemberLabels(
GroupInfoV2 groupInfoV2,
String labelEmoji,
@ -740,8 +758,14 @@ class GroupV2Helper {
var authCredentialResponse = groupApiCredentials.get(todaySeconds);
final var aci = getSelfAci();
final var pni = getSelfPni();
final var authCredentialSalt = context.getAccount().getAuthCredentialSalt();
return dependencies.getGroupsV2Api()
.getGroupsV2AuthorizationString(aci, pni, todaySeconds, groupSecretParams, authCredentialResponse);
.getGroupsV2AuthorizationString(aci,
pni,
authCredentialSalt,
todaySeconds,
groupSecretParams,
authCredentialResponse);
}
private ACI getSelfAci() {

View File

@ -109,8 +109,8 @@ public final class IncomingMessageHandler {
SignalServiceContent content = null;
if (!envelope.isReceipt()) {
account.getIdentityKeyStore().setRetryingDecryption(true);
final var destination = getDestination(envelope).serviceId();
try {
final var destination = getDestination(envelope).serviceId();
final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
@ -140,15 +140,30 @@ public final class IncomingMessageHandler {
final Manager.ReceiveMessageHandler handler
) {
final var actions = new ArrayList<HandleAction>();
if (envelope.isPreKeySignalMessage()) {
actions.add(RefreshPreKeysAction.create());
}
SignalServiceContent content = null;
Exception exception = null;
envelope.getSourceServiceId().map(ServiceId::parseOrNull)
// Store uuid if we don't have it already
// uuid in envelope is sent by server
.ifPresent(serviceId -> account.getRecipientResolver().resolveRecipient(serviceId));
if (envelope.getSourceServiceId() != null) {
// Store uuid if we don't have it already
// uuid in envelope is sent by server
account.getRecipientResolver().resolveRecipient(envelope.getSourceServiceId());
}
if (!envelope.isReceipt()) {
final var destination = getDestination(envelope).serviceId();
try {
final var destination = getDestination(envelope).serviceId();
if (destination == account.getPni() && envelope.getSourceServiceId() == null) {
throw new InvalidMessageException(
"Got a sealed sender message to our PNI? Invalid message, ignoring.");
}
if (envelope.getSourceServiceId() instanceof ServiceId.PNI
&& envelope.getProto().type != Envelope.Type.SERVER_DELIVERY_RECEIPT) {
throw new InvalidMessageException("Got a message from a PNI that was not a SERVER_DELIVERY_RECEIPT.");
}
final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
@ -173,7 +188,13 @@ public final class IncomingMessageHandler {
logger.debug("Received invalid message from blocked contact, ignoring.");
} else {
var serviceId = ServiceId.parseOrNull(e.getSender());
if (serviceId != null) {
ServiceId destination;
try {
destination = getDestination(envelope).serviceId();
} catch (InvalidMessageException ex) {
destination = null;
}
if (serviceId != null && destination != null) {
final var isSelf = sender.equals(account.getSelfRecipientId())
&& e.getSenderDevice() == account.getDeviceId();
logger.debug("Received invalid message, queuing renew session action.");
@ -311,12 +332,17 @@ public final class IncomingMessageHandler {
final var sender = senderDeviceAddress.recipientId();
final var senderServiceId = senderDeviceAddress.serviceId();
final var senderDeviceId = senderDeviceAddress.deviceId();
final var destination = getDestination(envelope);
final DeviceAddress destination;
try {
destination = getDestination(envelope);
} catch (InvalidMessageException e) {
throw new AssertionError(e);
}
if (account.getPni().equals(destination.serviceId)) {
account.getRecipientStore().markNeedsPniSignature(destination.recipientId, true);
if (destination.serviceId.equals(account.getPni())) {
account.getRecipientStore().markNeedsPniSignature(sender, true);
} else if (account.getAci().equals(destination.serviceId)) {
account.getRecipientStore().markNeedsPniSignature(destination.recipientId, false);
account.getRecipientStore().markNeedsPniSignature(sender, false);
}
if (content.getReceiptMessage().isPresent()) {
@ -472,10 +498,11 @@ public final class IncomingMessageHandler {
}
logger.debug("Verified association of ACI {} with PNI {}", aci, pni);
account.getRecipientTrustedResolver()
final var recipientId = account.getRecipientTrustedResolver()
.resolveRecipientTrusted(Optional.of(ACI.from(aci.getRawUuid())),
Optional.of(pni),
senderAddress.getNumber());
account.getRecipientStore().markPniSignatureVerified(recipientId);
return true;
}
@ -599,13 +626,12 @@ public final class IncomingMessageHandler {
for (var individual : blockedListMessage.individuals) {
final var address = new RecipientAddress(individual.getAci(), individual.getE164());
final var recipientId = account.getRecipientResolver().resolveRecipient(address);
context.getContactHelper().setContactBlocked(recipientId, true);
context.getContactHelper().setContactBlocked(recipientId, true, individual.getBlockedAt());
}
for (var groupId : blockedListMessage.groupIds.stream()
.map(GroupId::unknownVersion)
.collect(Collectors.toSet())) {
for (var group : blockedListMessage.groups) {
final var groupId = GroupId.unknownVersion(group.getGroupId());
try {
context.getGroupHelper().setGroupBlocked(groupId, true);
context.getGroupHelper().setGroupBlocked(groupId, true, group.getBlockedAt());
} catch (GroupNotFoundException e) {
logger.warn("BlockedListMessage contained groupID that was not found in GroupStore: {}",
groupId.toBase64());
@ -691,11 +717,30 @@ public final class IncomingMessageHandler {
}
if (syncMessage.getPniChangeNumber().isPresent()) {
final var pniChangeNumber = syncMessage.getPniChangeNumber().get();
logger.debug("Received PNI change number sync message, applying.");
final var updatedPniString = envelope.getUpdatedPni();
if (updatedPniString != null && !updatedPniString.isEmpty()) {
final var updatedPni = ServiceId.PNI.parseOrThrow(updatedPniString);
context.getAccountHelper().handlePniChangeNumberMessage(pniChangeNumber, updatedPni);
if (account.isPrimaryDevice()) {
logger.warn("Received PNI change number sync message on primary device, ignoring.");
} else if (sender.deviceId() != SignalServiceAddress.DEFAULT_DEVICE_ID) {
logger.warn("Received PNI change number sync message from non-primary device {}, ignoring.",
sender.deviceId());
} else {
final var envelopeServerTimestamp = envelope.getServerDeliveredTimestamp();
final var lastAppliedServerTimestamp = account.getLastAppliedPniChangeServerTimestamp();
if (envelopeServerTimestamp <= lastAppliedServerTimestamp) {
logger.warn(
"PNI change number sync server timestamp ({}) is not newer than last applied ({}), treating as replay.",
envelopeServerTimestamp,
lastAppliedServerTimestamp);
} else {
final var updatedPniString = envelope.getUpdatedPni();
if (updatedPniString != null && !updatedPniString.isEmpty()) {
final var updatedPni = ServiceId.PNI.parseOrThrow(updatedPniString);
final var applied = context.getAccountHelper()
.handlePniChangeNumberMessage(pniChangeNumber, updatedPni, true);
if (applied) {
account.setLastAppliedPniChangeServerTimestamp(envelopeServerTimestamp);
}
}
}
}
}
if (syncMessage.getDeviceNameChange().isPresent()) {
@ -791,6 +836,15 @@ public final class IncomingMessageHandler {
return true;
}
if (group.isTerminated()) {
return message == null
|| message.getBody().isPresent()
|| message.getAttachments().isPresent()
|| message.getQuote().isPresent()
|| message.getPreviews().isPresent()
|| message.getMentions().isPresent()
|| message.getSticker().isPresent();
}
if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) {
return message == null
|| message.getBody().isPresent()
@ -874,11 +928,6 @@ public final class IncomingMessageHandler {
final var selfAddress = isSync ? source : destination;
final var conversationPartnerAddress = isSync ? destination : source;
if (conversationPartnerAddress != null && message.isEndSession()) {
account.getAccountData(selfAddress.serviceId())
.getSessionStore()
.deleteAllSessions(conversationPartnerAddress.serviceId());
}
if (message.isExpirationUpdate() || message.getBody().isPresent()) {
if (message.getGroupContext().isPresent()) {
final var groupContext = message.getGroupContext().get();
@ -1047,7 +1096,7 @@ public final class IncomingMessageHandler {
}
private SignalServiceAddress getSenderAddress(SignalServiceEnvelope envelope, SignalServiceContent content) {
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null);
final var serviceId = envelope.getSourceServiceId();
if (!envelope.isUnidentifiedSender() && serviceId != null) {
return new SignalServiceAddress(serviceId);
} else if (content != null) {
@ -1058,7 +1107,7 @@ public final class IncomingMessageHandler {
}
private DeviceAddress getSender(SignalServiceEnvelope envelope, SignalServiceContent content) {
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null);
final var serviceId = envelope.getSourceServiceId();
if (!envelope.isUnidentifiedSender() && serviceId != null) {
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(serviceId),
serviceId,
@ -1070,10 +1119,13 @@ public final class IncomingMessageHandler {
}
}
private DeviceAddress getDestination(SignalServiceEnvelope envelope) {
private DeviceAddress getDestination(SignalServiceEnvelope envelope) throws InvalidMessageException {
final var destination = envelope.getDestinationServiceId();
if (destination == null || destination.isUnknown()) {
return new DeviceAddress(account.getSelfRecipientId(), account.getAci(), account.getDeviceId());
throw new InvalidMessageException("Missing destination");
}
if (!destination.equals(account.getAci()) && !destination.equals(account.getPni())) {
throw new InvalidMessageException("Message not intended for this account");
}
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
destination,

View File

@ -9,6 +9,7 @@ import org.signal.libsignal.protocol.InvalidKeyIdException;
import org.signal.libsignal.protocol.state.KyberPreKeyRecord;
import org.signal.libsignal.protocol.state.PreKeyRecord;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.NetworkResultUtil;
@ -16,7 +17,6 @@ import org.whispersystems.signalservice.api.account.PreKeyUpload;
import org.whispersystems.signalservice.api.keys.OneTimePreKeyCounts;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.util.List;
@ -84,7 +84,8 @@ public class PreKeyHelper {
) throws IOException {
OneTimePreKeyCounts preKeyCounts;
try {
preKeyCounts = handleResponseException(dependencies.getKeysApi().getAvailablePreKeyCounts(serviceIdType));
preKeyCounts = handleResponseException(dependencies.getKeysApi()
.getAvailablePreKeyCountsSync(serviceIdType));
} catch (AuthorizationFailedException e) {
logger.debug("Failed to get pre key count, ignoring: " + e.getClass().getSimpleName());
preKeyCounts = new OneTimePreKeyCounts(0, 0);
@ -145,7 +146,7 @@ public class PreKeyHelper {
kyberPreKeyRecords);
var needsReset = false;
try {
NetworkResultUtil.toPreKeysLegacy(dependencies.getKeysApi().setPreKeys(preKeyUpload));
NetworkResultUtil.toPreKeysLegacy(dependencies.getKeysApi().setPreKeysSync(preKeyUpload));
try {
if (preKeyRecords != null) {
account.addPreKeys(serviceIdType, preKeyRecords);

View File

@ -17,10 +17,12 @@ import org.asamk.signal.manager.util.PaymentUtils;
import org.asamk.signal.manager.util.ProfileUtils;
import org.asamk.signal.manager.util.Utils;
import org.jetbrains.annotations.Nullable;
import org.signal.core.util.ExpiringProfileCredentialUtil;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.exceptions.PushNetworkException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.NetworkResultUtil;
@ -30,9 +32,7 @@ import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil;
import java.io.IOException;
import java.io.OutputStream;
@ -103,8 +103,16 @@ public final class ProfileHelper {
return getRecipientProfiles(recipientIds, false);
}
public void refreshRecipientProfile(RecipientId recipientId) {
getRecipientProfile(recipientId, true);
public boolean refreshRecipientProfile(RecipientId recipientId) {
try {
blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE, false));
return true;
} catch (IOException e) {
logger.warn("Failed to retrieve profile for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
return false;
}
}
public void refreshRecipientProfiles(Collection<RecipientId> recipientIds) {
@ -134,7 +142,9 @@ public final class ProfileHelper {
SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL,
false));
} catch (IOException e) {
logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
logger.warn("Failed to retrieve profile key credential for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
return null;
}
@ -261,7 +271,9 @@ public final class ProfileHelper {
try {
blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE, false));
} catch (IOException e) {
logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
logger.warn("Failed to retrieve profile for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
}
return account.getProfileStore().getProfile(recipientId);
@ -379,7 +391,9 @@ public final class ProfileHelper {
logger.trace("Done handling retrieved profile");
}).doOnError(e -> {
logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
logger.warn("Failed to retrieve profile for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
final var profile = account.getProfileStore().getProfile(recipientId);
final var newProfile = (
profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)

View File

@ -9,10 +9,10 @@ import org.asamk.signal.manager.jobs.CleanOldPreKeysJob;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.messageCache.CachedMessage;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.EnvelopeResponse;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
@ -148,15 +148,19 @@ public class ReceiveHelper {
logger.debug("Retrieved {} envelopes!", batch.size());
isWaitingForMessage = false;
for (final var it : batch) {
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(it.getEnvelope(),
it.getServerDeliveredTimestamp());
final var recipientId = envelope1.getSourceServiceId()
.map(ServiceId::parseOrNull)
.map(s -> account.getRecipientResolver().resolveRecipient(s))
.orElse(null);
logger.trace("Storing new message from {}", recipientId);
// store message on disk, before acknowledging receipt to the server
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
if (it instanceof EnvelopeResponse.Unparseable) {
logger.warn("Received unparseable envelope from server, ignoring.");
} else if (it instanceof EnvelopeResponse.Parsed parsed) {
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(parsed.getEnvelope(),
parsed.getServerDeliveredTimestamp());
final var sourceServiceId = envelope1.getSourceServiceId();
final var recipientId = sourceServiceId == null
? null
: account.getRecipientResolver().resolveRecipient(sourceServiceId);
logger.trace("Storing new message from {}", recipientId);
// store message on disk, before acknowledging receipt to the server
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
}
try {
signalWebSocket.sendAck(it);
} catch (IOException e) {
@ -168,6 +172,9 @@ public class ReceiveHelper {
backOffCounter = 0;
if (queueNotEmpty) {
if (cachedMessage[0] == null) {
continue;
}
if (remainingMessages > 0) {
remainingMessages -= 1;
}
@ -238,7 +245,7 @@ public class ReceiveHelper {
if (exception instanceof UntrustedIdentityException) {
logger.debug("Keeping message with untrusted identity in message cache");
final var address = ((UntrustedIdentityException) exception).getSender();
if (envelope.getSourceServiceId().isEmpty() && address.aci().isPresent()) {
if (envelope.getSourceServiceId() == null && address.aci().isPresent()) {
final var recipientId = account.getRecipientResolver()
.resolveRecipient(ACI.parseOrThrow(address.aci().get()));
try {
@ -292,7 +299,7 @@ public class ReceiveHelper {
cachedMessage.delete();
return null;
}
if (envelope.getSourceServiceId().isEmpty()) {
if (envelope.getSourceServiceId() == null) {
final var identifier = ((UntrustedIdentityException) exception).getSender();
final var recipientId = account.getRecipientResolver()
.resolveRecipient(new RecipientAddress(identifier));

View File

@ -11,13 +11,13 @@ import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.cds.CdsiV2Service;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidArgumentException;
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.util.Collection;

View File

@ -35,6 +35,8 @@ import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage;
@ -330,6 +332,91 @@ public class SendHelper {
return result;
}
/**
* Send a story message (file attachment) to "My Story".
*/
public List<SendMessageResult> sendStoryMessage(
SignalServiceStoryMessage storyMessage,
long timestamp,
Set<RecipientId> recipientIds,
boolean allowsReplies
) throws IOException {
final var messageSender = dependencies.getMessageSender();
final var recipientIdList = List.copyOf(recipientIds);
final var addressesMap = recipientIdList.stream()
.collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress));
final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds);
final var addresses = recipientIdList.stream().map(addressesMap::get).toList();
final var unidentifiedAccesses = recipientIdList.stream().map(unidentifiedAccessesMap::get).toList();
final var storyMessageRecipients = recipientIdList.stream()
.map(id -> new SignalServiceStoryMessageRecipient(addressesMap.get(id),
List.of(DistributionId.MY_STORY.asUuid().toString()),
allowsReplies))
.collect(Collectors.toSet());
final List<SendMessageResult> results;
try {
results = messageSender.sendGroupStory(DistributionId.MY_STORY,
Optional.empty(),
addresses,
unidentifiedAccesses,
null,
false,
storyMessage,
timestamp,
storyMessageRecipients,
null);
} catch (UntrustedIdentityException | InvalidKeyException | NoSessionException |
InvalidRegistrationIdException e) {
throw new IOException(e);
}
for (var r : results) {
handleSendMessageResult(r);
}
return results;
}
/**
* Send a story message (file attachment) to a group.
*/
public List<SendMessageResult> sendGroupStoryMessage(
SignalServiceStoryMessage storyMessage,
long timestamp,
GroupInfoV2 groupInfo,
boolean allowsReplies
) throws IOException {
final var messageSender = dependencies.getMessageSender();
final var allRecipientIds = groupInfo.getMembersWithout(account.getSelfRecipientId());
final SenderKeySenderHandler senderKeySender = (distId, recipients, unidentifiedAccess, groupSendEndorsements, isRecipientUpdate) -> messageSender.sendGroupStory(
distId,
Optional.of(groupInfo.getMasterKey().serialize()),
recipients,
unidentifiedAccess,
groupSendEndorsements,
isRecipientUpdate,
storyMessage,
timestamp,
recipients.stream()
.map(address -> new SignalServiceStoryMessageRecipient(address,
List.of(groupInfo.getDistributionId().asUuid().toString()),
allowsReplies))
.collect(Collectors.toSet()),
null);
final var results = sendGroupMessageInternal(null, senderKeySender, allRecipientIds, groupInfo, false);
for (var r : results) {
handleSendMessageResult(r);
}
return results;
}
private List<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g,
@ -480,7 +567,7 @@ public class SendHelper {
return results;
}
private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException, GroupSendingNotAllowedException {
var g = context.getGroupHelper().getGroup(groupId);
if (g == null) {
throw new GroupNotFoundException(groupId);
@ -488,6 +575,10 @@ public class SendHelper {
if (!g.isMember(account.getSelfRecipientId())) {
throw new NotAGroupMemberException(groupId, g.getTitle());
}
if (g.isTerminated()) {
// Other clients drop messages sent to a terminated group.
throw new GroupSendingNotAllowedException(groupId, g.getTitle());
}
if (!g.isProfileSharingEnabled()) {
g.setProfileSharingEnabled(true);
account.getGroupStore().updateGroup(g);
@ -507,9 +598,31 @@ public class SendHelper {
) throws IOException {
long startTime = System.currentTimeMillis();
final var addressesMap = recipientIds.stream()
// Recipients that are already known to be unregistered are skipped here.
// Otherwise every group send re-attempts them via the slow legacy 1:1
// fan-out, which on large groups can take tens of seconds (and time out).
// The unregistered flag is maintained independently by profile/CDS
// discovery, which clears it once a recipient registers again, so they
// are re-included automatically. An unregisteredFailure result is still
// returned for each skipped recipient, so callers see them unchanged.
final var skippedResults = new ArrayList<SendMessageResult>();
final Set<RecipientId> targetRecipientIds;
final var unregisteredRecipientIds = account.getRecipientStore().getUnregisteredRecipientIds(recipientIds);
if (unregisteredRecipientIds.isEmpty()) {
targetRecipientIds = recipientIds;
} else {
logger.debug("Skipping {} known-unregistered recipient(s) in group send.", unregisteredRecipientIds.size());
targetRecipientIds = new HashSet<>(recipientIds);
targetRecipientIds.removeAll(unregisteredRecipientIds);
for (final var recipientId : unregisteredRecipientIds) {
skippedResults.add(SendMessageResult.unregisteredFailure(context.getRecipientHelper()
.resolveSignalServiceAddress(recipientId)));
}
}
final var addressesMap = targetRecipientIds.stream()
.collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress));
final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds);
final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(targetRecipientIds);
final var groupSendEndorsementsResult = getGroupSendEndorsements(groupInfo);
final var groupSecretParams = groupInfo instanceof GroupInfoV2 gv2
? GroupSecretParams.deriveFromMasterKey((gv2.getMasterKey()))
@ -523,7 +636,7 @@ public class SendHelper {
: groupSendEndorsementsResult.first();
Set<RecipientId> senderKeyTargets = groupInfo.getDistributionId() == null || groupSendEndorsements == null
? Set.of()
: recipientIds.stream()
: targetRecipientIds.stream()
.filter(s -> this.isSenderKeyCapable(s,
addressesMap.get(s),
unidentifiedAccessesMap.get(s),
@ -533,7 +646,9 @@ public class SendHelper {
logger.debug("Too few sender-key-capable users ({}). Doing all legacy sends.", senderKeyTargets.size());
senderKeyTargets = Set.of();
} else {
logger.debug("Can use sender key for {}/{} recipients.", senderKeyTargets.size(), recipientIds.size());
logger.debug("Can use sender key for {}/{} recipients.",
senderKeyTargets.size(),
targetRecipientIds.size());
}
final var allResults = new ArrayList<SendMessageResult>(recipientIds.size());
@ -569,11 +684,11 @@ public class SendHelper {
}
}
final var legacyTargets = new HashSet<>(recipientIds);
final var legacyTargets = new HashSet<>(targetRecipientIds);
legacyTargets.removeAll(senderKeyTargets);
final boolean onlyTargetIsSelfWithLinkedDevice = recipientIds.isEmpty() && account.isMultiDevice();
final boolean onlyTargetIsSelfWithLinkedDevice = targetRecipientIds.isEmpty() && account.isMultiDevice();
if (!legacyTargets.isEmpty() || onlyTargetIsSelfWithLinkedDevice) {
if (legacySender != null && (!legacyTargets.isEmpty() || onlyTargetIsSelfWithLinkedDevice)) {
if (!legacyTargets.isEmpty()) {
logger.debug("Need to do {} legacy sends.", legacyTargets.size());
} else {
@ -605,6 +720,7 @@ public class SendHelper {
isRecipientUpdate || !allResults.isEmpty());
allResults.addAll(results);
}
allResults.addAll(skippedResults);
final var duration = Duration.ofMillis(System.currentTimeMillis() - startTime);
logger.debug("Sending took {}", duration.toString());
return allResults;
@ -667,7 +783,7 @@ public class SendHelper {
final var successCount = results.stream().filter(SendMessageResult::isSuccess).count();
logger.debug("Successfully sent using 1:1 to {}/{} legacy targets.", successCount, addresses.size());
return results;
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException | NoSessionException e) {
return List.of();
}
}
@ -770,7 +886,8 @@ public class SendHelper {
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
try {
final boolean includePniSignature = account.getRecipientStore().needsPniSignature(recipientId);
final boolean includePniSignature = account.getPni() != null && account.getRecipientStore()
.needsPniSignature(recipientId);
try {
return s.send(messageSender,
address,
@ -859,7 +976,7 @@ public class SendHelper {
SignalServiceAddress address,
SealedSenderAccess unidentifiedAccess,
boolean includePniSignature
) throws IOException, UnregisteredUserException, ProofRequiredException, RateLimitException, org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
) throws IOException, UnregisteredUserException, ProofRequiredException, RateLimitException, org.whispersystems.signalservice.api.crypto.UntrustedIdentityException, NoSessionException;
}
interface SenderKeySenderHandler {
@ -879,6 +996,6 @@ public class SendHelper {
List<SignalServiceAddress> recipients,
List<SealedSenderAccess> unidentifiedAccess,
boolean isRecipientUpdate
) throws IOException, UntrustedIdentityException;
) throws IOException, UntrustedIdentityException, NoSessionException;
}
}

View File

@ -2,14 +2,19 @@ package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.GroupIdV1;
import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.Profile;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.asamk.signal.manager.syncStorage.AccountRecordProcessor;
import org.asamk.signal.manager.syncStorage.ContactRecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor;
import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor;
import org.asamk.signal.manager.syncStorage.StorageSyncLoopDetector;
import org.asamk.signal.manager.syncStorage.StorageSyncModels;
import org.asamk.signal.manager.syncStorage.StorageSyncValidations;
import org.asamk.signal.manager.syncStorage.WriteOperationResult;
@ -17,6 +22,9 @@ import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.models.storageservice.StorageKey;
import org.signal.core.util.SetUtil;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.network.service.StorageServiceService;
import org.signal.network.service.StorageServiceService.ManifestIfDifferentVersionResult;
import org.signal.network.service.StorageServiceService.WriteStorageRecordsResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
@ -25,9 +33,6 @@ import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.api.storage.StorageRecordConvertersKt;
import org.whispersystems.signalservice.api.storage.StorageServiceRepository;
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.ManifestIfDifferentVersionResult;
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.WriteStorageRecordsResult;
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord;
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord;
@ -38,8 +43,10 @@ import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
@ -50,16 +57,19 @@ public class StorageHelper {
private static final List<Integer> KNOWN_TYPES = List.of(ManifestRecord.Identifier.Type.CONTACT.getValue(),
ManifestRecord.Identifier.Type.GROUPV1.getValue(),
ManifestRecord.Identifier.Type.GROUPV2.getValue(),
ManifestRecord.Identifier.Type.ACCOUNT.getValue());
ManifestRecord.Identifier.Type.ACCOUNT.getValue(),
ManifestRecord.Identifier.Type.STICKER_PACK.getValue());
private final SignalAccount account;
private final SignalDependencies dependencies;
private final Context context;
private final StorageSyncLoopDetector storageSyncLoopDetector;
public StorageHelper(final Context context) {
this.account = context.getAccount();
this.dependencies = context.getDependencies();
this.context = context;
this.storageSyncLoopDetector = new StorageSyncLoopDetector(account::isMultiDevice);
}
public void syncDataWithStorage() throws IOException {
@ -78,6 +88,8 @@ public class StorageHelper {
final var storageServiceRepository = dependencies.getStorageServiceRepository();
final var result = storageServiceRepository.getStorageManifestIfDifferentVersion(storageKey,
localManifestVersion);
final var fetchedRemoteManifest = result instanceof ManifestIfDifferentVersionResult.DifferentVersion;
final Set<StorageId> identityConflictsPendingRepair = new HashSet<>();
var needsForcePush = false;
final var remoteManifest = switch (result) {
@ -106,18 +118,22 @@ public class StorageHelper {
if (remoteManifest.version > localManifestVersion) {
logger.trace("Remote version was newer, reading records.");
needsForcePush = readDataFromStorage(storageKey, localManifest, remoteManifest);
needsForcePush = readDataFromStorage(storageKey,
localManifest,
remoteManifest,
identityConflictsPendingRepair);
} else if (remoteManifest.version < localManifest.version) {
logger.debug("Remote storage manifest version was older. User might have switched accounts.");
}
logger.trace("Done reading data from remote storage");
readRecordsWithPreviouslyUnknownTypes(storageKey, remoteManifest);
readRecordsWithPreviouslyUnknownTypes(storageKey, remoteManifest, identityConflictsPendingRepair);
}
logger.trace("Adding missing storageIds to local data");
account.getRecipientStore().setMissingStorageIds();
account.getGroupStore().setMissingStorageIds();
account.getStickerStore().setMissingStorageIds();
var needsMultiDeviceSync = false;
@ -139,7 +155,11 @@ public class StorageHelper {
needsForcePush = true;
} else {
try {
needsMultiDeviceSync = writeToStorage(storageKey, remoteManifest, needsForcePush);
needsMultiDeviceSync = writeToStorage(storageKey,
remoteManifest,
needsForcePush,
fetchedRemoteManifest,
identityConflictsPendingRepair);
} catch (RetryLaterException e) {
// TODO retry later
return;
@ -184,7 +204,8 @@ public class StorageHelper {
private boolean readDataFromStorage(
final StorageKey storageKey,
final SignalStorageManifest localManifest,
final SignalStorageManifest remoteManifest
final SignalStorageManifest remoteManifest,
final Set<StorageId> identityConflictsPendingRepair
) throws IOException {
var needsForcePush = false;
try (final var connection = account.getAccountDatabase().getConnection()) {
@ -211,20 +232,28 @@ public class StorageHelper {
remoteOnlyRecords.size());
}
// This logic is wrong, records should only be deleted if they're deleted remotely, not if the remote record is updated
// if (!idDifference.localOnlyIds().isEmpty()) {
// final var updated = account.getRecipientStore()
// .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection,
// idDifference.localOnlyIds());
//
// if (updated > 0) {
// logger.warn(
// "Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.",
// updated);
// }
// }
//
final var unknownInserts = processKnownRecords(connection, remoteOnlyRecords);
final var listListPair = processKnownRecords(connection,
remoteOnlyRecords,
identityConflictsPendingRepair);
final var unknownInserts = listListPair.first();
final var updatedStorageIds = listListPair.second();
final var oldUnregisteredLocalOnlyIds = new HashSet<>(idDifference.localOnlyIds());
updatedStorageIds.forEach(oldUnregisteredLocalOnlyIds::remove);
if (!idDifference.localOnlyIds().isEmpty()) {
final var updated = account.getRecipientStore()
.removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection,
oldUnregisteredLocalOnlyIds);
final var updatedStickers = account.getStickerStore()
.removeStorageIdsFromLocalOnlyDeletedStickerPacks(connection, oldUnregisteredLocalOnlyIds);
if (updated > 0 || updatedStickers > 0) {
logger.warn(
"Found {} recipients and {} sticker packs that were deleted remotely but only marked deleted locally. Removed those from local store.",
updated,
updatedStickers);
}
}
final var unknownDeletes = idDifference.localOnlyIds()
.stream()
.filter(id -> !KNOWN_TYPES.contains(id.getType()))
@ -248,7 +277,8 @@ public class StorageHelper {
private void readRecordsWithPreviouslyUnknownTypes(
final StorageKey storageKey,
final SignalStorageManifest remoteManifest
final SignalStorageManifest remoteManifest,
final Set<StorageId> identityConflictsPendingRepair
) throws IOException {
try (final var connection = account.getAccountDatabase().getConnection()) {
connection.setAutoCommit(false);
@ -262,9 +292,8 @@ public class StorageHelper {
logger.debug("Found {} of the known-unknowns remotely.", remote.size());
processKnownRecords(connection, remote);
account.getUnknownStorageIdStore()
.deleteUnknownStorageIds(connection, remote.stream().map(SignalStorageRecord::getId).toList());
processKnownRecords(connection, remote, identityConflictsPendingRepair);
account.getUnknownStorageIdStore().deleteUnknownStorageIds(connection, knownUnknownIds);
}
connection.commit();
} catch (SQLException e) {
@ -275,7 +304,9 @@ public class StorageHelper {
private boolean writeToStorage(
final StorageKey storageKey,
final SignalStorageManifest remoteManifest,
final boolean needsForcePush
final boolean needsForcePush,
final boolean fetchedRemoteManifest,
final Set<StorageId> identityConflictsPendingRepair
) throws IOException, RetryLaterException {
final WriteOperationResult remoteWriteOperation;
try (final var connection = account.getAccountDatabase().getConnection()) {
@ -285,10 +316,8 @@ public class StorageHelper {
var idDifference = findIdDifference(remoteManifest.storageIds, localStorageIds);
logger.debug("ID Difference :: {}", idDifference);
final var unknownOnlyLocal = idDifference.localOnlyIds()
.stream()
.filter(id -> !KNOWN_TYPES.contains(id.getType()))
.toList();
final var unknownStorageIds = account.getUnknownStorageIdStore().getUnknownStorageIds(connection);
final var unknownOnlyLocal = findUnknownOnlyLocalStorageIds(idDifference.localOnlyIds(), unknownStorageIds);
if (!unknownOnlyLocal.isEmpty()) {
logger.debug("Storage ids with unknown type: {} to delete", unknownOnlyLocal.size());
@ -313,6 +342,28 @@ public class StorageHelper {
if (remoteWriteOperation.isEmpty()) {
logger.debug("No remote writes needed. Still at version: {}", remoteManifest.version);
storageSyncLoopDetector.onConverged();
return false;
}
final var onlyIdentityConflictsPendingRepair = containsOnlyIdentityConflictsPendingRepair(remoteWriteOperation,
identityConflictsPendingRepair);
if (onlyIdentityConflictsPendingRepair) {
logger.warn(
"Deferring remote write until the profile fetch says whose identity key is correct. WriteOperationResult :: {}",
remoteWriteOperation);
return false;
}
final var loopCheck = storageSyncLoopDetector.onWriteAttempt(remoteWriteOperation,
fetchedRemoteManifest,
false);
if (loopCheck instanceof StorageSyncLoopDetector.Decision.Denied denied) {
logger.warn(
"Skipping remote write, another device is likely undoing it. Cause: {}, level: {}. WriteOperationResult :: {}",
denied.cause(),
denied.level(),
remoteWriteOperation);
return false;
}
@ -331,11 +382,18 @@ public class StorageHelper {
remoteWriteOperation.deletes());
switch (result) {
case WriteStorageRecordsResult.ConflictError ignored -> {
storageSyncLoopDetector.onWriteFailed();
logger.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
throw new RetryLaterException();
}
case WriteStorageRecordsResult.NetworkError networkError -> throw networkError.getException();
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> throw statusCodeError.getException();
case WriteStorageRecordsResult.NetworkError networkError -> {
storageSyncLoopDetector.onWriteFailed();
throw networkError.getException();
}
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> {
storageSyncLoopDetector.onWriteFailed();
throw statusCodeError.getException();
}
case WriteStorageRecordsResult.Success ignored -> {
logger.debug("Saved new manifest. Now at version: {}", remoteWriteOperation.manifest().version);
storeManifestLocally(remoteWriteOperation.manifest());
@ -345,6 +403,22 @@ public class StorageHelper {
}
}
static List<StorageId> findUnknownOnlyLocalStorageIds(
final List<StorageId> localOnlyStorageIds,
final Set<StorageId> unknownStorageIds
) {
return localOnlyStorageIds.stream().filter(unknownStorageIds::contains).toList();
}
static boolean containsOnlyIdentityConflictsPendingRepair(
final WriteOperationResult writeOperation,
final Set<StorageId> identityConflictsPendingRepair
) {
return writeOperation.deletes().isEmpty() && !writeOperation.inserts().isEmpty() && writeOperation.inserts()
.stream()
.allMatch(record -> identityConflictsPendingRepair.contains(record.getId()));
}
private void forcePushToStorage(
final StorageKey storageServiceKey
) throws IOException, RetryLaterException {
@ -361,6 +435,7 @@ public class StorageHelper {
final Map<RecipientId, StorageId> newContactStorageIds;
final Map<GroupIdV1, StorageId> newGroupV1StorageIds;
final Map<GroupIdV2, StorageId> newGroupV2StorageIds;
final Map<StickerPackId, StorageId> newStickerPackStorageIds;
try (final var connection = account.getAccountDatabase().getConnection()) {
connection.setAutoCommit(false);
@ -407,6 +482,19 @@ public class StorageHelper {
new StorageRecord.Builder().groupV2(record).build()));
}
final var stickerPacks = account.getStickerStore()
.getStickerPacks(connection)
.stream()
.filter(pack -> pack.storageId() != null)
.toList();
newStickerPackStorageIds = generateStickerPackStorageIds(stickerPacks);
for (final var stickerPack : stickerPacks) {
final var storageId = newStickerPackStorageIds.get(stickerPack.packId());
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
newStorageRecords.add(new SignalStorageRecord(storageId,
new StorageRecord.Builder().stickerPack(record).build()));
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed to sync remote storage", e);
@ -457,6 +545,7 @@ public class StorageHelper {
connection.setAutoCommit(false);
account.getRecipientStore().updateStorageIds(connection, newContactStorageIds);
account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds);
account.getStickerStore().updateStorageIds(connection, newStickerPackStorageIds);
// delete all unknown storage ids
account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection);
@ -480,13 +569,21 @@ public class StorageHelper {
private Map<GroupIdV1, StorageId> generateGroupV1StorageIds(List<GroupIdV1> groupIds) {
return groupIds.stream()
.collect(Collectors.toMap(recipientId -> recipientId,
recipientId -> StorageId.forGroupV1(KeyUtils.createRawStorageId())));
_ -> StorageId.forGroupV1(KeyUtils.createRawStorageId())));
}
private Map<GroupIdV2, StorageId> generateGroupV2StorageIds(List<GroupIdV2> groupIds) {
return groupIds.stream()
.collect(Collectors.toMap(recipientId -> recipientId,
recipientId -> StorageId.forGroupV2(KeyUtils.createRawStorageId())));
_ -> StorageId.forGroupV2(KeyUtils.createRawStorageId())));
}
private Map<StickerPackId, StorageId> generateStickerPackStorageIds(
final List<StickerPack> stickerPacks
) {
return stickerPacks.stream()
.collect(Collectors.toMap(stickerPack -> stickerPack.packId(),
_ -> StorageId.forStickerPack(KeyUtils.createRawStorageId())));
}
private void storeManifestLocally(
@ -504,7 +601,7 @@ public class StorageHelper {
final var result = dependencies.getStorageServiceRepository()
.readStorageRecords(storageKey, manifest.recordIkm, storageIds);
return switch (result) {
case StorageServiceRepository.StorageRecordResult.DecryptionError decryptionError -> {
case StorageServiceService.StorageRecordResult.DecryptionError decryptionError -> {
if (decryptionError.getException() instanceof InvalidKeyException) {
logger.warn("Failed to read storage records, ignoring.");
yield List.of();
@ -514,11 +611,11 @@ public class StorageHelper {
throw new IOException(decryptionError.getException());
}
}
case StorageServiceRepository.StorageRecordResult.NetworkError networkError ->
case StorageServiceService.StorageRecordResult.NetworkError networkError ->
throw networkError.getException();
case StorageServiceRepository.StorageRecordResult.StatusCodeError statusCodeError ->
case StorageServiceService.StorageRecordResult.StatusCodeError statusCodeError ->
throw statusCodeError.getException();
case StorageServiceRepository.StorageRecordResult.Success success -> success.getRecords();
case StorageServiceService.StorageRecordResult.Success success -> success.getRecords();
default -> throw new IllegalStateException("Unexpected value: " + result);
};
}
@ -528,6 +625,7 @@ public class StorageHelper {
storageIds.addAll(account.getUnknownStorageIdStore().getUnknownStorageIds(connection));
storageIds.addAll(account.getGroupStore().getStorageIds(connection));
storageIds.addAll(account.getRecipientStore().getStorageIds(connection));
storageIds.addAll(account.getStickerStore().getStorageIds(connection));
storageIds.add(account.getRecipientStore().getSelfStorageId(connection));
return storageIds;
}
@ -576,6 +674,14 @@ public class StorageHelper {
account.getUsernameLink());
yield new SignalStorageRecord(storageId, new StorageRecord.Builder().account(record).build());
}
case ManifestRecord.Identifier.Type.STICKER_PACK -> {
final var stickerPack = account.getStickerStore().getStickerPack(connection, storageId);
if (stickerPack == null) {
throw new AssertionError("Missing local sticker pack model for storage id: " + storageId);
}
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build());
}
case null, default -> {
throw new AssertionError("Got unknown local storage record type: " + storageId);
}
@ -630,16 +736,29 @@ public class StorageHelper {
return new IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch);
}
private List<StorageId> processKnownRecords(
private Pair<List<StorageId>, List<StorageId>> processKnownRecords(
final Connection connection,
List<SignalStorageRecord> records
List<SignalStorageRecord> records,
final Set<StorageId> identityConflictsPendingRepair
) throws SQLException {
final var unknownRecords = new ArrayList<StorageId>();
final var processedRecords = new ArrayList<StorageId>();
final var accountRecordProcessor = new AccountRecordProcessor(account, connection, context.getJobExecutor());
final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
final var groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection);
final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection);
final var contactRecordProcessor = new ContactRecordProcessor(account,
connection,
context.getJobExecutor(),
identityConflictsPendingRepair);
final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection);
final var contactRecords = records.stream()
.filter(record -> record.getProto().contact != null)
.map(record -> StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
record.getId()))
.toList();
contactRecordProcessor.prepare(contactRecords);
for (final var record : records) {
if (record.getProto().account != null) {
@ -658,12 +777,21 @@ public class StorageHelper {
logger.debug("Reading record {} of type contact", record.getId());
contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
record.getId()));
} else if (record.getProto().stickerPack != null) {
logger.debug("Reading record {} of type stickerPack", record.getId());
stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack,
record.getId()));
} else {
unknownRecords.add(record.getId());
}
}
processedRecords.addAll(accountRecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(groupV1RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(stickerPackRecordProcessor.getUpdatedStorageIds());
return unknownRecords;
return new Pair<>(unknownRecords, processedRecords);
}
/**

View File

@ -38,6 +38,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOper
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.SyncMessage;
import java.io.FileInputStream;
@ -131,11 +132,14 @@ public class SyncHelper {
if (groupsFile.exists() && groupsFile.length() > 0) {
try (var groupsFileStream = new FileInputStream(groupsFile)) {
final var uploadSpec = context.getDependencies().getMessageSender().getResumableUploadSpec();
final var streamDetails = new StreamDetails(groupsFileStream,
MimeUtils.OCTET_STREAM,
groupsFile.length());
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
var attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(groupsFileStream)
.withContentType(MimeUtils.OCTET_STREAM)
.withLength(groupsFile.length())
.withStream(streamDetails.getStream())
.withContentType(streamDetails.getContentType())
.withLength(streamDetails.getLength())
.withResumableUploadSpec(uploadSpec)
.build();
@ -156,7 +160,7 @@ public class SyncHelper {
try {
try (OutputStream fos = new FileOutputStream(contactsFile)) {
var out = new DeviceContactsOutputStream(fos, true, true);
var out = new DeviceContactsOutputStream(fos);
for (var contactPair : account.getContactStore().getContacts()) {
final var recipientId = contactPair.first();
final var contact = contactPair.second();
@ -190,11 +194,14 @@ public class SyncHelper {
if (contactsFile.exists() && contactsFile.length() > 0) {
try (var contactsFileStream = new FileInputStream(contactsFile)) {
final var uploadSpec = context.getDependencies().getMessageSender().getResumableUploadSpec();
final var streamDetails = new StreamDetails(contactsFileStream,
MimeUtils.OCTET_STREAM,
contactsFile.length());
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
var attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(contactsFileStream)
.withContentType(MimeUtils.OCTET_STREAM)
.withLength(contactsFile.length())
.withStream(streamDetails.getStream())
.withContentType(streamDetails.getContentType())
.withLength(streamDetails.getLength())
.withResumableUploadSpec(uploadSpec)
.build();
@ -230,18 +237,19 @@ public class SyncHelper {
final var address = account.getRecipientAddressResolver().resolveRecipientAddress(record.first());
if (address.aci().isPresent() || address.number().isPresent()) {
addresses.add(new BlockedListMessage.Individual(address.aci().orElse(null),
address.number().orElse(null)));
address.number().orElse(null),
record.second().blockedAt()));
}
}
}
var groupIds = new ArrayList<byte[]>();
var groups = new ArrayList<BlockedListMessage.Group>();
for (var record : account.getGroupStore().getGroups()) {
if (record.isBlocked()) {
groupIds.add(record.getGroupId().serialize());
groups.add(new BlockedListMessage.Group(record.getGroupId().serialize(), record.getBlockedAt()));
}
}
return context.getSendHelper()
.sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groupIds)));
.sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groups)));
}
public SendMessageResult sendVerifiedMessage(

View File

@ -83,6 +83,7 @@ import org.asamk.signal.manager.storage.AttachmentStore;
import org.asamk.signal.manager.storage.AvatarStore;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.identities.IdentityInfo;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
@ -97,16 +98,23 @@ import org.asamk.signal.manager.util.StickerUtils;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.Base64;
import org.signal.core.util.Hex;
import org.signal.core.util.crypto.DeviceName;
import org.signal.core.util.crypto.DeviceNameCipher;
import org.signal.libsignal.net.LinkedDevice;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.NoSessionException;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
@ -114,11 +122,8 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage;
import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.util.Util;
@ -155,6 +160,7 @@ import okio.Utf8;
import static org.asamk.signal.manager.config.ServiceConfig.MAX_MESSAGE_SIZE_BYTES;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
import static org.signal.core.util.StringExtensionsKt.splitByByteLength;
public class ManagerImpl implements Manager {
@ -190,6 +196,7 @@ public class ManagerImpl implements Manager {
userAgent,
account.getCredentialsProvider(),
account.getSignalServiceDataStore(),
account.getDeviceId(),
executor,
sessionLock);
final var avatarStore = new AvatarStore(pathConfig.avatarsPath());
@ -243,6 +250,11 @@ public class ManagerImpl implements Manager {
return account.getNumber();
}
@Override
public String getSelfACI() {
return account.getAci().toString();
}
public void checkAccountState() throws IOException {
context.getAccountHelper().checkAccountState();
final var lastRecipientsRefresh = account.getLastRecipientsRefresh();
@ -278,7 +290,7 @@ public class ManagerImpl implements Manager {
registeredUsers = context.getRecipientHelper().getRegisteredUsers(canonicalizedNumbersSet);
} catch (CdsiResourceExhaustedException e) {
logger.debug("CDSI resource exhausted: {}", e.getMessage());
throw new RateLimitException(System.currentTimeMillis() + e.getRetryAfterSeconds() * 1000L);
throw new RateLimitException(e.getRetryAfterSeconds() * 1000L);
}
return numbers.stream().collect(Collectors.toMap(n -> n, n -> {
@ -472,23 +484,26 @@ public class ManagerImpl implements Manager {
@Override
public List<Device> getLinkedDevices() throws IOException {
var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
final List<LinkedDevice> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
.getDevices(cont));
account.setMultiDevice(devices.size() > 1);
var identityKey = account.getAciIdentityKeyPair().getPrivateKey();
var identityKey = account.getAciIdentityKeyPair();
return devices.stream().map(d -> {
String deviceName = d.getName();
if (deviceName != null) {
String deviceName = null;
if (d.getEncryptedName() != null && d.getEncryptedName().length > 0) {
try {
deviceName = DeviceNameUtil.decryptDeviceName(deviceName, identityKey);
} catch (IOException e) {
deviceName = new String(DeviceNameCipher.decryptDeviceName(DeviceName.ADAPTER.decode(d.getEncryptedName()),
identityKey), StandardCharsets.UTF_8);
} catch (Exception e) {
logger.debug("Failed to decrypt device name, maybe plain text?", e);
deviceName = new String(d.getEncryptedName(), StandardCharsets.UTF_8);
}
}
final var createdAt = getPlaintextCreatedAt(d);
return new Device(d.getId(),
deviceName,
createdAt == null ? 0 : createdAt,
d.getLastSeen(),
d.getLastSeen().toEpochMilli(),
d.getId() == account.getDeviceId());
}).toList();
}
@ -508,7 +523,7 @@ public class ManagerImpl implements Manager {
}
}
private Long getPlaintextCreatedAt(DeviceInfo d) {
private Long getPlaintextCreatedAt(LinkedDevice d) {
final var DECRYPTION_INFO = "deviceCreatedAt";
var identityKey = account.getAciIdentityKeyPair().getPrivateKey();
try {
@ -516,8 +531,9 @@ public class ManagerImpl implements Manager {
var associatedData = new ByteArrayOutputStream();
associatedData.write(d.getId());
associatedData.write(ByteBuffer.allocate(4).putInt(d.getRegistrationId()).array());
var createdAtPlaintext = identityKey.open(Base64.decode(d.getCreatedAtCiphertext()
.getBytes(StandardCharsets.UTF_8)), DECRYPTION_INFO, associatedData.toByteArray());
var createdAtPlaintext = identityKey.open(d.getCreatedAtCiphertext(),
DECRYPTION_INFO,
associatedData.toByteArray());
return ByteBuffer.wrap(createdAtPlaintext).getLong();
} catch (Exception e) {
logger.warn("Failed while reading the protobuf.", e);
@ -594,6 +610,11 @@ public class ManagerImpl implements Manager {
context.getGroupHelper().deleteGroup(groupId);
}
@Override
public SendGroupMessageResults terminateGroup(GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException {
return context.getGroupHelper().terminateGroup(groupId);
}
@Override
public Pair<GroupId, SendGroupMessageResults> createGroup(
String name,
@ -782,13 +803,20 @@ public class ManagerImpl implements Manager {
) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(sender);
final var result = context.getSendHelper().sendReceiptMessage(receiptMessage, recipientId);
List<SendMessageResult> results;
if (receiptMessage.isDeliveryReceipt() || !Boolean.FALSE.equals(account.getConfigurationStore()
.getReadReceipts())) {
final var result = context.getSendHelper().sendReceiptMessage(receiptMessage, recipientId);
results = List.of(toSendMessageResult(result));
} else {
results = List.of();
}
final var aci = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId).aci();
if (aci.isPresent()) {
context.getSyncHelper().sendSyncReceiptMessage(aci.get(), receiptMessage);
}
return new SendMessageResults(timestamp, Map.of(sender, List.of(toSendMessageResult(result))));
return new SendMessageResults(timestamp, Map.of(sender, results));
} catch (UnregisteredRecipientException e) {
return new SendMessageResults(timestamp,
Map.of(sender, List.of(SendMessageResult.unregisteredFailure(sender.toPartialRecipientAddress()))));
@ -822,6 +850,98 @@ public class ManagerImpl implements Manager {
return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp), message.urgent());
}
@Override
public SendMessageResults sendStory(
String attachment,
boolean allowsReplies,
Optional<GroupId> groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException {
final var file = new File(attachment);
final var mimeType = MimeUtils.getFileMimeType(file);
if (mimeType.isEmpty() || (!mimeType.get().startsWith("image/") && !mimeType.get().startsWith("video/"))) {
throw new AttachmentInvalidException(attachment,
new IOException("Stories only support image and video attachments"));
}
if (groupId.isPresent()) {
return sendGroupStory(attachment, allowsReplies, groupId.get());
}
final var recipients = account.getRecipientStore()
.getRecipients(true, Optional.of(false), Set.of(), Optional.empty());
final var recipientIds = recipients.stream()
.filter(r -> !r.getRecipientId().equals(account.getSelfRecipientId()))
.filter(r -> r.getContact() != null && !r.getContact().hideStory())
.map(r -> r.getRecipientId())
.collect(Collectors.toSet());
if (recipientIds.isEmpty()) {
throw new IOException("No eligible contacts found for story delivery");
}
final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(),
null,
uploadedAttachment,
allowsReplies,
List.of());
final var timestamp = getNextMessageTimestamp();
final var sendResults = context.getSendHelper()
.sendStoryMessage(storyMessage, timestamp, recipientIds, allowsReplies);
final var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
for (final var sendResult : sendResults) {
final var result = toSendMessageResult(sendResult);
results.put(RecipientIdentifier.Single.fromAddress(result.address()), List.of(result));
}
return new SendMessageResults(timestamp, results);
}
private SendMessageResults sendGroupStory(
String attachment,
boolean allowsReplies,
GroupId groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException {
final var groupInfo = context.getGroupHelper().getGroup(groupId);
if (groupInfo == null) {
throw new GroupNotFoundException(groupId);
}
if (!groupInfo.isMember(account.getSelfRecipientId())) {
throw new NotAGroupMemberException(groupId, groupInfo.getTitle());
}
if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) {
throw new IOException("Stories are only supported for V2 groups");
}
if (groupInfoV2.isTerminated()) {
// Other clients drop messages sent to a terminated group.
throw new IOException("Cannot send a story to a group that has been terminated");
}
final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
.withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().revision)
.build();
final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(),
groupContext,
uploadedAttachment,
allowsReplies,
List.of());
final var timestamp = getNextMessageTimestamp();
final var sendResults = context.getSendHelper()
.sendGroupStoryMessage(storyMessage, timestamp, groupInfoV2, allowsReplies);
final var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
for (final var sendResult : sendResults) {
final var result = toSendMessageResult(sendResult);
results.put(RecipientIdentifier.Single.fromAddress(result.address()), List.of(result));
}
return new SendMessageResults(timestamp, results);
}
private void applyMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final Message message
@ -833,10 +953,10 @@ public class ManagerImpl implements Manager {
final var remainder = result.getSecond();
if (remainder != null) {
final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
MimeUtils.LONG_TEXT,
messageBytes.length);
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
final var textAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(),
uploadSpec);
@ -850,7 +970,10 @@ public class ManagerImpl implements Manager {
}
if (!message.attachments().isEmpty()) {
final var uploadedAttachments = context.getAttachmentHelper()
.uploadAttachments(message.attachments(), message.voiceNote());
.uploadAttachments(message.attachments(),
message.attachmentDimensions(),
message.attachmentBlurHashes(),
message.voiceNote());
if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments);
@ -904,7 +1027,7 @@ public class ManagerImpl implements Manager {
if (streamDetails == null) {
throw new InvalidStickerException("Missing local sticker file");
}
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(),
uploadSpec);
@ -1088,30 +1211,26 @@ public class ManagerImpl implements Manager {
}
@Override
public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException {
var messageBuilder = SignalServiceDataMessage.newBuilder().asEndSessionMessage();
try {
return sendMessage(messageBuilder,
recipients.stream().map(RecipientIdentifier.class::cast).collect(Collectors.toSet()),
false);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new AssertionError(e);
} finally {
for (var recipient : recipients) {
final RecipientId recipientId;
try {
recipientId = context.getRecipientHelper().resolveRecipient(recipient);
} catch (UnregisteredRecipientException e) {
continue;
}
final var serviceId = context.getAccount()
.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId)
.serviceId();
if (serviceId.isPresent()) {
account.getAccountData(ServiceIdType.ACI).getSessionStore().deleteAllSessions(serviceId.get());
}
public void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException {
for (var recipient : recipients) {
final RecipientId recipientId;
try {
recipientId = context.getRecipientHelper().resolveRecipient(recipient);
} catch (UnregisteredRecipientException e) {
continue;
}
final var recipientAddress = context.getAccount()
.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId);
final var aciSessionStore = account.getAccountData(ServiceIdType.ACI).getSessionStore();
final var pniSessionStore = account.getAccountData(ServiceIdType.PNI).getSessionStore();
if (recipientAddress.aci().isPresent()) {
aciSessionStore.archiveSessions(recipientAddress.aci().get());
pniSessionStore.archiveSessions(recipientAddress.aci().get());
}
if (recipientAddress.pni().isPresent()) {
aciSessionStore.archiveSessions(recipientAddress.pni().get());
pniSessionStore.archiveSessions(recipientAddress.pni().get());
}
}
}
@ -1596,8 +1715,15 @@ public class ManagerImpl implements Manager {
}
final var contact = account.getContactStore().getContact(recipientId);
if (contact != null && !Util.isEmpty(contact.getName())) {
return contact.getName();
if (contact != null) {
final var nickname = contact.getDisplayNickname();
if (!Util.isEmpty(nickname)) {
return nickname;
}
if (!Util.isEmpty(contact.getName())) {
return contact.getName();
}
}
final var profile = context.getProfileHelper().getRecipientProfile(recipientId);
@ -1818,8 +1944,10 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forOffer(offerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
}
}
@ -1835,8 +1963,10 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
}
}
@ -1852,8 +1982,10 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
}
}
@ -1876,8 +2008,10 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
}
}
@ -1892,8 +2026,10 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
}
}

View File

@ -7,6 +7,7 @@ import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.SignalAccountFiles;
import org.asamk.signal.manager.api.AccountCheckException;
import org.asamk.signal.manager.api.NotRegisteredException;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -38,13 +39,6 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
managers.forEach(m -> m.addClosedListener(() -> this.removeManager(m)));
}
@Override
public List<String> getAccountNumbers() {
synchronized (managers) {
return managers.stream().map(Manager::getSelfNumber).toList();
}
}
@Override
public List<Manager> getManagers() {
synchronized (managers) {
@ -95,23 +89,47 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
}
@Override
public Manager getManager(final String number) {
public Manager getManager(final String identifier) {
synchronized (managers) {
final var manager = managers.stream()
.filter(m -> m.getSelfNumber().equals(number))
.findFirst()
.orElse(null);
if (manager != null) {
return manager;
}
try {
final var newManager = signalAccountFiles.initManager(number);
managers.add(newManager);
return newManager;
} catch (IOException | NotRegisteredException | AccountCheckException e) {
logger.warn("Failed to load new manager", e);
return null;
if (UuidUtil.INSTANCE.isUuid(identifier)) {
// Check if UUID corresponds to an already-loaded manager
final var existing = managers.stream()
.filter(m -> m.getSelfACI().equals(identifier))
.findFirst()
.orElse(null);
if (existing != null) {
logger.debug("Found already loaded manager for ACI: {}", identifier);
return existing;
}
// Load by ACI
try {
final var newManager = signalAccountFiles.initManagerByAci(identifier);
managers.add(newManager);
return newManager;
} catch (NotRegisteredException e) {
logger.debug("Manager not found by ACI: {}", identifier);
} catch (IOException | IllegalArgumentException | AccountCheckException e) {
logger.warn("Failed to load new manager by ACI: {}", identifier, e);
}
} else {
// Phone number check already loaded managers
var existing = managers.stream()
.filter(m -> identifier.equals(m.getSelfNumber()))
.findFirst()
.orElse(null);
if (existing != null) {
return existing;
}
// Load by phone number
try {
final var newManager = signalAccountFiles.initManagerByNumber(identifier);
managers.add(newManager);
return newManager;
} catch (NotRegisteredException | IOException | IllegalArgumentException | AccountCheckException e) {
logger.warn("Failed to load manager by number: {}", identifier, e);
}
}
return null;
}
}

View File

@ -19,34 +19,59 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.UserAlreadyExistsException;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.accounts.AccountsStore;
import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.models.AccountEntropyPool;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.models.backup.MediaRootBackupKey;
import org.signal.core.util.crypto.DeviceNameCipher;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.api.RegistrationApiV2.LinkDeviceResponse;
import org.signal.network.api.RegistrationApiV2.RegisterAsLinkedDeviceError;
import org.signal.network.rest.SignalRestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.PreKeyCollection;
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.registration.ProvisioningApi;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.ProvisioningSocket;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher;
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
import java.io.Closeable;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
import kotlin.ResultKt;
import kotlin.Unit;
import kotlin.coroutines.Continuation;
import kotlin.coroutines.EmptyCoroutineContext;
import kotlin.coroutines.intrinsics.IntrinsicsKt;
import kotlin.jvm.functions.Function3;
import kotlinx.coroutines.BuildersKt;
import kotlinx.coroutines.CoroutineScope;
public class ProvisioningManagerImpl implements ProvisioningManager {
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
private static final Logger logger = LoggerFactory.getLogger(ProvisioningManagerImpl.class);
@ -56,9 +81,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
private final Consumer<Manager> newManagerListener;
private final AccountsStore accountsStore;
private final ProvisioningApi provisioningApi;
private final IdentityKeyPair tempIdentityKey;
private final String password;
private final CompletableFuture<String> urlFuture = new CompletableFuture<>();
private final CompletableFuture<SecondaryProvisioningCipher.ProvisioningDecryptResult<ProvisionMessage>> messageFuture = new CompletableFuture<>();
private final Closeable socketHandle;
public ProvisioningManagerImpl(
PathConfig pathConfig,
@ -73,46 +99,72 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
this.newManagerListener = newManagerListener;
this.accountsStore = accountsStore;
tempIdentityKey = KeyUtils.generateIdentityKeyPair();
final IdentityKeyPair tempIdentityKey = KeyUtils.generateIdentityKeyPair();
password = KeyUtils.createPassword();
final var credentialsProvider = new DynamicCredentialsProvider(null,
null,
null,
password,
SignalServiceAddress.DEFAULT_DEVICE_ID);
final var pushServiceSocket = new PushServiceSocket(serviceEnvironmentConfig.signalServiceConfiguration(),
credentialsProvider,
userAgent,
ServiceConfig.AUTOMATIC_NETWORK_RETRY);
final var provisioningSocket = new ProvisioningSocket(serviceEnvironmentConfig.signalServiceConfiguration(),
userAgent);
this.provisioningApi = new ProvisioningApi(pushServiceSocket, provisioningSocket, credentialsProvider);
socketHandle = ProvisioningSocket.Companion.start(new ProvisioningSocket.Mode.Link(false),
tempIdentityKey,
serviceEnvironmentConfig.signalServiceConfiguration(),
(id, t) -> {
urlFuture.completeExceptionally(t);
messageFuture.completeExceptionally(t);
},
new ProvisioningBlock());
}
@Override
public URI getDeviceLinkUri() throws TimeoutException, IOException {
var deviceUuid = provisioningApi.getNewDeviceUuid();
return new DeviceLinkUrl(deviceUuid, tempIdentityKey.getPublicKey().getPublicKey()).createDeviceLinkUri();
try {
var url = urlFuture.get(30, TimeUnit.SECONDS);
// Mode.Link(false) does not advertise any capabilities itself.
return new URI(url + "&capabilities=nopni");
} catch (java.util.concurrent.TimeoutException e) {
throw new TimeoutException("Timed out waiting for provisioning URL");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for provisioning URL", e);
} catch (ExecutionException e) {
throw new IOException("Failed to get provisioning URL", e.getCause());
} catch (URISyntaxException e) {
throw new IOException("Invalid provisioning URL", e);
}
}
@Override
public String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException {
var ret = provisioningApi.getNewDeviceRegistration(tempIdentityKey);
var number = ret.getNumber();
var aci = ret.getAci();
var pni = ret.getPni();
SecondaryProvisioningCipher.ProvisioningDecryptResult<ProvisionMessage> decryptResult;
try {
decryptResult = messageFuture.get(120, TimeUnit.SECONDS);
} catch (java.util.concurrent.TimeoutException e) {
throw new TimeoutException("Timed out waiting for provisioning message");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for provisioning message", e);
} catch (ExecutionException e) {
throw new IOException("Failed to receive provisioning message", e.getCause());
}
logger.info("Received link information from {}, linking in progress ...", number);
if (!(decryptResult instanceof SecondaryProvisioningCipher.ProvisioningDecryptResult.Success<ProvisionMessage> success)) {
throw new IOException("Failed to decrypt provisioning message");
}
var msg = success.getMessage();
var number = msg.number;
var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary);
var pni = parsePni(msg);
var identifier = number != null ? number : aci.toString();
logger.info("Received link information from {}, linking in progress ...", identifier);
var accountPath = accountsStore.getPathByAci(aci);
if (accountPath == null) {
if (accountPath == null && number != null) {
accountPath = accountsStore.getPathByNumber(number);
}
final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(),
accountPath);
if (accountExists && !canRelinkExistingAccount(accountPath)) {
throw new UserAlreadyExistsException(number, SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
throw new UserAlreadyExistsException(identifier,
SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
}
if (accountPath == null) {
accountPath = accountsStore.addAccount(number, aci);
@ -120,21 +172,44 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
accountsStore.updateAccount(accountPath, number, aci);
}
final IdentityKeyPair aciIdentity;
final IdentityKeyPair pniIdentity;
final ProfileKey profileKey;
try {
aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()),
new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray()));
pniIdentity = pni == null
? null
: new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
profileKey = msg.profileKey == null
? KeyUtils.createProfileKey()
: new ProfileKey(msg.profileKey.toByteArray());
} catch (Exception e) {
throw new IOException("Invalid key material in provisioning message", e);
}
var encryptedDeviceName = deviceName == null
? null
: DeviceNameUtil.encryptDeviceName(deviceName, ret.getAciIdentity().getPrivateKey());
// Create new account with the synced identity
var profileKey = ret.getProfileKey() == null ? KeyUtils.createProfileKey() : ret.getProfileKey();
: DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), aciIdentity);
var accountEntropyPool = msg.accountEntropyPool == null ? null : new AccountEntropyPool(msg.accountEntropyPool);
var mediaRootBackupKey = msg.mediaRootBackupKey == null
? null
: new MediaRootBackupKey(msg.mediaRootBackupKey.toByteArray());
SignalAccount account = null;
var cleanUpPartialAccountOnFailure = false;
var linkingFinished = false;
try {
if (!accountExists) {
account = SignalAccount.createLinkedAccount(pathConfig.dataPath(),
accountPath,
serviceEnvironmentConfig.type(),
Settings.DEFAULT);
cleanUpPartialAccountOnFailure = true;
} else {
account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, Settings.DEFAULT);
cleanUpPartialAccountOnFailure = false;
}
account.setProvisioningData(number,
@ -142,24 +217,34 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
pni,
password,
encryptedDeviceName,
ret.getAciIdentity(),
ret.getPniIdentity(),
aciIdentity,
pniIdentity,
profileKey,
ret.getAccountEntropyPool(),
ret.getMediaRootBackupKey());
accountEntropyPool,
msg.authCredentialSalt == null ? null : msg.authCredentialSalt.toByteArray(),
mediaRootBackupKey);
account.getConfigurationStore().setReadReceipts(ret.isReadReceipts());
if (msg.readReceipts != null) {
account.getConfigurationStore().setReadReceipts(msg.readReceipts);
}
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
final var pniPreKeys = pni == null
? null
: generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
logger.debug("Finishing new device registration");
var deviceId = provisioningApi.finishNewDeviceRegistration(ret.getProvisioningCode(),
account.getAccountAttributes(null),
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(),
userAgent);
final var registrationApi = new RegistrationApiV2(restClient, false);
final var deviceId = registerLinkedDevice(registrationApi,
account,
msg.provisioningCode,
aciPreKeys,
pniPreKeys);
account.finishLinking(deviceId, aciPreKeys, pniPreKeys);
linkingFinished = true;
ManagerImpl m = null;
try {
@ -190,12 +275,18 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
newManagerListener.accept(m);
m = null;
}
return number;
return identifier;
} finally {
if (m != null) {
m.close();
}
}
} catch (Exception e) {
if (!linkingFinished && cleanUpPartialAccountOnFailure && account != null) {
cleanupPartialAccount(account, accountPath, e);
account = null;
}
throw e;
} finally {
if (account != null) {
account.close();
@ -203,6 +294,98 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
}
}
static PNI parsePni(final ProvisionMessage message) throws IOException {
if (message.number != null) {
return PNI.parseOrThrow(message.pni, message.pniBinary);
}
if (message.pni != null
|| message.pniBinary != null
|| message.pniIdentityKeyPublic != null
|| message.pniIdentityKeyPrivate != null) {
throw new IOException("Provisioning message has PNI material without a phone number");
}
if (message.authCredentialSalt == null || message.authCredentialSalt.size() == 0) {
throw new IOException("Numberless provisioning message is missing the group auth credential salt");
}
return null;
}
static int registerLinkedDevice(
final RegistrationApiV2 registrationApi,
final SignalAccount account,
final String provisioningCode,
final PreKeyCollection aciPreKeys,
final PreKeyCollection pniPreKeys
) throws IOException {
final var attrs = account.getAccountAttributesV2();
final var deviceAttributes = new RegistrationApiV2.DeviceAttributes(attrs.getFetchesMessages(),
attrs.getRegistrationId(),
attrs.getPniRegistrationId(),
attrs.getName(),
attrs.getCapabilities());
try {
final LinkDeviceResponse result = handleResponseExceptionSuspend(cont -> registrationApi.registerAsSecondaryDevice(
account.getAci(),
account.getPassword(),
provisioningCode,
deviceAttributes,
toRegistrationPreKeys(aciPreKeys),
toRegistrationPreKeys(pniPreKeys),
null,
cont));
return result.getDeviceId();
} catch (BadRequestException e) {
throw switch (e.getError()) {
case RegisterAsLinkedDeviceError.IncorrectVerification ignored ->
new AuthorizationFailedException(403, "Device verification failed");
case RegisterAsLinkedDeviceError.MissingCapability ignored ->
new IOException("Linked device is missing a required account capability");
case RegisterAsLinkedDeviceError.MaxLinkedDevices ignored ->
new IOException("Account has reached its linked device limit");
case RegisterAsLinkedDeviceError.InvalidRequest ignored ->
new IOException("Signal rejected the device linking request");
case RegisterAsLinkedDeviceError.RateLimited ignored ->
new IOException("Device linking rate limited; try again later");
default -> new IOException("Unexpected device linking response");
};
}
}
static RegistrationApiV2.PreKeyCollection toRegistrationPreKeys(final PreKeyCollection preKeys) {
return preKeys == null
? null
: new RegistrationApiV2.PreKeyCollection(preKeys.getIdentityKey(),
preKeys.getSignedPreKey(),
preKeys.getLastResortKyberPreKey());
}
@Override
public void close() throws IOException {
socketHandle.close();
}
private void cleanupPartialAccount(final SignalAccount account, final String accountPath, final Exception cause) {
logger.warn("Link attempt failed before registration completed, removing partial account state for {}.",
accountPath,
cause);
try {
account.deleteAccountData();
} catch (IOException cleanupError) {
logger.warn("Failed to delete partial account data for {}: {}",
accountPath,
cleanupError.getMessage(),
cleanupError);
}
try {
accountsStore.removeAccount(accountPath);
} catch (RuntimeException cleanupError) {
logger.warn("Failed to remove partial account entry for {}: {}",
accountPath,
cleanupError.getMessage(),
cleanupError);
}
}
private boolean canRelinkExistingAccount(final String accountPath) throws IOException {
final SignalAccount signalAccount;
try {
@ -216,6 +399,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
}
try (signalAccount) {
if (signalAccount.getDeviceId() <= 0) {
logger.debug("Account has invalid deviceId {}, allowing relink.", signalAccount.getDeviceId());
return true;
}
if (signalAccount.isPrimaryDevice()) {
logger.debug("Account is a primary device.");
return false;
@ -243,4 +430,36 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
return false;
}
}
private class ProvisioningBlock implements Function3<CoroutineScope, ProvisioningSocket<ProvisionMessage>, Continuation<? super Unit>, Object> {
@Override
public Object invoke(
CoroutineScope scope,
ProvisioningSocket<ProvisionMessage> socket,
Continuation<? super Unit> cont
) {
Thread.ofVirtual().start(() -> {
try {
urlFuture.complete(BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(s, c) -> socket.getProvisioningUrl(c)));
messageFuture.complete(BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(s, c) -> socket.getProvisioningMessageDecryptResult(c)));
cont.resumeWith(Unit.INSTANCE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
failBoth(new RuntimeException(e), cont);
} catch (Throwable t) {
failBoth(t, cont);
}
});
return IntrinsicsKt.getCOROUTINE_SUSPENDED();
}
private void failBoth(Throwable t, Continuation<? super Unit> cont) {
urlFuture.completeExceptionally(t);
messageFuture.completeExceptionally(t);
cont.resumeWith(ResultKt.createFailure(t));
}
}
}

View File

@ -18,12 +18,14 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.TotpRequiredException;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.asamk.signal.manager.config.ServiceConfig;
@ -33,10 +35,14 @@ import org.asamk.signal.manager.helper.PinHelper;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.NumberVerificationUtils;
import org.signal.core.models.AccountEntropyPool;
import org.signal.core.models.MasterKey;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.api.RegistrationApiV2.RegisterAccountError;
import org.signal.network.rest.SignalRestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@ -50,15 +56,25 @@ import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
import java.io.IOException;
import java.util.Base64;
import java.util.function.Consumer;
import static org.asamk.signal.manager.internal.ProvisioningManagerImpl.toRegistrationPreKeys;
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
public class RegistrationManagerImpl implements RegistrationManager {
private static final Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class);
private static final class RecoveryRequestFailedException extends IOException {
private RecoveryRequestFailedException(final String message, final Throwable cause) {
super(message, cause);
}
}
private SignalAccount account;
private final PathConfig pathConfig;
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
@ -107,7 +123,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
boolean voiceVerification,
String captcha,
final boolean forceRegister
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException {
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException {
if (account.isRegistered()
&& account.getServiceEnvironment() != null
&& account.getServiceEnvironment() != serviceEnvironmentConfig.type()) {
@ -129,6 +145,13 @@ public class RegistrationManagerImpl implements RegistrationManager {
if (recoveryPassword != null && account.isPrimaryDevice() && attemptReregisterAccount(recoveryPassword)) {
return;
}
if (account.getAci() != null && account.getAccountEntropyPool() != null && attemptRecoverAccount(null)) {
return;
}
if (account.getNumber() == null) {
throw new IOException("Failed to recover account using its ACI and Account Entropy Pool");
}
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
logger.trace("Creating verification session");
@ -184,6 +207,108 @@ public class RegistrationManagerImpl implements RegistrationManager {
finishAccountRegistration(response, pin, masterKey, aciPreKeys, pniPreKeys);
}
@Override
public void registerWithRecoveryKey(
final String recoveryKey,
final boolean forceRegister,
final Integer totp
) throws IOException, RateLimitException, TotpRequiredException {
if (account.getAci() == null) {
throw new IOException("Recovery-key registration requires an ACI account identifier");
}
if (account.isRegistered() && !forceRegister) {
throw new IOException("Account is already registered; use --reregister to register it again");
}
final var accountEntropyPool = AccountEntropyPool.Companion.parseOrNull(recoveryKey);
if (accountEntropyPool == null || !AccountEntropyPool.Companion.isFullyValid(accountEntropyPool.getValue())) {
throw new IOException("Invalid recovery key");
}
recoverAccount(totp, false, accountEntropyPool);
}
private boolean attemptRecoverAccount(
final Integer totp
) throws IOException, RateLimitException, TotpRequiredException {
final var accountEntropyPool = account.getAccountEntropyPool();
try {
recoverAccount(totp, false, accountEntropyPool);
logger.info("Reregistered existing account using its ACI and Account Entropy Pool.");
return true;
} catch (TotpRequiredException | RateLimitException e) {
throw e;
} catch (RecoveryRequestFailedException e) {
logger.debug("Failed to reregister account using its ACI and Account Entropy Pool", e);
return false;
}
}
private void recoverAccount(
final Integer totp,
final boolean includeRegistrationLock,
final AccountEntropyPool accountEntropyPool
) throws IOException, RateLimitException, TotpRequiredException {
if (account.getPniIdentityKeyPair() == null) {
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
}
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
final var masterKey = accountEntropyPool.deriveMasterKey();
final var recoveryPassword = masterKey.deriveRegistrationRecoveryPassword();
final var registrationLock = includeRegistrationLock ? masterKey.deriveRegistrationLock() : null;
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(), userAgent);
final var registrationApi = new RegistrationApiV2(restClient, true);
final RegistrationApiV2.RegisterAccountResponse response;
try {
response = handleResponseExceptionSuspend(cont -> registrationApi.registerAccount(null,
account.getPassword(),
null,
recoveryPassword,
null,
account.getAccountAttributesV2ForRecovery(registrationLock, recoveryPassword),
toRegistrationPreKeys(aciPreKeys),
toRegistrationPreKeys(pniPreKeys),
null,
true,
account.getAci(),
totp,
cont));
} catch (BadRequestException e) {
switch (e.getError()) {
case RegisterAccountError.RegistrationLock ignored -> {
if (includeRegistrationLock) {
throw new RecoveryRequestFailedException("Registration lock recovery failed", e);
}
recoverAccount(totp, true, accountEntropyPool);
return;
}
case RegisterAccountError.TotpMissingOrIncorrect ignored -> throw new TotpRequiredException();
case RegisterAccountError.RegistrationRecoveryPasswordIncorrect ignored ->
throw new RecoveryRequestFailedException("Account key or recovery key is incorrect", e);
case RegisterAccountError.RateLimited ignored -> throw new RateLimitException(null);
case RegisterAccountError.PostQuantumRatchetRequired ignored ->
throw new IOException("signal-cli is too old to register this account", e);
default -> throw new IOException("Signal rejected recovery-key registration", e);
}
}
final var aci = ACI.parseOrThrow(response.getAci());
final var pni = response.getPni() == null ? null : PNI.parseOrThrow(response.getPni());
final var authCredentialSalt = response.getAuthCredentialSalt() == null
? null
: Base64.getDecoder().decode(response.getAuthCredentialSalt());
account.finishRecoveryRegistration(aci,
pni,
response.getE164(),
accountEntropyPool,
authCredentialSalt,
aciPreKeys,
pni == null ? null : pniPreKeys);
accountFileUpdater.updateAccountIdentifiers(response.getE164(), aci);
finishManagerRegistration(response.getStorageCapable());
}
@Override
public void deleteLocalAccountData() throws IOException {
account.deleteAccountData();
@ -231,6 +356,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
userAgent,
account.getCredentialsProvider(),
account.getSignalServiceDataStore(),
0,
null,
new ReentrantSignalSessionLock());
handleResponseException(dependencies.getAccountApi()
@ -290,13 +416,17 @@ public class RegistrationManagerImpl implements RegistrationManager {
account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys);
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
finishManagerRegistration(response.isStorageCapable());
}
private void finishManagerRegistration(final boolean storageCapable) throws IOException {
ManagerImpl m = null;
try {
m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent);
account = null;
m.refreshPreKeys();
if (response.isStorageCapable()) {
if (storageCapable) {
m.syncRemoteStorage();
}
// Set an initial empty profile so user can be added to groups

View File

@ -3,9 +3,22 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.util.Utils;
import org.signal.core.util.UptimeSleepTimer;
import org.signal.libsignal.metadata.certificate.CertificateValidator;
import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.network.api.AccountApiV2;
import org.signal.network.api.AttachmentApi;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
import org.signal.network.api.CertificateApi;
import org.signal.network.api.LinkDeviceApi;
import org.signal.network.api.RateLimitChallengeApi;
import org.signal.network.api.UsernameApi;
import org.signal.network.rest.SignalRestClient;
import org.signal.network.service.CdnService;
import org.signal.network.service.StorageServiceService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@ -14,29 +27,21 @@ import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.account.AccountApi;
import org.whispersystems.signalservice.api.attachment.AttachmentApi;
import org.whispersystems.signalservice.api.calling.CallingApi;
import org.whispersystems.signalservice.api.cds.CdsApi;
import org.whispersystems.signalservice.api.certificate.CertificateApi;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.keys.KeysApi;
import org.whispersystems.signalservice.api.link.LinkDeviceApi;
import org.whispersystems.signalservice.api.keys.PreKeyRepository;
import org.whispersystems.signalservice.api.message.MessageApi;
import org.whispersystems.signalservice.api.profiles.ProfileApi;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.storage.StorageServiceApi;
import org.whispersystems.signalservice.api.storage.StorageServiceRepository;
import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
import org.whispersystems.signalservice.api.username.UsernameApi;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection;
@ -61,6 +66,7 @@ public class SignalDependencies {
private final String userAgent;
private final CredentialsProvider credentialsProvider;
private final SignalServiceDataStore dataStore;
private final int deviceId;
private final ExecutorService executor;
private final SignalSessionLock sessionLock;
@ -68,6 +74,7 @@ public class SignalDependencies {
private SignalServiceAccountManager accountManager;
private AccountApi accountApi;
private AccountApiV2 accountApiV2;
private RateLimitChallengeApi rateLimitChallengeApi;
private CdsApi cdsApi;
private UsernameApi usernameApi;
@ -82,6 +89,11 @@ public class SignalDependencies {
private KeysApi keysApi;
private GroupsV2Operations groupsV2Operations;
private ClientZkOperations clientZkOperations;
private ProfileService profileService;
private ProfileApi profileApi;
private CdnService cdnService;
private PreKeyRepository preKeyRepository;
private SignalRestClient signalRestClient;
private PushServiceSocket pushServiceSocket;
private Network libSignalNetwork;
@ -91,14 +103,13 @@ public class SignalDependencies {
private SignalServiceMessageSender messageSender;
private List<SecureValueRecovery> secureValueRecovery;
private ProfileService profileService;
private ProfileApi profileApi;
SignalDependencies(
final ServiceEnvironmentConfig serviceEnvironmentConfig,
final String userAgent,
final CredentialsProvider credentialsProvider,
final SignalServiceDataStore dataStore,
final int deviceId,
final ExecutorService executor,
final SignalSessionLock sessionLock
) {
@ -106,6 +117,7 @@ public class SignalDependencies {
this.userAgent = userAgent;
this.credentialsProvider = credentialsProvider;
this.dataStore = dataStore;
this.deviceId = deviceId;
this.executor = executor;
this.sessionLock = sessionLock;
}
@ -211,6 +223,11 @@ public class SignalDependencies {
return getOrCreate(() -> accountApi, () -> accountApi = new AccountApi(getAuthenticatedSignalWebSocket()));
}
public AccountApiV2 getAccountApiV2() {
return getOrCreate(() -> accountApiV2,
() -> accountApiV2 = new AccountApiV2(getAuthenticatedSignalWebSocket()));
}
public RateLimitChallengeApi getRateLimitChallengeApi() {
return getOrCreate(() -> rateLimitChallengeApi,
() -> rateLimitChallengeApi = new RateLimitChallengeApi(getAuthenticatedSignalWebSocket()));
@ -243,8 +260,8 @@ public class SignalDependencies {
getPushServiceSocket()));
}
public StorageServiceRepository getStorageServiceRepository() {
return new StorageServiceRepository(getStorageServiceApi());
public StorageServiceService getStorageServiceRepository() {
return new StorageServiceService(getStorageServiceApi());
}
public CertificateApi getCertificateApi() {
@ -301,7 +318,7 @@ public class SignalDependencies {
getLibSignalNetwork(),
credentialsProvider,
allowStories,
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(10));
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(30));
healthMonitor.monitor(authenticatedSignalWebSocket);
});
}
@ -316,7 +333,7 @@ public class SignalDependencies {
getLibSignalNetwork(),
null,
allowStories,
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(10));
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(30));
healthMonitor.monitor(unauthenticatedSignalWebSocket);
});
}
@ -326,12 +343,34 @@ public class SignalDependencies {
() -> messageReceiver = new SignalServiceMessageReceiver(getPushServiceSocket()));
}
private SignalRestClient getSignalRestClient() {
return getOrCreate(() -> signalRestClient,
() -> signalRestClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(),
userAgent,
credentialsProvider,
ServiceConfig.AUTOMATIC_NETWORK_RETRY));
}
public CdnService getCdnService() {
return getOrCreate(() -> cdnService,
() -> cdnService = new CdnService(getSignalRestClient(), getAttachmentApi()));
}
public PreKeyRepository getPreKeyRepository() {
final SignalProtocolAddress localProtocolAddress = credentialsProvider.getAci().toProtocolAddress(deviceId);
return getOrCreate(() -> preKeyRepository,
() -> preKeyRepository = new PreKeyRepository(getKeysApi(),
dataStore.aci(),
localProtocolAddress,
getSessionLock(),
Runnable::run));
}
public SignalServiceMessageSender getMessageSender() {
return getOrCreate(() -> messageSender,
() -> messageSender = new SignalServiceMessageSender(getPushServiceSocket(),
dataStore,
sessionLock,
getAttachmentApi(),
getMessageApi(),
getKeysApi(),
Optional.empty(),
@ -339,8 +378,7 @@ public class SignalDependencies {
ServiceConfig.MAX_ENVELOPE_SIZE,
ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE,
() -> true,
true,
true));
getPreKeyRepository()));
}
public List<SecureValueRecovery> getSecureValueRecovery() {
@ -368,7 +406,10 @@ public class SignalDependencies {
public SignalServiceCipher getCipher(ServiceIdType serviceIdType) {
final var certificateValidator = new CertificateValidator(serviceEnvironmentConfig.unidentifiedSenderTrustRoots());
final var address = new SignalServiceAddress(credentialsProvider.getAci(), credentialsProvider.getE164());
final var serviceId = serviceIdType == ServiceIdType.ACI
? credentialsProvider.getAci()
: credentialsProvider.getPni();
final var address = new SignalServiceAddress(serviceId, credentialsProvider.getE164());
final var deviceId = credentialsProvider.getDeviceId();
return new SignalServiceCipher(address,
deviceId,

View File

@ -1,13 +1,15 @@
package org.asamk.signal.manager.internal;
import org.jetbrains.annotations.NotNull;
import org.signal.core.util.SleepTimer;
import org.signal.network.util.Preconditions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.websocket.HealthMonitor;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import java.time.Duration;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@ -94,6 +96,26 @@ final class SignalWebSocketHealthMonitor implements HealthMonitor {
return needsKeepAlive && webSocket != null && webSocket.shouldSendKeepAlives();
}
@Override
public void onReceivedAlerts(@NotNull final String[] strings, final boolean b) {
if (strings.length == 0) {
return;
}
logger.info("Received alerts: {}", String.join(", ", strings));
}
@Override
public void onServerTimestamp(final long serverTimestamp, final boolean isIdentifiedWebsocket) {
final var skew = skewFrom(serverTimestamp);
if (skew.compareTo(Duration.ofDays(1)) > 0) {
logger.warn("Local clock is off from the server by {}, which exceeds the allowed limit..", skew);
}
}
private Duration skewFrom(long serverTime) {
return Duration.ofMillis(Math.abs(System.currentTimeMillis() - serverTime));
}
/**
* Sends periodic heartbeats/keep-alives over the WebSocket to prevent connection timeouts. If
* the WebSocket fails to get a return heartbeat after [KEEP_ALIVE_TIMEOUT] seconds, it is forced to be recreated.

View File

@ -9,9 +9,15 @@ public class DownloadProfileJob implements Job {
private static final Logger logger = LoggerFactory.getLogger(DownloadProfileJob.class);
private final RecipientAddress address;
private final boolean resolveIdentityKeyConflict;
public DownloadProfileJob(RecipientAddress address) {
this(address, false);
}
public DownloadProfileJob(RecipientAddress address, boolean resolveIdentityKeyConflict) {
this.address = address;
this.resolveIdentityKeyConflict = resolveIdentityKeyConflict;
}
@Override
@ -19,6 +25,9 @@ public class DownloadProfileJob implements Job {
logger.trace("Refreshing profile for {}", address);
final var account = context.getAccount();
final var recipientId = account.getRecipientStore().resolveRecipient(address);
context.getProfileHelper().refreshRecipientProfile(recipientId);
final var refreshed = context.getProfileHelper().refreshRecipientProfile(recipientId);
if (refreshed && resolveIdentityKeyConflict) {
context.getJobExecutor().enqueueJob(new SyncStorageJob());
}
}
}

View File

@ -33,7 +33,7 @@ import java.util.UUID;
public class AccountDatabase extends Database {
private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
private static final long DATABASE_VERSION = 28;
private static final long DATABASE_VERSION = 31;
private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource);
@ -623,6 +623,36 @@ public class AccountDatabase extends Database {
""");
}
}
if (oldVersion < 29) {
logger.debug("Updating database: Adding sticker storage sync columns");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
ALTER TABLE sticker ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sticker ADD COLUMN deleted_timestamp INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sticker ADD COLUMN storage_id BLOB;
ALTER TABLE sticker ADD COLUMN storage_record BLOB;
CREATE UNIQUE INDEX sticker_storage_id_index ON sticker (storage_id);
""");
}
}
if (oldVersion < 30) {
logger.debug("Updating database: Create pni_signature_verified column");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
ALTER TABLE recipient ADD pni_signature_verified INTEGER NOT NULL DEFAULT FALSE;
""");
}
}
if (oldVersion < 31) {
logger.debug("Updating database: Add blocked-at timestamps");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
ALTER TABLE recipient ADD blocked_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE group_v1 ADD blocked_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE group_v2 ADD blocked_at INTEGER NOT NULL DEFAULT 0;
""");
}
}
}
private static void createUuidMappingTable(

View File

@ -44,7 +44,8 @@ public class AttachmentStore {
}
public StreamDetails retrieveAttachment(final String id) throws IOException {
final var attachmentFile = new File(attachmentsPath, id);
final var safeId = sanitizeId(id);
final var attachmentFile = new File(attachmentsPath, safeId);
return Utils.createStreamDetailsFromFile(attachmentFile);
}
@ -61,7 +62,8 @@ public class AttachmentStore {
Optional<String> contentType
) {
final var extension = getAttachmentExtension(filename, contentType);
return new File(attachmentsPath, attachmentId.toString() + extension + ".preview");
final var safe = sanitizeId(attachmentId.toString());
return new File(attachmentsPath, safe + extension + ".preview");
}
private File getAttachmentFile(
@ -70,7 +72,15 @@ public class AttachmentStore {
Optional<String> contentType
) {
final var extension = getAttachmentExtension(filename, contentType);
return new File(attachmentsPath, attachmentId.toString() + extension);
final var safe = sanitizeId(attachmentId.toString());
return new File(attachmentsPath, safe + extension);
}
private static String sanitizeId(final String id) {
if (id == null) {
return "";
}
return id.replaceAll("[^A-Za-z0-9_.-]", "_");
}
private static String getAttachmentExtension(final Optional<String> filename, final Optional<String> contentType) {

View File

@ -71,6 +71,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.protocol.util.KeyHelper;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.api.RegistrationApiV2;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
@ -116,7 +117,7 @@ public class SignalAccount implements Closeable {
private static final Logger logger = LoggerFactory.getLogger(SignalAccount.class);
private static final int MINIMUM_STORAGE_VERSION = 1;
private static final int CURRENT_STORAGE_VERSION = 10;
private static final int CURRENT_STORAGE_VERSION = 11;
private final Object LOCK = new Object();
@ -141,6 +142,7 @@ public class SignalAccount implements Closeable {
private MasterKey pinMasterKey;
private StorageKey storageKey;
private AccountEntropyPool accountEntropyPool;
private byte[] authCredentialSalt;
private MediaRootBackupKey mediaRootBackupKey;
private ProfileKey profileKey;
@ -153,6 +155,10 @@ public class SignalAccount implements Closeable {
private final KeyValueEntry<Long> lastReceiveTimestamp = new KeyValueEntry<>("last-receive-timestamp",
long.class,
0L);
private final KeyValueEntry<Long> lastAppliedPniChangeServerTimestamp = new KeyValueEntry<>(
"last-applied-pni-change-server-timestamp",
long.class,
0L);
private final KeyValueEntry<Boolean> needsToRetryFailedMessages = new KeyValueEntry<>("retry-failed-messages",
Boolean.class,
true);
@ -192,6 +198,10 @@ public class SignalAccount implements Closeable {
this.lock = lock;
}
public File getDataPath() {
return dataPath;
}
public static SignalAccount load(
File dataPath,
String accountPath,
@ -224,6 +234,28 @@ public class SignalAccount implements Closeable {
IdentityKeyPair pniIdentityKey,
ProfileKey profileKey,
final Settings settings
) throws IOException {
return create(dataPath,
accountPath,
number,
null,
serviceEnvironment,
aciIdentityKey,
pniIdentityKey,
profileKey,
settings);
}
public static SignalAccount create(
File dataPath,
String accountPath,
String number,
ACI aci,
ServiceEnvironment serviceEnvironment,
IdentityKeyPair aciIdentityKey,
IdentityKeyPair pniIdentityKey,
ProfileKey profileKey,
final Settings settings
) throws IOException {
IOUtils.createPrivateDirectories(dataPath);
var fileName = getFileName(dataPath, accountPath);
@ -242,6 +274,7 @@ public class SignalAccount implements Closeable {
signalAccount.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
signalAccount.dataPath = dataPath;
signalAccount.aciAccountData.setServiceId(aci);
signalAccount.aciAccountData.setIdentityKeyPair(aciIdentityKey);
signalAccount.pniAccountData.setIdentityKeyPair(pniIdentityKey);
signalAccount.aciAccountData.setLocalRegistrationId(KeyHelper.generateRegistrationId(false));
@ -288,11 +321,12 @@ public class SignalAccount implements Closeable {
final ACI aci,
final PNI pni,
final String password,
final String encryptedDeviceName,
final byte[] encryptedDeviceName,
final IdentityKeyPair aciIdentity,
final IdentityKeyPair pniIdentity,
final ProfileKey profileKey,
final AccountEntropyPool accountEntropyPool,
final byte[] authCredentialSalt,
final MediaRootBackupKey mediaRootBackupKey
) {
this.deviceId = 0;
@ -303,12 +337,13 @@ public class SignalAccount implements Closeable {
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
this.password = password;
this.profileKey = profileKey;
this.encryptedDeviceName = encryptedDeviceName;
this.encryptedDeviceName = org.signal.core.util.Base64.encodeWithoutPadding(encryptedDeviceName);
this.aciAccountData.setIdentityKeyPair(aciIdentity);
this.pniAccountData.setIdentityKeyPair(pniIdentity);
this.registered = false;
this.isMultiDevice = true;
setLastReceiveTimestamp(0L);
setLastAppliedPniChangeServerTimestamp(0L);
if (accountEntropyPool != null) {
this.pinMasterKey = null;
this.accountEntropyPool = accountEntropyPool;
@ -316,6 +351,7 @@ public class SignalAccount implements Closeable {
this.pinMasterKey = null;
this.accountEntropyPool = null;
}
this.authCredentialSalt = authCredentialSalt;
this.mediaRootBackupKey = mediaRootBackupKey;
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null);
@ -338,7 +374,9 @@ public class SignalAccount implements Closeable {
this.registered = true;
this.deviceId = deviceId;
setPreKeys(ServiceIdType.ACI, aciPreKeys);
setPreKeys(ServiceIdType.PNI, pniPreKeys);
if (pniPreKeys != null) {
setPreKeys(ServiceIdType.PNI, pniPreKeys);
}
save();
}
@ -352,6 +390,7 @@ public class SignalAccount implements Closeable {
) {
this.pinMasterKey = masterKey;
this.accountEntropyPool = null;
this.authCredentialSalt = null;
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null);
this.storageKey = null;
@ -364,6 +403,7 @@ public class SignalAccount implements Closeable {
init();
this.registrationLockPin = pin;
setLastReceiveTimestamp(0L);
setLastAppliedPniChangeServerTimestamp(0L);
save();
setPreKeys(ServiceIdType.ACI, aciPreKeys);
@ -378,6 +418,53 @@ public class SignalAccount implements Closeable {
clearSessionId();
}
public void finishRecoveryRegistration(
final ACI aci,
final PNI pni,
final String number,
final AccountEntropyPool accountEntropyPool,
final byte[] authCredentialSalt,
final PreKeyCollection aciPreKeys,
final PreKeyCollection pniPreKeys
) {
this.pinMasterKey = null;
this.accountEntropyPool = accountEntropyPool;
this.authCredentialSalt = authCredentialSalt;
this.number = number;
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null);
this.storageKey = null;
this.encryptedDeviceName = null;
this.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
this.isMultiDevice = false;
this.registered = true;
this.aciAccountData.setServiceId(aci);
this.pniAccountData.setServiceId(pni);
if (pni == null) {
this.pniAccountData.setIdentityKeyPair(null);
}
init();
this.registrationLockPin = null;
setLastReceiveTimestamp(0L);
setLastAppliedPniChangeServerTimestamp(0L);
save();
setPreKeys(ServiceIdType.ACI, aciPreKeys);
if (pni != null && pniPreKeys != null) {
setPreKeys(ServiceIdType.PNI, pniPreKeys);
}
aciAccountData.getSessionStore().archiveAllSessions();
pniAccountData.getSessionStore().archiveAllSessions();
getSenderKeyStore().deleteAll();
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
trustSelfIdentity(ServiceIdType.ACI);
if (pni != null) {
trustSelfIdentity(ServiceIdType.PNI);
}
getKeyValueStore().storeEntry(lastRecipientsRefresh, null);
clearSessionId();
}
public void initDatabase() {
getAccountDatabase();
}
@ -387,7 +474,7 @@ public class SignalAccount implements Closeable {
}
private void migrateLegacyConfigs() {
if (isPrimaryDevice() && getPniIdentityKeyPair() == null) {
if (isPrimaryDevice() && (number != null || getPni() != null) && getPniIdentityKeyPair() == null) {
logger.trace("Migrating legacy parts of account file");
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
}
@ -516,6 +603,9 @@ public class SignalAccount implements Closeable {
if (storage.accountEntropyPool != null) {
accountEntropyPool = new AccountEntropyPool(storage.accountEntropyPool);
}
if (storage.authCredentialSalt != null) {
authCredentialSalt = base64.decode(storage.authCredentialSalt);
}
if (storage.mediaRootBackupKey != null) {
mediaRootBackupKey = new MediaRootBackupKey(base64.decode(storage.mediaRootBackupKey));
}
@ -896,6 +986,7 @@ public class SignalAccount implements Closeable {
0,
false,
contact.blocked,
0,
contact.archived,
false,
false,
@ -1004,6 +1095,7 @@ public class SignalAccount implements Closeable {
pinMasterKey == null ? null : base64.encodeToString(pinMasterKey.serialize()),
storageKey == null ? null : base64.encodeToString(storageKey.serialize()),
accountEntropyPool == null ? null : accountEntropyPool.getValue(),
authCredentialSalt == null ? null : base64.encodeToString(authCredentialSalt),
mediaRootBackupKey == null ? null : base64.encodeToString(mediaRootBackupKey.getValue()),
profileKey == null ? null : base64.encodeToString(profileKey.serialize()),
usernameLink == null ? null : base64.encodeToString(usernameLink.getEntropy()),
@ -1218,6 +1310,11 @@ public class SignalAccount implements Closeable {
return pniAccountData.getSignalServiceAccountDataStore();
}
@Override
public SignalServiceAccountDataStore pniOrNull() {
return getPni() != null ? pniAccountData.getSignalServiceAccountDataStore() : null;
}
@Override
public boolean isMultiDevice() {
return SignalAccount.this.isMultiDevice();
@ -1399,7 +1496,56 @@ public class SignalAccount implements Closeable {
}
public AccountAttributes.Capabilities getAccountCapabilities() {
return getCapabilities(isPrimaryDevice());
return getCapabilities(isPrimaryDevice(), number != null);
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2() {
return getAccountAttributesV2(false, getRegistrationLock());
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2(
final boolean includePniRegistrationId,
final String registrationLock
) {
return getAccountAttributesV2(includePniRegistrationId,
registrationLock,
getRecoveryPassword(),
number == null ? null : isDiscoverableByPhoneNumber());
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2ForRecovery(
final String registrationLock,
final String recoveryPassword
) {
return getAccountAttributesV2(true, registrationLock, recoveryPassword, null);
}
private RegistrationApiV2.AccountAttributes getAccountAttributesV2(
final boolean includePniRegistrationId,
final String registrationLock,
final String recoveryPassword,
final Boolean discoverableByPhoneNumber
) {
final var attributes = getAccountAttributes(null);
final var capabilities = attributes.getCapabilities();
return new RegistrationApiV2.AccountAttributes(attributes.getSignalingKey(),
attributes.getRegistrationId(),
attributes.getVoice(),
attributes.getVideo(),
attributes.getFetchesMessages(),
registrationLock,
attributes.getUnidentifiedAccessKey(),
attributes.getUnrestrictedUnidentifiedAccess(),
discoverableByPhoneNumber,
new RegistrationApiV2.AccountAttributes.Capabilities(capabilities.getStorage(),
capabilities.getVersionedExpirationTimer(),
capabilities.getAttachmentBackfill(),
capabilities.getSpqr(),
capabilities.getUsernameChangeSyncMessage(),
capabilities.getOptionalPhoneNumber()),
attributes.getName(),
includePniRegistrationId || getPni() != null ? attributes.getPniRegistrationId() : null,
recoveryPassword);
}
public ServiceId getAccountId(ServiceIdType serviceIdType) {
@ -1612,6 +1758,10 @@ public class SignalAccount implements Closeable {
return accountEntropyPool;
}
public AccountEntropyPool getAccountEntropyPool() {
return accountEntropyPool;
}
public void setAccountEntropyPool(final AccountEntropyPool accountEntropyPool) {
this.accountEntropyPool = accountEntropyPool;
if (accountEntropyPool != null) {
@ -1638,6 +1788,10 @@ public class SignalAccount implements Closeable {
save();
}
public byte[] getAuthCredentialSalt() {
return authCredentialSalt;
}
public String getRecoveryPassword() {
final var masterKey = getPinBackedMasterKey();
if (masterKey == null) {
@ -1721,7 +1875,7 @@ public class SignalAccount implements Closeable {
}
public boolean isRegistered() {
return registered;
return registered && deviceId > 0;
}
public void setRegistered(final boolean registered) {
@ -1749,6 +1903,14 @@ public class SignalAccount implements Closeable {
getKeyValueStore().storeEntry(lastReceiveTimestamp, value);
}
public long getLastAppliedPniChangeServerTimestamp() {
return getKeyValueStore().getEntry(lastAppliedPniChangeServerTimestamp);
}
public void setLastAppliedPniChangeServerTimestamp(final long value) {
getKeyValueStore().storeEntry(lastAppliedPniChangeServerTimestamp, value);
}
public void setNeedsToRetryFailedMessages(final boolean value) {
getKeyValueStore().storeEntry(needsToRetryFailedMessages, value);
}
@ -1920,7 +2082,8 @@ public class SignalAccount implements Closeable {
getSessionStore(),
getIdentityKeyStore(),
getSenderKeyStore(),
SignalAccount.this::isMultiDevice));
SignalAccount.this::isMultiDevice,
SignalAccount.this::setMultiDevice));
}
public PreKeyStore getPreKeyStore() {
@ -1968,6 +2131,7 @@ public class SignalAccount implements Closeable {
String pinMasterKey,
String storageKey,
String accountEntropyPool,
String authCredentialSalt,
String mediaRootBackupKey,
String profileKey,
String usernameLinkEntropy,

View File

@ -63,11 +63,14 @@ public class AccountsStore {
public synchronized Set<AccountsStorage.Account> getAllAccounts() throws IOException {
return readAccounts().stream()
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
.filter(a -> a.number() != null)
.filter(a -> a.number() != null || a.uuid() != null)
.collect(Collectors.toSet());
}
public synchronized String getPathByNumber(String number) throws IOException {
if (number == null) {
return null;
}
return readAccounts().stream()
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
.filter(a -> number.equals(a.number()))
@ -101,7 +104,7 @@ public class AccountsStore {
if (number != null && number.equals(a.number())) {
return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid());
}
if (aci != null && aci.toString().equals(a.toString())) {
if (aci != null && aci.toString().equals(a.uuid())) {
return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null);
}
@ -188,7 +191,7 @@ public class AccountsStore {
private List<AccountsStorage.Account> readAccounts() throws IOException {
final var pair = openFileChannel(getAccountsFile());
try (final var fileChannel = pair.first(); final var lock = pair.second()) {
final var storage = readAccountsLocked(fileChannel);
var storage = readAccountsLocked(fileChannel);
var accountsVersion = storage.version() == null ? 1 : storage.version();
if (accountsVersion > CURRENT_STORAGE_VERSION) {
@ -197,7 +200,7 @@ public class AccountsStore {
throw new IOException("Accounts file was created by a no longer supported older version: "
+ accountsVersion);
} else if (accountsVersion < CURRENT_STORAGE_VERSION) {
return upgradeAccountsFile(fileChannel, storage, accountsVersion).accounts();
storage = upgradeAccountsFile(fileChannel, storage, accountsVersion);
}
return storage.accounts();
}

View File

@ -58,6 +58,10 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract void setBlocked(boolean blocked);
public abstract long getBlockedAt();
public abstract void setBlockedAt(long blockedAt);
public abstract boolean isProfileSharingEnabled();
public abstract void setProfileSharingEnabled(boolean profileSharingEnabled);
@ -66,6 +70,8 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract boolean isAnnouncementGroup();
public abstract boolean isTerminated();
public abstract GroupPermission getPermissionAddMember();
public abstract GroupPermission getPermissionEditDetails();

View File

@ -24,6 +24,7 @@ public final class GroupInfoV1 extends GroupInfo {
public String color;
public int messageExpirationTime;
public boolean blocked;
private long blockedAt;
public boolean archived;
private byte[] storageRecord;
@ -39,6 +40,7 @@ public final class GroupInfoV1 extends GroupInfo {
final String color,
final int messageExpirationTime,
final boolean blocked,
final long blockedAt,
final boolean archived,
final byte[] storageRecord
) {
@ -49,6 +51,7 @@ public final class GroupInfoV1 extends GroupInfo {
this.color = color;
this.messageExpirationTime = messageExpirationTime;
this.blocked = blocked;
this.blockedAt = blockedAt;
this.archived = archived;
this.storageRecord = storageRecord;
}
@ -91,9 +94,24 @@ public final class GroupInfoV1 extends GroupInfo {
@Override
public void setBlocked(final boolean blocked) {
if (blocked && !this.blocked) {
blockedAt = System.currentTimeMillis();
} else if (!blocked) {
blockedAt = 0;
}
this.blocked = blocked;
}
@Override
public long getBlockedAt() {
return blockedAt;
}
@Override
public void setBlockedAt(final long blockedAt) {
this.blockedAt = blockedAt;
}
@Override
public boolean isProfileSharingEnabled() {
return true;
@ -113,6 +131,11 @@ public final class GroupInfoV1 extends GroupInfo {
return false;
}
@Override
public boolean isTerminated() {
return false;
}
@Override
public GroupPermission getPermissionAddMember() {
return GroupPermission.EVERY_MEMBER;

View File

@ -23,6 +23,7 @@ public final class GroupInfoV2 extends GroupInfo {
private final GroupMasterKey masterKey;
private final DistributionId distributionId;
private boolean blocked;
private long blockedAt;
private boolean profileSharingEnabled;
private DecryptedGroup group;
private byte[] storageRecord;
@ -47,6 +48,7 @@ public final class GroupInfoV2 extends GroupInfo {
final DecryptedGroup group,
final DistributionId distributionId,
final boolean blocked,
final long blockedAt,
final boolean profileSharingEnabled,
final boolean permissionDenied,
final byte[] storageRecord,
@ -57,6 +59,7 @@ public final class GroupInfoV2 extends GroupInfo {
this.group = group;
this.distributionId = distributionId;
this.blocked = blocked;
this.blockedAt = blockedAt;
this.profileSharingEnabled = profileSharingEnabled;
this.permissionDenied = permissionDenied;
this.storageRecord = storageRecord;
@ -186,9 +189,24 @@ public final class GroupInfoV2 extends GroupInfo {
@Override
public void setBlocked(final boolean blocked) {
if (blocked && !this.blocked) {
blockedAt = System.currentTimeMillis();
} else if (!blocked) {
blockedAt = 0;
}
this.blocked = blocked;
}
@Override
public long getBlockedAt() {
return blockedAt;
}
@Override
public void setBlockedAt(final long blockedAt) {
this.blockedAt = blockedAt;
}
@Override
public boolean isProfileSharingEnabled() {
return profileSharingEnabled;
@ -211,6 +229,11 @@ public final class GroupInfoV2 extends GroupInfo {
return this.group != null && this.group.isAnnouncementGroup == EnabledState.ENABLED;
}
@Override
public boolean isTerminated() {
return this.group != null && Boolean.TRUE.equals(this.group.terminated);
}
@Override
public GroupPermission getPermissionAddMember() {
final var accessControl = getAccessControl();

View File

@ -63,6 +63,7 @@ public class GroupStore {
distribution_id BLOB UNIQUE NOT NULL,
endorsement_expiration_time INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
profile_sharing INTEGER NOT NULL DEFAULT FALSE,
permission_denied INTEGER NOT NULL DEFAULT FALSE
) STRICT;
@ -83,6 +84,7 @@ public class GroupStore {
color TEXT,
expiration_time INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT FALSE
) STRICT;
CREATE TABLE group_v1_member (
@ -152,6 +154,7 @@ public class GroupStore {
statement.setBytes(2, groupId.serialize());
final var result = Utils.executeQueryForOptional(statement, Utils::getIdMapper);
if (result.isEmpty()) {
connection.commit();
return;
}
internalId = result.get();
@ -400,6 +403,7 @@ public class GroupStore {
deleteGroup(connection, groupInfoV1.getGroupId());
final var groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, recipientResolver);
groupInfoV2.setBlocked(groupInfoV1.isBlocked());
groupInfoV2.setBlockedAt(groupInfoV1.getBlockedAt());
updateGroup(connection, groupInfoV2);
logger.debug("Locally migrated group {} to group v2, id: {}",
groupInfoV1.getGroupId().toBase64(),
@ -613,9 +617,9 @@ public class GroupStore {
}
}
final var sql = """
INSERT INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, archived, storage_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, group_id_v2=excluded.group_id_v2, name=excluded.name, color=excluded.color, expiration_time=excluded.expiration_time, blocked=excluded.blocked, archived=excluded.archived, storage_id=excluded.storage_id
INSERT INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, blocked_at, archived, storage_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, group_id_v2=excluded.group_id_v2, name=excluded.name, color=excluded.color, expiration_time=excluded.expiration_time, blocked=excluded.blocked, blocked_at=excluded.blocked_at, archived=excluded.archived, storage_id=excluded.storage_id
RETURNING _id
""".formatted(TABLE_GROUP_V1);
try (final var statement = connection.prepareStatement(sql)) {
@ -630,8 +634,9 @@ public class GroupStore {
statement.setString(5, groupV1.color);
statement.setLong(6, groupV1.getMessageExpirationTimer());
statement.setBoolean(7, groupV1.isBlocked());
statement.setBoolean(8, groupV1.archived);
statement.setBytes(9, KeyUtils.createRawStorageId());
statement.setLong(8, groupV1.getBlockedAt());
statement.setBoolean(9, groupV1.archived);
statement.setBytes(10, KeyUtils.createRawStorageId());
final var generatedKey = Utils.executeQueryForOptional(statement, Utils::getIdMapper);
if (internalId == null) {
@ -657,9 +662,9 @@ public class GroupStore {
} else if (group instanceof GroupInfoV2 groupV2) {
final var sql = (
"""
INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, permission_denied, storage_id, profile_sharing)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, master_key=excluded.master_key, group_data=excluded.group_data, distribution_id=excluded.distribution_id, blocked=excluded.blocked, permission_denied=excluded.permission_denied, storage_id=excluded.storage_id, profile_sharing=excluded.profile_sharing
INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, blocked_at, permission_denied, storage_id, profile_sharing)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, master_key=excluded.master_key, group_data=excluded.group_data, distribution_id=excluded.distribution_id, blocked=excluded.blocked, blocked_at=excluded.blocked_at, permission_denied=excluded.permission_denied, storage_id=excluded.storage_id, profile_sharing=excluded.profile_sharing
"""
).formatted(TABLE_GROUP_V2);
try (final var statement = connection.prepareStatement(sql)) {
@ -677,9 +682,10 @@ public class GroupStore {
}
statement.setBytes(5, UuidUtil.toByteArray(groupV2.getDistributionId().asUuid()));
statement.setBoolean(6, groupV2.isBlocked());
statement.setBoolean(7, groupV2.isPermissionDenied());
statement.setBytes(8, KeyUtils.createRawStorageId());
statement.setBoolean(9, groupV2.isProfileSharingEnabled());
statement.setLong(7, groupV2.getBlockedAt());
statement.setBoolean(8, groupV2.isPermissionDenied());
statement.setBytes(9, KeyUtils.createRawStorageId());
statement.setBoolean(10, groupV2.isProfileSharingEnabled());
statement.executeUpdate();
}
} else {
@ -690,7 +696,7 @@ public class GroupStore {
private List<GroupInfoV2> getGroupsV2() {
final var sql = (
"""
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
FROM %s g
"""
).formatted(TABLE_GROUP_V2);
@ -708,7 +714,7 @@ public class GroupStore {
public GroupInfoV2 getGroup(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
FROM %s g
WHERE g.group_id = ?
"""
@ -742,7 +748,7 @@ public class GroupStore {
public GroupInfoV2 getGroupV2(Connection connection, StorageId storageId) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
FROM %s g
WHERE g.storage_id = ?
"""
@ -765,6 +771,7 @@ public class GroupStore {
final var groupData = resultSet.getBytes("group_data");
final var distributionId = resultSet.getBytes("distribution_id");
final var blocked = resultSet.getBoolean("blocked");
final var blockedAt = resultSet.getLong("blocked_at");
final var profileSharingEnabled = resultSet.getBoolean("profile_sharing");
final var permissionDenied = resultSet.getBoolean("permission_denied");
final var storageRecord = resultSet.getBytes("storage_record");
@ -773,6 +780,7 @@ public class GroupStore {
groupData == null ? null : DecryptedGroup.ADAPTER.decode(groupData),
DistributionId.from(UuidUtil.parseOrThrow(distributionId)),
blocked,
blockedAt,
profileSharingEnabled,
permissionDenied,
storageRecord,
@ -799,7 +807,7 @@ public class GroupStore {
private List<GroupInfoV1> getGroupsV1() {
final var sql = (
"""
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
FROM %s g
"""
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
@ -817,7 +825,7 @@ public class GroupStore {
public GroupInfoV1 getGroup(Connection connection, GroupIdV1 groupIdV1) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
FROM %s g
WHERE g.group_id = ?
"""
@ -851,7 +859,7 @@ public class GroupStore {
public GroupInfoV1 getGroupV1(Connection connection, StorageId storageId) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
FROM %s g
WHERE g.storage_id = ?
"""
@ -881,6 +889,7 @@ public class GroupStore {
.collect(Collectors.toSet());
final var expirationTime = resultSet.getInt("expiration_time");
final var blocked = resultSet.getBoolean("blocked");
final var blockedAt = resultSet.getLong("blocked_at");
final var archived = resultSet.getBoolean("archived");
final var storageRecord = resultSet.getBytes("storage_record");
return new GroupInfoV1(GroupId.v1(groupId),
@ -890,6 +899,7 @@ public class GroupStore {
color,
expirationTime,
blocked,
blockedAt,
archived,
storageRecord);
}
@ -901,7 +911,7 @@ public class GroupStore {
private GroupInfoV1 getGroupV1ByV2Id(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
FROM %s g
WHERE g.group_id_v2 = ?
"""

View File

@ -59,6 +59,7 @@ public class LegacyGroupStore {
g1.color,
g1.messageExpirationTime,
g1.blocked,
0,
g1.archived,
null);
}
@ -77,6 +78,7 @@ public class LegacyGroupStore {
loadDecryptedGroupLocked(groupId, groupCachePath),
g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId),
g2.blocked,
0,
true,
g2.permissionDenied,
null,

View File

@ -26,6 +26,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
public class SignalProtocolStore implements SignalServiceAccountDataStore {
@ -37,6 +38,7 @@ public class SignalProtocolStore implements SignalServiceAccountDataStore {
private final IdentityKeyStore identityKeyStore;
private final SignalServiceSenderKeyStore senderKeyStore;
private final Supplier<Boolean> isMultiDevice;
private final Consumer<Boolean> setMultiDeviceCallback;
public SignalProtocolStore(
final SignalServicePreKeyStore preKeyStore,
@ -45,7 +47,8 @@ public class SignalProtocolStore implements SignalServiceAccountDataStore {
final SignalServiceSessionStore sessionStore,
final IdentityKeyStore identityKeyStore,
final SignalServiceSenderKeyStore senderKeyStore,
final Supplier<Boolean> isMultiDevice
final Supplier<Boolean> isMultiDevice,
final Consumer<Boolean> setMultiDeviceCallback
) {
this.preKeyStore = preKeyStore;
this.signedPreKeyStore = signedPreKeyStore;
@ -54,6 +57,7 @@ public class SignalProtocolStore implements SignalServiceAccountDataStore {
this.identityKeyStore = identityKeyStore;
this.senderKeyStore = senderKeyStore;
this.isMultiDevice = isMultiDevice;
this.setMultiDeviceCallback = setMultiDeviceCallback;
}
@Override
@ -209,6 +213,11 @@ public class SignalProtocolStore implements SignalServiceAccountDataStore {
return isMultiDevice.get();
}
@Override
public void setMultiDevice(final boolean isMultiDevice) {
setMultiDeviceCallback.accept(isMultiDevice);
}
@Override
public KyberPreKeyRecord loadKyberPreKey(final int kyberPreKeyId) throws InvalidKeyIdException {
return kyberPreKeyStore.loadKyberPreKey(kyberPreKeyId);

View File

@ -50,6 +50,7 @@ public class LegacyRecipientStore2 {
0,
false,
r.contact.blocked,
0,
r.contact.archived,
r.contact.profileSharingEnabled,
false,
@ -100,6 +101,7 @@ public class LegacyRecipientStore2 {
profile,
null,
null,
false,
null);
}).collect(Collectors.toMap(Recipient::getRecipientId, r -> r));

View File

@ -25,6 +25,8 @@ public class Recipient {
private final Long unregisteredTimestamp;
private final boolean pniSignatureVerified;
private final byte[] storageRecord;
public Recipient(
@ -36,6 +38,7 @@ public class Recipient {
final Profile profile,
final Boolean discoverable,
final Long unregisteredTimestamp,
final boolean pniSignatureVerified,
final byte[] storageRecord
) {
this.recipientId = recipientId;
@ -46,6 +49,7 @@ public class Recipient {
this.profile = profile;
this.discoverable = discoverable;
this.unregisteredTimestamp = unregisteredTimestamp;
this.pniSignatureVerified = pniSignatureVerified;
this.storageRecord = storageRecord;
}
@ -58,6 +62,7 @@ public class Recipient {
profile = builder.profile;
discoverable = builder.discoverable;
unregisteredTimestamp = builder.unregisteredTimestamp;
pniSignatureVerified = builder.pniSignatureVerified;
storageRecord = builder.storageRecord;
}
@ -73,6 +78,9 @@ public class Recipient {
builder.profileKey = copy.getProfileKey();
builder.expiringProfileKeyCredential = copy.getExpiringProfileKeyCredential();
builder.profile = copy.getProfile();
builder.discoverable = copy.getDiscoverable();
builder.unregisteredTimestamp = copy.getUnregisteredTimestamp();
builder.pniSignatureVerified = copy.isPniSignatureVerified();
builder.storageRecord = copy.getStorageRecord();
return builder;
}
@ -113,6 +121,10 @@ public class Recipient {
return unregisteredTimestamp == null;
}
public boolean isPniSignatureVerified() {
return pniSignatureVerified;
}
public byte[] getStorageRecord() {
return storageRecord;
}
@ -127,12 +139,19 @@ public class Recipient {
&& Objects.equals(contact, recipient.contact)
&& Objects.equals(profileKey, recipient.profileKey)
&& Objects.equals(expiringProfileKeyCredential, recipient.expiringProfileKeyCredential)
&& Objects.equals(profile, recipient.profile);
&& Objects.equals(profile, recipient.profile)
&& pniSignatureVerified == recipient.pniSignatureVerified;
}
@Override
public int hashCode() {
return Objects.hash(recipientId, address, contact, profileKey, expiringProfileKeyCredential, profile);
return Objects.hash(recipientId,
address,
contact,
profileKey,
expiringProfileKeyCredential,
profile,
pniSignatureVerified);
}
public static final class Builder {
@ -145,6 +164,7 @@ public class Recipient {
private Profile profile;
private Boolean discoverable;
private Long unregisteredTimestamp;
private boolean pniSignatureVerified;
private byte[] storageRecord;
private Builder() {
@ -190,6 +210,11 @@ public class Recipient {
return this;
}
public Builder withPniSignatureVerified(final boolean val) {
pniSignatureVerified = val;
return this;
}
public Builder withStorageRecord(final byte[] val) {
storageRecord = val;
return this;

View File

@ -28,7 +28,9 @@ import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -50,7 +52,18 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private final Map<Long, Long> recipientsMerged = new HashMap<>();
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = new HashMap<>();
private static final int MAX_RECIPIENT_CACHE_SIZE = 2000;
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(new LinkedHashMap<>(
16,
0.75f,
true) {
@Override
protected boolean removeEldestEntry(Map.Entry<ServiceId, RecipientWithAddress> eldest) {
return size() > MAX_RECIPIENT_CACHE_SIZE;
}
});
public static void createSql(Connection connection) throws SQLException {
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java
@ -69,6 +82,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
profile_key BLOB,
profile_key_credential BLOB,
needs_pni_signature INTEGER NOT NULL DEFAULT FALSE,
pni_signature_verified INTEGER NOT NULL DEFAULT FALSE,
given_name TEXT,
family_name TEXT,
@ -82,6 +96,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
expiration_time_version INTEGER DEFAULT 1 NOT NULL,
mute_until INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT FALSE,
profile_sharing INTEGER NOT NULL DEFAULT FALSE,
hide_story INTEGER NOT NULL DEFAULT FALSE,
@ -184,12 +199,12 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
@Override
public RecipientId resolveRecipient(final ServiceId serviceId) {
final var recipientWithAddress = recipientAddressCache.get(serviceId);
if (recipientWithAddress != null) {
return recipientWithAddress.id();
}
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
final var recipientWithAddress = recipientAddressCache.get(serviceId);
if (recipientWithAddress != null) {
return recipientWithAddress.id();
}
final var recipientId = resolveRecipientLocked(connection, serviceId);
connection.commit();
return recipientId;
@ -337,7 +352,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
public List<Pair<RecipientId, Contact>> getContacts() {
final var sql = (
"""
SELECT r._id, r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp
SELECT r._id, r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp
FROM %s r
WHERE (r.number IS NOT NULL OR r.pni IS NOT NULL OR r.aci IS NOT NULL) AND %s AND r.hidden = FALSE
"""
@ -361,7 +376,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
SELECT r._id,
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
r.pni_signature_verified,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
@ -381,7 +397,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
SELECT r._id,
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
r.pni_signature_verified,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
@ -430,7 +447,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
SELECT r._id,
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
r.pni_signature_verified,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
@ -462,6 +480,40 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
/**
* Returns the subset of the given recipients that are currently known to be
* unregistered (i.e. have an unregistered timestamp set).
* <p>
* These can be skipped when sending group messages; otherwise every send
* re-attempts them via the slow legacy 1:1 fan-out. The unregistered flag is
* maintained independently by profile/CDS discovery and cleared again once a
* recipient registers, so they are re-included automatically.
*/
public Set<RecipientId> getUnregisteredRecipientIds(final Set<RecipientId> recipientIds) {
if (recipientIds.isEmpty()) {
return Set.of();
}
final var recipientIdsCommaSeparated = recipientIds.stream()
.map(recipientId -> String.valueOf(recipientId.id()))
.collect(Collectors.joining(","));
final var sql = (
"""
SELECT r._id
FROM %s r
WHERE r.unregistered_timestamp IS NOT NULL AND r._id IN (%s)
"""
).formatted(TABLE_RECIPIENT, recipientIdsCommaSeparated);
try (final var connection = database.getConnection()) {
try (final var statement = connection.prepareStatement(sql)) {
try (var result = Utils.executeQueryForStream(statement, this::getRecipientIdFromResultSet)) {
return result.collect(Collectors.toSet());
}
}
} catch (SQLException e) {
throw new RuntimeException("Failed read from recipient store", e);
}
}
public Set<String> getAllNumbers() {
final var sql = (
"""
@ -841,7 +893,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
UPDATE %s
SET given_name = ?, family_name = ?, nick_name = ?, expiration_time = ?, expiration_time_version = ?, mute_until = ?, hide_story = ?, profile_sharing = ?, color = ?, blocked = ?, archived = ?, unregistered_timestamp = ?, nick_name_given_name = ?, nick_name_family_name = ?, note = ?, hidden = ?
SET given_name = ?, family_name = ?, nick_name = ?, expiration_time = ?, expiration_time_version = ?, mute_until = ?, hide_story = ?, profile_sharing = ?, color = ?, blocked = ?, blocked_at = ?, archived = ?, unregistered_timestamp = ?, nick_name_given_name = ?, nick_name_family_name = ?, note = ?, hidden = ?
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
@ -856,28 +908,52 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
statement.setBoolean(8, contact != null && contact.isProfileSharingEnabled());
statement.setString(9, contact == null ? null : contact.color());
statement.setBoolean(10, contact != null && contact.isBlocked());
statement.setBoolean(11, contact != null && contact.isArchived());
statement.setLong(11, contact == null ? 0 : contact.blockedAt());
statement.setBoolean(12, contact != null && contact.isArchived());
if (contact == null || contact.unregisteredTimestamp() == null) {
statement.setNull(12, Types.INTEGER);
statement.setNull(13, Types.INTEGER);
} else {
statement.setLong(12, contact.unregisteredTimestamp());
statement.setLong(13, contact.unregisteredTimestamp());
}
statement.setString(13, contact == null ? null : contact.nickNameGivenName());
statement.setString(14, contact == null ? null : contact.nickNameFamilyName());
statement.setString(15, contact == null ? null : contact.note());
statement.setBoolean(16, contact != null && contact.isHidden());
statement.setLong(17, recipientId.id());
statement.setString(14, contact == null ? null : contact.nickNameGivenName());
statement.setString(15, contact == null ? null : contact.nickNameFamilyName());
statement.setString(16, contact == null ? null : contact.note());
statement.setBoolean(17, contact != null && contact.isHidden());
statement.setLong(18, recipientId.id());
statement.executeUpdate();
}
if (contact != null && contact.unregisteredTimestamp() != null) {
markUnregisteredAndSplitIfNecessary(connection, recipientId);
markUnregisteredAndSplitIfNecessary(connection, recipientId, contact.unregisteredTimestamp());
}
rotateStorageId(connection, recipientId);
}
public void splitForStorageSyncIfNecessary(final Connection connection, final ACI aci) throws SQLException {
final var recipient = findByServiceId(connection, aci);
if (recipient.isEmpty()) {
return;
}
final var recipientId = recipient.get().id();
final var address = recipient.get().address();
if (address.pni().isEmpty() && address.number().isEmpty()) {
return;
}
logger.debug("Splitting {} for storage sync", recipientId);
final var splitAddress = new RecipientAddress(Optional.empty(),
address.pni(),
address.number(),
Optional.empty());
updateRecipientAddress(connection,
recipientId,
new RecipientAddress(address.aci(), Optional.empty(), Optional.empty(), address.username()));
resolveRecipientTrusted(connection, splitAddress);
}
public int removeStorageIdsFromLocalOnlyUnregisteredRecipients(
final Connection connection,
final List<StorageId> storageIds
final Collection<StorageId> storageIds
) throws SQLException {
final var sql = (
"""
@ -916,6 +992,69 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
public void markPniSignatureVerified(final RecipientId recipientId) {
logger.debug("Marking {} as pni signature verified", recipientId);
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
if (storePniSignatureVerified(connection, recipientId, true)) {
rotateStorageId(connection, recipientId);
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update recipient store", e);
}
}
public boolean storePniSignatureVerified(
final Connection connection,
final RecipientId recipientId,
final boolean value
) throws SQLException {
return storePniSignatureVerified(connection, recipientId, value, false);
}
private boolean storePniSignatureVerified(
final Connection connection,
final RecipientId recipientId,
final boolean value,
final boolean force
) throws SQLException {
if (!force && isPniSignatureVerified(connection, recipientId) == value) {
return false;
}
final var sql = (
"""
UPDATE %s
SET pni_signature_verified = ?
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBoolean(1, value);
statement.setLong(2, recipientId.id());
statement.executeUpdate();
}
return true;
}
private boolean isPniSignatureVerified(
final Connection connection,
final RecipientId recipientId
) throws SQLException {
final var sql = (
"""
SELECT pni_signature_verified
FROM %s
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setLong(1, recipientId.id());
return Utils.executeQuerySingleRow(statement, resultSet -> resultSet.getBoolean("pni_signature_verified"));
}
}
public boolean needsPniSignature(final RecipientId recipientId) {
try (final var connection = database.getConnection()) {
final var sql = (
@ -947,7 +1086,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
if (recipientAddress.get().address().aci().isEmpty() || (
contact != null && contact.unregisteredTimestamp() != null
)) {
markUnregisteredAndSplitIfNecessary(connection, recipientId);
markUnregisteredAndSplitIfNecessary(connection, recipientId, System.currentTimeMillis());
}
}
}
@ -981,7 +1120,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
if (registered) {
markRegistered(connection, recipientId);
} else {
markUnregisteredAndSplitIfNecessary(connection, recipientId);
markUnregisteredAndSplitIfNecessary(connection, recipientId, System.currentTimeMillis());
}
connection.commit();
} catch (SQLException e) {
@ -991,9 +1130,10 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private void markUnregisteredAndSplitIfNecessary(
final Connection connection,
final RecipientId recipientId
final RecipientId recipientId,
final long unregisteredTimestamp
) throws SQLException {
markUnregistered(connection, recipientId);
markUnregistered(connection, recipientId, unregisteredTimestamp);
final var address = resolveRecipientAddress(connection, recipientId);
final var needSplit = address.aci().isPresent() && address.pni().isPresent();
logger.trace("Marking unregistered recipient {} as unregistered (and split={}): {}",
@ -1040,7 +1180,11 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
private void markUnregistered(final Connection connection, final RecipientId recipientId) throws SQLException {
private void markUnregistered(
final Connection connection,
final RecipientId recipientId,
final long unregisteredTimestamp
) throws SQLException {
final var sql = (
"""
UPDATE %s
@ -1049,7 +1193,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
"""
).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setLong(1, System.currentTimeMillis());
statement.setLong(1, unregisteredTimestamp);
statement.setLong(2, recipientId.id());
statement.executeUpdate();
}
@ -1210,6 +1354,9 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final List<RecipientId> toBeMergedRecipientIds
) throws SQLException {
for (final var toBeMergedRecipientId : toBeMergedRecipientIds) {
if (isPniSignatureVerified(connection, toBeMergedRecipientId)) {
storePniSignatureVerified(connection, recipientId, true, true);
}
recipientMergeHandler.mergeRecipients(connection, recipientId, toBeMergedRecipientId);
deleteRecipient(connection, toBeMergedRecipientId);
recipientAddressCache.entrySet().removeIf(e -> e.getValue().id().equals(toBeMergedRecipientId));
@ -1304,7 +1451,12 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
UPDATE %s
SET number = NULL, aci = NULL, pni = NULL, username = NULL, storage_id = NULL
SET number = NULL,
aci = NULL,
pni = NULL,
username = NULL,
storage_id = NULL,
pni_signature_verified = FALSE
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
@ -1320,10 +1472,18 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final RecipientAddress address
) throws SQLException {
recipientAddressCache.entrySet().removeIf(e -> e.getValue().id().equals(recipientId));
final var existingAddress = resolveRecipientAddress(connection, recipientId);
final var keepPniSignatureVerified = Objects.equals(existingAddress.aci(), address.aci()) && Objects.equals(
existingAddress.pni(),
address.pni());
final var sql = (
"""
UPDATE %s
SET number = ?, aci = ?, pni = ?, username = ?
SET number = ?,
aci = ?,
pni = ?,
username = ?,
pni_signature_verified = ?
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
@ -1332,7 +1492,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
statement.setString(2, address.aci().map(ACI::toString).orElse(null));
statement.setString(3, address.pni().map(PNI::toString).orElse(null));
statement.setString(4, address.username().orElse(null));
statement.setLong(5, recipientId.id());
statement.setBoolean(5, keepPniSignatureVerified && isPniSignatureVerified(connection, recipientId));
statement.setLong(6, recipientId.id());
statement.executeUpdate();
}
rotateStorageId(connection, recipientId);
@ -1357,9 +1518,14 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
RecipientId toBeMergedRecipientId
) throws SQLException {
final var contact = getContact(connection, recipientId);
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
if (contact == null) {
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
storeContact(connection, recipientId, toBeMergedContact);
} else if (toBeMergedContact != null) {
final var mergedContact = mergeContacts(contact, toBeMergedContact);
if (!contact.equals(mergedContact)) {
storeContact(connection, recipientId, mergedContact);
}
}
final var profileKey = getProfileKey(connection, recipientId);
@ -1384,6 +1550,24 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
recipientsMerged.put(toBeMergedRecipientId.id(), recipientId.id());
}
static Contact mergeContacts(final Contact primary, final Contact secondary) {
final var profileSharingEnabled = primary.isProfileSharingEnabled() || secondary.isProfileSharingEnabled();
return Contact.newBuilder(primary)
.withGivenName(secondary.givenName())
.withFamilyName(secondary.familyName())
.withMessageExpirationTime(primary.messageExpirationTime() > 0
? primary.messageExpirationTime()
: secondary.messageExpirationTime())
.withMessageExpirationTimeVersion(Math.max(primary.messageExpirationTimeVersion(),
secondary.messageExpirationTimeVersion()))
.withMuteUntil(primary.muteUntil() > 0 ? primary.muteUntil() : secondary.muteUntil())
.withIsBlocked(primary.isBlocked() || secondary.isBlocked())
.withBlockedAt(Math.max(primary.blockedAt(), secondary.blockedAt()))
.withIsProfileSharingEnabled(profileSharingEnabled)
.withIsHidden(profileSharingEnabled ? false : primary.isHidden())
.build();
}
private Optional<RecipientWithAddress> findByNumber(
final Connection connection,
final String number
@ -1463,7 +1647,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private Contact getContact(final Connection connection, final RecipientId recipientId) throws SQLException {
final var sql = (
"""
SELECT r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp
SELECT r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp
FROM %s r
WHERE r._id = ? AND (%s)
"""
@ -1550,6 +1734,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
getProfileFromResultSet(resultSet),
getDiscoverableFromResultSet(resultSet),
getUnregisteredTimestampFromResultSet(resultSet),
resultSet.getBoolean("pni_signature_verified"),
getStorageRecordFromResultSet(resultSet));
}
@ -1567,6 +1752,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
resultSet.getLong("mute_until"),
resultSet.getBoolean("hide_story"),
resultSet.getBoolean("blocked"),
resultSet.getLong("blocked_at"),
resultSet.getBoolean("archived"),
resultSet.getBoolean("profile_sharing"),
resultSet.getBoolean("hidden"),

View File

@ -303,6 +303,7 @@ public class MessageSendLogStore implements AutoCloseable {
}
if (contentId == -1) {
logger.warn("Failed to insert message send log content");
connection.commit();
return -1;
}
insertRecipientsForExistingContent(contentId, recipientDevices, connection);

View File

@ -204,7 +204,7 @@ public class SenderKeySharedStore {
).formatted(TABLE_SENDER_KEY_SHARED);
try (final var statement = connection.prepareStatement(sql)) {
for (final var entry : newEntries) {
statement.setString(1, entry.toString());
statement.setString(1, entry.address());
statement.setInt(2, entry.deviceId());
statement.setBytes(3, UuidUtil.toByteArray(distributionId.asUuid()));
statement.setLong(4, System.currentTimeMillis());

View File

@ -18,7 +18,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -28,8 +28,15 @@ public class SessionStore implements SignalServiceSessionStore {
private static final String TABLE_SESSION = "session";
private static final Logger logger = LoggerFactory.getLogger(SessionStore.class);
private static final int MAX_CACHE_SIZE = 1000;
private final Map<Key, SessionRecord> cachedSessions = new HashMap<>();
private final Map<Key, SessionRecord> cachedSessions = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Key, SessionRecord> eldest) {
return size() > MAX_CACHE_SIZE;
}
};
private final Database database;
private final int accountIdType;
@ -194,8 +201,9 @@ public class SessionStore implements SignalServiceSessionStore {
if (session != null) {
session.archiveCurrentState();
storeSession(connection, key, session);
connection.commit();
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update session store", e);
}

View File

@ -1,10 +1,29 @@
package org.asamk.signal.manager.storage.stickers;
import org.asamk.signal.manager.api.StickerPackId;
import org.whispersystems.signalservice.api.storage.StorageId;
public record StickerPack(long internalId, StickerPackId packId, byte[] packKey, boolean isInstalled) {
public record StickerPack(
long internalId,
StickerPackId packId,
byte[] packKey,
boolean isInstalled,
int position,
long deletedTimestamp,
StorageId storageId,
byte[] storageRecord
) {
public StickerPack(
final long internalId,
final StickerPackId packId,
final byte[] packKey,
final boolean isInstalled
) {
this(internalId, packId, packKey, isInstalled, 0, 0, null, null);
}
public StickerPack(final StickerPackId packId, final byte[] packKey) {
this(-1, packId, packKey, false);
this(-1, packId, packKey, false, 0, 0, null, null);
}
}

View File

@ -3,14 +3,19 @@ package org.asamk.signal.manager.storage.stickers;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.storage.Database;
import org.asamk.signal.manager.storage.Utils;
import org.asamk.signal.manager.util.KeyUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.Collection;
import java.util.List;
import java.util.Map;
public class StickerStore {
@ -27,7 +32,11 @@ public class StickerStore {
_id INTEGER PRIMARY KEY,
pack_id BLOB UNIQUE NOT NULL,
pack_key BLOB NOT NULL,
installed INTEGER NOT NULL DEFAULT FALSE
installed INTEGER NOT NULL DEFAULT FALSE,
position INTEGER NOT NULL DEFAULT 0,
deleted_timestamp INTEGER NOT NULL DEFAULT 0,
storage_id BLOB UNIQUE,
storage_record BLOB
) STRICT;
""");
}
@ -38,55 +47,103 @@ public class StickerStore {
}
public List<StickerPack> getStickerPacks() {
final var sql = (
"""
SELECT s._id, s.pack_id, s.pack_key, s.installed
FROM %s s
"""
).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) {
try (final var statement = connection.prepareStatement(sql)) {
try (var result = Utils.executeQueryForStream(statement, this::getStickerPackFromResultSet)) {
return result.toList();
}
}
return getStickerPacks(connection);
} catch (SQLException e) {
throw new RuntimeException("Failed read from sticker store", e);
}
}
public StickerPack getStickerPack(StickerPackId packId) {
public List<StickerPack> getStickerPacks(final Connection connection) throws SQLException {
final var sql = (
"""
SELECT s._id, s.pack_id, s.pack_key, s.installed
SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record
FROM %s s
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(sql)) {
try (var result = Utils.executeQueryForStream(statement, this::getStickerPackFromResultSet)) {
return result.toList();
}
}
}
public StickerPack getStickerPack(StickerPackId packId) {
try (final var connection = database.getConnection()) {
return getStickerPack(connection, packId);
} catch (SQLException e) {
throw new RuntimeException("Failed read from sticker store", e);
}
}
public StickerPack getStickerPack(Connection connection, StickerPackId packId) throws SQLException {
final var sql = (
"""
SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record
FROM %s s
WHERE s.pack_id = ?
"""
).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) {
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, packId.serialize());
return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null);
}
} catch (SQLException e) {
throw new RuntimeException("Failed read from sticker store", e);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, packId.serialize());
return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null);
}
}
public StickerPack getStickerPack(Connection connection, StorageId storageId) throws SQLException {
final var sql = (
"""
SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record
FROM %s s
WHERE s.storage_id = ?
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, storageId.getRaw());
return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null);
}
}
public void addStickerPack(StickerPack stickerPack) {
final var sql = (
"""
INSERT INTO %s (pack_id, pack_key, installed)
VALUES (?, ?, ?)
INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record)
VALUES (?, ?, ?, ?, ?, ?, ?)
"""
).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
var storageId = stickerPack.storageId();
if (storageId == null && (stickerPack.isInstalled() || stickerPack.deletedTimestamp() > 0)) {
storageId = StorageId.forStickerPack(KeyUtils.createRawStorageId());
}
final var position = stickerPack.isInstalled() ? Math.max(stickerPack.position(),
getNextPosition(connection)) : 0;
var deletedTimestamp = stickerPack.deletedTimestamp();
if (!stickerPack.isInstalled() && deletedTimestamp == 0 && storageId != null) {
deletedTimestamp = System.currentTimeMillis();
}
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, stickerPack.packId().serialize());
statement.setBytes(2, stickerPack.packKey());
statement.setBoolean(3, stickerPack.isInstalled());
statement.setInt(4, position);
statement.setLong(5, deletedTimestamp);
if (storageId == null) {
statement.setNull(6, Types.BLOB);
} else {
statement.setBytes(6, storageId.getRaw());
}
if (stickerPack.storageRecord() == null) {
statement.setNull(7, Types.BLOB);
} else {
statement.setBytes(7, stickerPack.storageRecord());
}
statement.executeUpdate();
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update sticker store", e);
}
@ -96,28 +153,279 @@ public class StickerStore {
final var sql = (
"""
UPDATE %s
SET installed = ?
SET installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?
WHERE pack_id = ?
"""
).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
final var existing = getStickerPack(connection, stickerPackId);
if (existing == null || existing.isInstalled() == installed) {
connection.commit();
return;
}
final var newStorageId = StorageId.forStickerPack(KeyUtils.createRawStorageId());
final var position = installed ? getNextPosition(connection) : 0;
final var deletedTimestamp = installed ? 0 : System.currentTimeMillis();
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, stickerPackId.serialize());
statement.setBoolean(2, installed);
statement.setBoolean(1, installed);
statement.setInt(2, position);
statement.setLong(3, deletedTimestamp);
statement.setBytes(4, newStorageId.getRaw());
statement.setBytes(5, stickerPackId.serialize());
statement.executeUpdate();
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update sticker store", e);
}
}
public List<StorageId> getStorageIds(final Connection connection) throws SQLException {
final var sql = (
"""
SELECT s.storage_id
FROM %s s
WHERE s.storage_id IS NOT NULL
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(sql)) {
return Utils.executeQueryForStream(statement, this::getStorageIdFromResultSet).toList();
}
}
public void updateStorageId(
final Connection connection,
final StickerPackId packId,
final StorageId storageId
) throws SQLException {
final var sql = (
"""
UPDATE %s
SET storage_id = ?
WHERE pack_id = ?
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, storageId.getRaw());
statement.setBytes(2, packId.serialize());
statement.executeUpdate();
}
}
public void updateStorageIds(
final Connection connection,
final Map<StickerPackId, StorageId> storageIdMap
) throws SQLException {
final var sql = (
"""
UPDATE %s
SET storage_id = ?
WHERE pack_id = ?
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(sql)) {
for (final var entry : storageIdMap.entrySet()) {
statement.setBytes(1, entry.getValue().getRaw());
statement.setBytes(2, entry.getKey().serialize());
statement.executeUpdate();
}
}
}
public StorageId getStorageId(final Connection connection, final StickerPackId packId) throws SQLException {
final var sql = (
"""
SELECT s.storage_id
FROM %s s
WHERE s.pack_id = ? AND s.storage_id IS NOT NULL
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, packId.serialize());
final var storageId = Utils.executeQueryForOptional(statement, this::getStorageIdFromResultSet);
if (storageId.isPresent()) {
return storageId.get();
}
}
final var newStorageId = StorageId.forStickerPack(KeyUtils.createRawStorageId());
updateStorageId(connection, packId, newStorageId);
return newStorageId;
}
public void storeStorageRecord(
final Connection connection,
final StickerPackId packId,
final StorageId storageId,
final byte[] storageRecord
) throws SQLException {
final var clearSql = (
"""
UPDATE %s
SET storage_id = NULL
WHERE storage_id = ?
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(clearSql)) {
statement.setBytes(1, storageId.getRaw());
statement.executeUpdate();
}
final var updateSql = (
"""
UPDATE %s
SET storage_id = ?, storage_record = ?
WHERE pack_id = ?
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(updateSql)) {
statement.setBytes(1, storageId.getRaw());
if (storageRecord == null) {
statement.setNull(2, Types.BLOB);
} else {
statement.setBytes(2, storageRecord);
}
statement.setBytes(3, packId.serialize());
statement.executeUpdate();
}
}
public void setMissingStorageIds() {
final var selectSql = (
"""
SELECT s.pack_id
FROM %s s
WHERE s.storage_id IS NULL AND s.installed = TRUE
"""
).formatted(TABLE_STICKER);
final var updateSql = (
"""
UPDATE %s
SET storage_id = ?
WHERE pack_id = ?
"""
).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
try (final var selectStatement = connection.prepareStatement(selectSql)) {
final var packIds = Utils.executeQueryForStream(selectStatement,
resultSet -> StickerPackId.deserialize(resultSet.getBytes("pack_id"))).toList();
try (final var updateStatement = connection.prepareStatement(updateSql)) {
for (final var packId : packIds) {
updateStatement.setBytes(1, KeyUtils.createRawStorageId());
updateStatement.setBytes(2, packId.serialize());
updateStatement.executeUpdate();
}
}
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update sticker store", e);
}
}
public int removeStorageIdsFromLocalOnlyDeletedStickerPacks(
final Connection connection,
final Collection<StorageId> storageIds
) throws SQLException {
final var sql = (
"""
UPDATE %s
SET storage_id = NULL
WHERE storage_id = ? AND installed = FALSE AND deleted_timestamp > 0
"""
).formatted(TABLE_STICKER);
var count = 0;
try (final var statement = connection.prepareStatement(sql)) {
for (final var storageId : storageIds) {
statement.setBytes(1, storageId.getRaw());
count += statement.executeUpdate();
}
}
return count;
}
public void upsertFromStorageSync(
final Connection connection,
final SignalStickerPackRecord record
) throws SQLException {
final var remote = record.getProto();
final var packId = StickerPackId.deserialize(remote.packId.toByteArray());
final var deleted = remote.deletedAtTimestamp > 0;
final var packKey = remote.packKey.toByteArray();
final var storageRecord = remote.encode();
final var current = getStickerPack(connection, packId);
if (current == null) {
final var insertSql = (
"""
INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record)
VALUES (?, ?, ?, ?, ?, ?, ?)
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(insertSql)) {
statement.setBytes(1, packId.serialize());
statement.setBytes(2, packKey);
statement.setBoolean(3, !deleted);
statement.setInt(4, deleted ? 0 : remote.position);
statement.setLong(5, remote.deletedAtTimestamp);
statement.setBytes(6, record.getId().getRaw());
statement.setBytes(7, storageRecord);
statement.executeUpdate();
}
return;
}
if (packKey.length > 0) {
final var updateSql = (
"""
UPDATE %s
SET pack_key = ?, installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?, storage_record = ?
WHERE pack_id = ?
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(updateSql)) {
statement.setBytes(1, packKey);
statement.setBoolean(2, !deleted);
statement.setInt(3, deleted ? 0 : remote.position);
statement.setLong(4, remote.deletedAtTimestamp);
statement.setBytes(5, record.getId().getRaw());
statement.setBytes(6, storageRecord);
statement.setBytes(7, packId.serialize());
statement.executeUpdate();
}
} else {
final var updateSql = (
"""
UPDATE %s
SET installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?, storage_record = ?
WHERE pack_id = ?
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(updateSql)) {
statement.setBoolean(1, !deleted);
statement.setInt(2, deleted ? 0 : remote.position);
statement.setLong(3, remote.deletedAtTimestamp);
statement.setBytes(4, record.getId().getRaw());
statement.setBytes(5, storageRecord);
statement.setBytes(6, packId.serialize());
statement.executeUpdate();
}
}
}
void addLegacyStickers(Collection<StickerPack> stickerPacks) {
logger.debug("Migrating legacy stickers to database");
long start = System.nanoTime();
final var sql = (
"""
INSERT INTO %s (pack_id, pack_key, installed)
VALUES (?, ?, ?)
INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record)
VALUES (?, ?, ?, ?, ?, ?, ?)
"""
).formatted(TABLE_STICKER);
try (final var connection = database.getConnection()) {
@ -125,11 +433,23 @@ public class StickerStore {
try (final var statement = connection.prepareStatement("DELETE FROM %s".formatted(TABLE_STICKER))) {
statement.executeUpdate();
}
var installedPosition = 0;
try (final var statement = connection.prepareStatement(sql)) {
for (final var sticker : stickerPacks) {
final var storageId = sticker.isInstalled()
? StorageId.forStickerPack(KeyUtils.createRawStorageId())
: null;
statement.setBytes(1, sticker.packId().serialize());
statement.setBytes(2, sticker.packKey());
statement.setBoolean(3, sticker.isInstalled());
statement.setInt(4, sticker.isInstalled() ? installedPosition++ : 0);
statement.setLong(5, 0);
if (storageId == null) {
statement.setNull(6, Types.BLOB);
} else {
statement.setBytes(6, storageId.getRaw());
}
statement.setNull(7, Types.BLOB);
statement.executeUpdate();
}
}
@ -145,6 +465,36 @@ public class StickerStore {
final var packId = resultSet.getBytes("pack_id");
final var packKey = resultSet.getBytes("pack_key");
final var installed = resultSet.getBoolean("installed");
return new StickerPack(internalId, StickerPackId.deserialize(packId), packKey, installed);
final var position = resultSet.getInt("position");
final var deletedTimestamp = resultSet.getLong("deleted_timestamp");
final var storageIdBytes = resultSet.getBytes("storage_id");
final var storageId = storageIdBytes == null ? null : StorageId.forStickerPack(storageIdBytes);
final var storageRecord = resultSet.getBytes("storage_record");
return new StickerPack(internalId,
StickerPackId.deserialize(packId),
packKey,
installed,
position,
deletedTimestamp,
storageId,
storageRecord);
}
private StorageId getStorageIdFromResultSet(final ResultSet resultSet) throws SQLException {
final var storageId = resultSet.getBytes("storage_id");
return StorageId.forStickerPack(storageId);
}
private int getNextPosition(final Connection connection) throws SQLException {
final var sql = (
"""
SELECT IFNULL(MAX(position) + 1, 0) AS next_position
FROM %s
WHERE installed = TRUE
"""
).formatted(TABLE_STICKER);
try (final var statement = connection.prepareStatement(sql)) {
return Utils.executeQuerySingleRow(statement, resultSet -> resultSet.getInt("next_position"));
}
}
}

View File

@ -24,8 +24,10 @@ import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.Id
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import okio.ByteString;
@ -46,16 +48,69 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
private final SignalAccount account;
private final Connection connection;
private final JobExecutor jobExecutor;
private final Set<StorageId> identityConflictsPendingRepair;
public ContactRecordProcessor(SignalAccount account, Connection connection, final JobExecutor jobExecutor) {
public ContactRecordProcessor(
SignalAccount account,
Connection connection,
final JobExecutor jobExecutor,
final Set<StorageId> identityConflictsPendingRepair
) {
this.account = account;
this.connection = connection;
this.jobExecutor = jobExecutor;
this.identityConflictsPendingRepair = identityConflictsPendingRepair;
this.selfAci = account.getAci();
this.selfPni = account.getPni();
this.selfNumber = account.getNumber();
}
public void prepare(final Collection<SignalContactRecord> remoteRecords) throws SQLException {
for (final var remoteRecord : remoteRecords) {
if (isInvalid(remoteRecord)) {
continue;
}
final var remote = remoteRecord.getProto();
final var aci = ACI.parseOrNull(remote.aci, remote.aciBinary);
final var pni = PNI.parseOrNull(remote.pni, remote.pniBinary);
if (shouldSplitForStorageSync(remote.unregisteredAtTimestamp, aci, pni, remote.e164)) {
account.getRecipientStore().splitForStorageSyncIfNecessary(connection, aci);
}
}
}
static boolean shouldSplitForStorageSync(
final long unregisteredAtTimestamp,
final ACI aci,
final PNI pni,
final String e164
) {
return unregisteredAtTimestamp > 0 && aci != null && pni == null && e164.isEmpty();
}
static boolean shouldUseRemoteIdentityKey(
final boolean isPrimaryDevice,
final boolean statesDiffer,
final int remoteIdentityKeySize,
final int localIdentityKeySize,
final long localUnregisteredAtTimestamp,
final boolean unrepairableIdentityKeyConflict
) {
return remoteIdentityKeySize > 0 && (
statesDiffer || localIdentityKeySize == 0 || localUnregisteredAtTimestamp > 0 || (
unrepairableIdentityKeyConflict && !isPrimaryDevice
)
);
}
@Override
protected String describeRecord(final SignalContactRecord record) {
final var proto = record.getProto();
final var aci = ACI.parseOrNull(proto.aci, proto.aciBinary);
final var pni = PNI.parseOrNull(proto.pni, proto.pniBinary);
return "[" + firstNonNull(aci, pni) + "]";
}
/**
* Error cases:
* - You can't have a contact record without an ACI or PNI.
@ -117,32 +172,40 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
IdentityState identityState;
ByteString identityKey;
if (remote.identityKey.size() > 0 && (
!account.isPrimaryDevice()
|| remote.identityState != local.identityState
|| local.identityKey.size() == 0
)) {
identityState = remote.identityState;
identityKey = remote.identityKey;
} else {
identityState = local.identityState;
identityKey = local.identityKey.size() > 0 ? local.identityKey : ByteString.EMPTY;
}
// Parse ACI/PNI first so we can determine if contact has identity
final var localAci = ACI.parseOrNull(local.aci, local.aciBinary);
final var localPni = PNI.parseOrNull(local.pni, local.pniBinary);
final var remoteAci = ACI.parseOrNull(remote.aci, remote.aciBinary);
final var remotePni = PNI.parseOrNull(remote.pni, remote.pniBinary);
if (localAci != null
&& local.identityKey.size() > 0
&& remote.identityKey.size() > 0
&& !local.identityKey.equals(remote.identityKey)) {
logger.debug("The local and remote identity keys do not match for {}. Enqueueing a profile fetch.",
localAci);
final var address = getRecipientAddress(local);
jobExecutor.enqueueJob(new DownloadProfileJob(address));
final var hasLocalIdentity = localAci != null || localPni != null;
final var hasRemoteIdentity = remoteAci != null || remotePni != null;
final var remoteIdentityKeySize = remote.identityKey.size();
final var localIdentityKeySize = local.identityKey.size();
final var statesDiffer = remote.identityState != local.identityState;
final var identityKeysExistAndConflict = remoteIdentityKeySize > 0
&& localIdentityKeySize > 0
&& !remote.identityKey.equals(local.identityKey);
final var conflictAci = firstNonNull(localAci, remoteAci);
final var unrepairableIdentityKeyConflict = identityKeysExistAndConflict && conflictAci == null;
if (shouldUseRemoteIdentityKey(account.isPrimaryDevice(),
statesDiffer,
remoteIdentityKeySize,
localIdentityKeySize,
local.unregisteredAtTimestamp,
unrepairableIdentityKeyConflict)) {
identityState = remote.identityState;
identityKey = remote.identityKey;
} else {
identityState = local.identityState;
if (hasLocalIdentity && localIdentityKeySize > 0) {
identityKey = local.identityKey;
} else {
identityKey = ByteString.EMPTY;
}
}
PNI pni;
@ -178,6 +241,17 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
e164 = firstNonEmpty(remote.e164, local.e164);
}
if (identityKeysExistAndConflict) {
if (conflictAci != null) {
logger.debug("Identity keys conflict for {}. Enqueueing a profile fetch.", conflictAci);
jobExecutor.enqueueJob(new DownloadProfileJob(new RecipientAddress(conflictAci, pni, e164), true));
} else {
logger.debug("Identity keys conflict for {}. No ACI, so no profile fetch is possible.", localPni);
}
} else if (identityKey.size() > 0 && remoteIdentityKeySize == 0) {
logger.debug("Remote identity key is missing for {}. Keeping ours.", firstNonNull(localAci, localPni));
}
final var remoteProfileKey = remote.profileKey.size() == 0
|| KeyUtils.profileKeyOrNull(remote.profileKey.toByteArray()) == null
? ByteString.EMPTY
@ -194,6 +268,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
.identityState(identityState)
.identityKey(identityKey)
.blocked(remote.blocked)
.blockedAtTimestamp(remote.blockedAtTimestamp)
.whitelisted(remote.whitelisted)
.archived(remote.archived)
.markedUnread(remote.markedUnread)
@ -201,7 +276,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
.hideStory(remote.hideStory)
.unregisteredAtTimestamp(remote.unregisteredAtTimestamp)
.hidden(remote.hidden)
.pniSignatureVerified(remote.pniSignatureVerified || local.pniSignatureVerified)
.pniSignatureVerified((remote.pniSignatureVerified || local.pniSignatureVerified)
&& pni != null
&& pni.isValid())
.nickname(remote.nickname)
.note(remote.note)
.avatarColor(remote.avatarColor);
@ -236,6 +313,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
final var matchesLocal = doProtosMatch(merged, local);
if (matchesLocal) {
if (identityKeysExistAndConflict && conflictAci != null) {
identityConflictsPendingRepair.add(localRecord.getId());
}
return localRecord;
}
@ -272,7 +352,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
final var contactNickGivenName = contact == null ? null : contact.nickNameGivenName();
final var contactNickFamilyName = contact == null ? null : contact.nickNameFamilyName();
final var contactNote = contact == null ? null : contact.note();
final var blockedAt = contact == null ? 0 : contact.blockedAt();
if (blocked != contactProto.blocked
|| blockedAt != contactProto.blockedAtTimestamp
|| profileShared != contactProto.whitelisted
|| archived != contactProto.archived
|| hidden != contactProto.hidden
@ -290,6 +372,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
logger.debug("Storing new or updated contact {}", recipientId);
final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
final var newContact = contactBuilder.withIsBlocked(contactProto.blocked)
.withBlockedAt(contactProto.blocked ? contactProto.blockedAtTimestamp : 0)
.withIsProfileSharingEnabled(contactProto.whitelisted)
.withIsArchived(contactProto.archived)
.withIsHidden(contactProto.hidden)
@ -346,6 +429,8 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
logger.warn("Received invalid contact identity key from storage");
}
}
account.getRecipientStore()
.storePniSignatureVerified(connection, recipientId, contactProto.pniSignatureVerified);
account.getRecipientStore()
.storeStorageRecord(connection, recipientId, contactRecord.getId(), contactProto.encode());
}

View File

@ -6,7 +6,9 @@ import org.whispersystems.signalservice.api.storage.SignalRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.SQLException;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@ -24,6 +26,7 @@ abstract class DefaultStorageRecordProcessor<E extends SignalRecord<?>> implemen
private static final Logger logger = LoggerFactory.getLogger(DefaultStorageRecordProcessor.class);
private final Set<E> matchedRecords = new TreeSet<>(this);
private final Set<StorageId> updatedStorageIds = new HashSet<>();
/**
* One type of invalid remote data this handles is two records mapping to the same local data. We
@ -50,6 +53,7 @@ abstract class DefaultStorageRecordProcessor<E extends SignalRecord<?>> implemen
if (local.isEmpty()) {
debug(remote.getId(), remote, "[Local Insert] No matching local record. Inserting.");
updatedStorageIds.add(remote.getId());
insertLocal(remote);
return;
}
@ -64,6 +68,7 @@ abstract class DefaultStorageRecordProcessor<E extends SignalRecord<?>> implemen
matchedRecords.add(local.get());
final var merged = merge(remote, local.get());
updatedStorageIds.add(merged.getId());
if (!merged.equals(remote)) {
debug(remote.getId(), remote, "[Remote Update] " + merged.describeDiff(remote));
}
@ -75,8 +80,19 @@ abstract class DefaultStorageRecordProcessor<E extends SignalRecord<?>> implemen
}
}
public Set<StorageId> getUpdatedStorageIds() {
return Collections.unmodifiableSet(updatedStorageIds);
}
/**
* Optional extra identifying detail about a record, included in every log line for it.
*/
protected String describeRecord(E record) {
return "";
}
private void debug(StorageId i, E record, String message) {
logger.debug("[{}][{}] {}", i, record.getClass().getSimpleName(), message);
logger.debug("[{}][{}]{} {}", i, record.getClass().getSimpleName(), describeRecord(record), message);
}
/**

View File

@ -114,6 +114,7 @@ public final class GroupV1RecordProcessor extends DefaultStorageRecordProcessor<
final var group = account.getGroupStore().getOrCreateGroupV1(connection, groupIdV1);
if (group != null) {
group.setBlocked(groupV1Proto.blocked);
group.setBlockedAt(0);
account.getGroupStore().updateGroup(connection, group);
account.getGroupStore()
.storeStorageRecord(connection, group.getGroupId(), groupV1Record.getId(), groupV1Proto.encode());

View File

@ -56,6 +56,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
final var mergedBuilder = remote.newBuilder()
.masterKey(remote.masterKey)
.blocked(remote.blocked)
.blockedAtTimestamp(remote.blockedAtTimestamp)
.whitelisted(remote.whitelisted)
.archived(remote.archived)
.markedUnread(remote.markedUnread)
@ -93,6 +94,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
final var group = account.getGroupStore().getGroupOrPartialMigrate(connection, groupMasterKey);
group.setBlocked(groupV2Proto.blocked);
group.setBlockedAt(groupV2Proto.blocked ? groupV2Proto.blockedAtTimestamp : 0);
group.setProfileSharingEnabled(groupV2Proto.whitelisted);
account.getGroupStore().updateGroup(connection, group);
account.getGroupStore()

View File

@ -0,0 +1,86 @@
package org.asamk.signal.manager.syncStorage;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Optional;
public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<SignalStickerPackRecord> {
private static final int PACK_ID_LENGTH = 16;
private static final int PACK_KEY_LENGTH = 32;
private final SignalAccount account;
private final Connection connection;
public StickerPackRecordProcessor(final SignalAccount account, final Connection connection) {
this.account = account;
this.connection = connection;
}
@Override
public int compare(final SignalStickerPackRecord lhs, final SignalStickerPackRecord rhs) {
return lhs.getProto().packId.equals(rhs.getProto().packId) ? 0 : 1;
}
@Override
protected boolean isInvalid(final SignalStickerPackRecord remote) {
return remote.getProto().packId.size() != PACK_ID_LENGTH || (
remote.getProto().deletedAtTimestamp == 0 && remote.getProto().packKey.size() != PACK_KEY_LENGTH
);
}
@Override
protected Optional<SignalStickerPackRecord> getMatching(final SignalStickerPackRecord remote) throws SQLException {
final var packId = StickerPackId.deserialize(remote.getProto().packId.toByteArray());
final var local = account.getStickerStore().getStickerPack(connection, packId);
if (local == null || (!local.isInstalled() && local.deletedTimestamp() == 0)) {
return Optional.empty();
}
final StorageId storageId;
if (local.storageId() != null) {
storageId = local.storageId();
} else {
storageId = StorageId.forStickerPack(KeyUtils.createRawStorageId());
account.getStickerStore().updateStorageId(connection, packId, storageId);
}
return Optional.of(new SignalStickerPackRecord(storageId, StorageSyncModels.localToRemoteRecord(local)));
}
@Override
protected SignalStickerPackRecord merge(
final SignalStickerPackRecord remoteRecord,
final SignalStickerPackRecord localRecord
) {
final var remote = remoteRecord.getProto();
final var local = localRecord.getProto();
if (shouldKeepLocalDeletion(remote.deletedAtTimestamp, local.deletedAtTimestamp)) {
return localRecord;
}
return remoteRecord;
}
static boolean shouldKeepLocalDeletion(final long remoteDeletedAt, final long localDeletedAt) {
return remoteDeletedAt > 0 && localDeletedAt > 0 && localDeletedAt < remoteDeletedAt;
}
@Override
protected void insertLocal(final SignalStickerPackRecord record) throws SQLException {
account.getStickerStore().upsertFromStorageSync(connection, record);
}
@Override
protected void updateLocal(final StorageRecordUpdate<SignalStickerPackRecord> update) throws SQLException {
account.getStickerStore().upsertFromStorageSync(connection, update.newRecord());
}
}

View File

@ -0,0 +1,151 @@
package org.asamk.signal.manager.syncStorage;
import org.asamk.signal.manager.util.LeakyBucket;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.BooleanSupplier;
public final class StorageSyncLoopDetector {
private static final int FINGERPRINT_HISTORY = 3;
private final BooleanSupplier isMultiDevice;
private final List<Integer> recentFingerprints = new ArrayList<>();
private final LeakyBucket contentBucket = new LeakyBucket(3,
Duration.ofHours(1).toMillis(),
new InMemoryBucketState());
private final LeakyBucket rateBucket = new LeakyBucket(100,
Duration.ofMinutes(10).toMillis(),
new InMemoryBucketState());
private boolean lastAttemptChargedContent;
private boolean lastAttemptChargedRate;
public StorageSyncLoopDetector(final BooleanSupplier isMultiDevice) {
this.isMultiDevice = isMultiDevice;
}
public synchronized Decision onWriteAttempt(
final WriteOperationResult write,
final boolean fetchedRemoteManifest,
final boolean isRetry
) {
return onWriteAttempt(write, fetchedRemoteManifest, isRetry, System.currentTimeMillis());
}
synchronized Decision onWriteAttempt(
final WriteOperationResult write,
final boolean fetchedRemoteManifest,
final boolean isRetry,
final long now
) {
lastAttemptChargedContent = false;
lastAttemptChargedRate = false;
if (!isMultiDevice.getAsBoolean() || isRetry) {
return Decision.Allowed.INSTANCE;
}
final var fingerprint = fingerprint(write);
final var chargeContent = fetchedRemoteManifest && fingerprint != null && recentFingerprints.contains(
fingerprint);
if (chargeContent && !contentBucket.hasRoom(now)) {
return new Decision.Denied(Cause.REPEATED_PAYLOAD, contentBucket.level(now));
}
if (fetchedRemoteManifest && !rateBucket.hasRoom(now)) {
return new Decision.Denied(Cause.WRITE_RATE, rateBucket.level(now));
}
if (chargeContent) {
contentBucket.use(now);
lastAttemptChargedContent = true;
}
if (fetchedRemoteManifest) {
rateBucket.use(now);
lastAttemptChargedRate = true;
}
if (fingerprint != null) {
remember(fingerprint);
}
return Decision.Allowed.INSTANCE;
}
public synchronized void onWriteFailed() {
onWriteFailed(System.currentTimeMillis());
}
synchronized void onWriteFailed(final long now) {
if (lastAttemptChargedContent) {
contentBucket.refund(now);
lastAttemptChargedContent = false;
}
if (lastAttemptChargedRate) {
rateBucket.refund(now);
lastAttemptChargedRate = false;
}
}
public synchronized void onConverged() {
contentBucket.clear();
}
private void remember(final int fingerprint) {
recentFingerprints.remove(Integer.valueOf(fingerprint));
recentFingerprints.addFirst(fingerprint);
if (recentFingerprints.size() > FINGERPRINT_HISTORY) {
recentFingerprints.removeLast();
}
}
private static Integer fingerprint(final WriteOperationResult write) {
if (write.inserts().isEmpty()) {
return null;
}
return write.inserts()
.stream()
.map(record -> Arrays.hashCode(record.getProto().encode()))
.sorted()
.toList()
.hashCode();
}
private static final class InMemoryBucketState implements LeakyBucket.State {
private int level;
private long levelUpdatedAt;
@Override
public int level() {
return level;
}
@Override
public long levelUpdatedAt() {
return levelUpdatedAt;
}
@Override
public void update(final int level, final long levelUpdatedAt) {
this.level = level;
this.levelUpdatedAt = levelUpdatedAt;
}
}
public enum Cause {
REPEATED_PAYLOAD,
WRITE_RATE
}
public sealed interface Decision {
enum Allowed implements Decision {
INSTANCE
}
record Denied(Cause cause, int level) implements Decision {}
}
}

View File

@ -8,6 +8,7 @@ import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.identities.IdentityInfo;
import org.asamk.signal.manager.storage.recipients.Recipient;
import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.UuidUtil;
@ -16,12 +17,14 @@ import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink;
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord;
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState;
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record;
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record;
import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord;
import java.sql.Connection;
import java.sql.SQLException;
@ -92,9 +95,13 @@ public final class StorageSyncModels {
public static ContactRecord localToRemoteRecord(Recipient recipient, IdentityInfo identity) {
final var address = recipient.getAddress();
final var aciPresent = address.aci().isPresent();
final var builder = SignalContactRecord.Companion.newBuilder(recipient.getStorageRecord())
.e164(address.number().orElse(""))
.username(address.username().orElse(""))
.pniSignatureVerified(address.pni().map(PNI::isValid).orElse(false)
&& recipient.isPniSignatureVerified())
.profileKey(recipient.getProfileKey() == null
? ByteString.EMPTY
: ByteString.of(recipient.getProfileKey().serialize()));
@ -117,6 +124,7 @@ public final class StorageSyncModels {
.nickname(getNicknameRemoteRecord(recipient.getContact()))
.note(emptyIfNull(recipient.getContact().note()))
.blocked(recipient.getContact().isBlocked())
.blockedAtTimestamp(recipient.getContact().blockedAt())
.whitelisted(recipient.getContact().isProfileSharingEnabled())
.mutedUntilTimestamp(recipient.getContact().muteUntil())
.hideStory(recipient.getContact().hideStory())
@ -126,7 +134,7 @@ public final class StorageSyncModels {
.archived(recipient.getContact().isArchived())
.hidden(recipient.getContact().isHidden());
}
if (identity != null) {
if (identity != null && aciPresent) {
builder.identityKey(ByteString.of(identity.getIdentityKey().serialize()))
.identityState(localToRemote(identity.getTrustLevel()));
}
@ -155,10 +163,28 @@ public final class StorageSyncModels {
final var builder = SignalGroupV2Record.Companion.newBuilder(group.getStorageRecord());
builder.masterKey(ByteString.of(group.getMasterKey().serialize()));
builder.blocked(group.isBlocked());
builder.blockedAtTimestamp(group.getBlockedAt());
builder.whitelisted(group.isProfileSharingEnabled());
return builder.build();
}
public static StickerPackRecord localToRemoteRecord(StickerPack stickerPack) {
final var builder = SignalStickerPackRecord.Companion.newBuilder(stickerPack.storageRecord());
builder.packId(ByteString.of(stickerPack.packId().serialize()));
if (stickerPack.deletedTimestamp() > 0) {
builder.packKey(ByteString.EMPTY);
builder.position(0);
builder.deletedAtTimestamp(stickerPack.deletedTimestamp());
} else {
builder.packKey(ByteString.of(stickerPack.packKey()));
builder.position(stickerPack.position());
builder.deletedAtTimestamp(0);
}
return builder.build();
}
public static TrustLevel remoteToLocal(IdentityState identityState) {
return switch (identityState) {
case DEFAULT -> TrustLevel.TRUSTED_UNVERIFIED;

View File

@ -168,6 +168,11 @@ public final class StorageSyncValidations {
throw new DuplicateCallLinkError();
}
ids = manifest.getStorageIdsByType().get(ManifestRecord.Identifier.Type.STICKER_PACK.getValue());
if (ids.size() != new HashSet<>(ids).size()) {
throw new DuplicateStickerPackError();
}
throw new DuplicateRawIdAcrossTypesError();
}
@ -217,6 +222,8 @@ public final class StorageSyncValidations {
private static final class DuplicateInsertInWriteError extends Error {}
private static final class DuplicateStickerPackError extends Error {}
private static final class InsertNotPresentInFullIdSetError extends Error {}
private static final class DeletePresentInFullIdSetError extends Error {}

View File

@ -1,51 +1,50 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
import java.util.UUID;
import javax.imageio.ImageIO;
public class AttachmentUtils {
public static SignalServiceAttachmentStream createAttachmentStream(
String attachment,
boolean voiceNote,
ResumableUploadSpec resumableUploadSpec
) throws AttachmentInvalidException {
try {
final var streamDetails = Utils.createStreamDetails(attachment);
private static final Logger logger = LoggerFactory.getLogger(AttachmentUtils.class);
return createAttachmentStream(streamDetails.first(), streamDetails.second(), voiceNote, resumableUploadSpec);
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
}
}
public static SignalServiceAttachmentStream createAttachmentStream(
String attachment,
ResumableUploadSpec resumableUploadSpec
) throws AttachmentInvalidException {
return createAttachmentStream(attachment, false, resumableUploadSpec);
}
// Images are fully buffered in memory to probe their dimensions, so cap how large a file we'll do this for.
private static final long MAX_DIMENSION_PROBE_SIZE = 20 * 1024 * 1024;
public static SignalServiceAttachmentStream createAttachmentStream(
StreamDetails streamDetails,
Optional<String> name,
boolean voiceNote,
AttachmentDimensions dimensions,
String blurHash,
ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException {
) throws ResumeLocationInvalidException, IOException {
final var uploadTimestamp = System.currentTimeMillis();
final var probedStream = dimensions != null ? new ProbedStream(streamDetails.getStream(),
dimensions.width(),
dimensions.height()) : probeImageDimensions(streamDetails);
return SignalServiceAttachmentStream.newStreamBuilder()
.withStream(streamDetails.getStream())
.withStream(probedStream.inputStream())
.withContentType(streamDetails.getContentType())
.withLength(streamDetails.getLength())
.withFileName(name.orElse(null))
.withVoiceNote(voiceNote)
.withBlurHash(blurHash)
.withWidth(probedStream.width())
.withHeight(probedStream.height())
.withUploadTimestamp(uploadTimestamp)
.withResumableUploadSpec(resumableUploadSpec)
.withUuid(UUID.randomUUID())
@ -56,7 +55,55 @@ public class AttachmentUtils {
StreamDetails streamDetails,
Optional<String> name,
ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException {
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
) throws ResumeLocationInvalidException, IOException {
return createAttachmentStream(streamDetails, name, false, null, null, resumableUploadSpec);
}
/**
* Reads the attachment's dimensions if it's an image, so recipients don't get a square-cropped thumbnail.
* Falls back to width/height 0 (today's behavior) if the content isn't an image, is too large to probe
* cheaply, or fails to parse.
*/
private static ProbedStream probeImageDimensions(StreamDetails streamDetails) throws IOException {
final var contentType = streamDetails.getContentType();
final var length = streamDetails.getLength();
if (contentType == null
|| !contentType.startsWith("image/")
|| length <= 0
|| length > MAX_DIMENSION_PROBE_SIZE) {
return new ProbedStream(streamDetails.getStream(), 0, 0);
}
final var stream = streamDetails.getStream();
var width = 0;
var height = 0;
if (stream instanceof FileInputStream fis) {
try {
final var image = ImageIO.read(fis);
if (image != null) {
width = image.getWidth();
height = image.getHeight();
}
fis.getChannel().position(0);
} catch (IOException | LinkageError e) {
logger.debug("Failed to probe image dimensions, sending without width/height: {}", e.getMessage());
fis.getChannel().position(0);
}
return new ProbedStream(fis, width, height);
}
final var bytes = stream.readAllBytes();
try {
final var image = ImageIO.read(new ByteArrayInputStream(bytes));
if (image != null) {
width = image.getWidth();
height = image.getHeight();
}
} catch (IOException | LinkageError e) {
logger.debug("Failed to probe image dimensions, sending without width/height: {}", e.getMessage());
}
return new ProbedStream(new ByteArrayInputStream(bytes), width, height);
}
private record ProbedStream(InputStream inputStream, int width, int height) {}
}

View File

@ -21,9 +21,19 @@ import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE;
public class IOUtils {
public static File createTempFile() throws IOException {
final var tempFile = File.createTempFile("signal-cli_tmp_", ".tmp");
tempFile.deleteOnExit();
return tempFile;
final var prefix = "signal-cli_tmp_";
final var suffix = ".tmp";
try {
Set<PosixFilePermission> perms = EnumSet.of(OWNER_READ, OWNER_WRITE);
var path = Files.createTempFile(prefix, suffix, PosixFilePermissions.asFileAttribute(perms));
var tempFile = path.toFile();
tempFile.deleteOnExit();
return tempFile;
} catch (UnsupportedOperationException e) {
final var tempFile = File.createTempFile(prefix, suffix);
tempFile.deleteOnExit();
return tempFile;
}
}
public static byte[] readFully(InputStream in) throws IOException {

View File

@ -0,0 +1,60 @@
package org.asamk.signal.manager.util;
public final class LeakyBucket {
private final int capacity;
private final long dripIntervalMillis;
private final State state;
public LeakyBucket(final int capacity, final long dripIntervalMillis, final State state) {
this.capacity = capacity;
this.dripIntervalMillis = dripIntervalMillis;
this.state = state;
}
public int level(final long now) {
return calculateStateForCurrentTime(now).level();
}
public boolean hasRoom(final long now) {
return level(now) < capacity;
}
public void use(final long now) {
final var currentState = calculateStateForCurrentTime(now);
state.update(currentState.level() + 1, currentState.levelUpdatedAt());
}
public void refund(final long now) {
final var currentState = calculateStateForCurrentTime(now);
state.update(Math.max(currentState.level() - 1, 0), currentState.levelUpdatedAt());
}
public void clear() {
state.update(0, 0);
}
private Snapshot calculateStateForCurrentTime(final long now) {
final var level = state.level();
final var levelUpdatedAt = state.levelUpdatedAt();
final var elapsed = now - levelUpdatedAt;
if (level <= 0 || elapsed < 0) {
return new Snapshot(0, now);
}
final var drips = elapsed / dripIntervalMillis;
return new Snapshot((int) Math.max(level - drips, 0), levelUpdatedAt + dripIntervalMillis * drips);
}
private record Snapshot(int level, long levelUpdatedAt) {}
public interface State {
int level();
long levelUpdatedAt();
void update(int level, long levelUpdatedAt);
}
}

View File

@ -10,11 +10,11 @@ import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.asamk.signal.manager.helper.PinHelper;
import org.signal.core.models.MasterKey;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.exceptions.ChallengeRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.TokenNotAcceptedException;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.internal.push.LockedException;
@ -65,14 +65,12 @@ public class NumberVerificationUtils {
if (nextAttempt == null) {
throw new VerificationMethodNotAvailableException();
} else if (nextAttempt > 0) {
final var timestamp = sessionResponse.getClientReceivedAtMilliseconds() + nextAttempt * 1000;
throw new RateLimitException(timestamp);
throw new RateLimitException(nextAttempt * 1000L);
}
final var nextVerificationAttempt = sessionResponse.getMetadata().getNextVerificationAttempt();
if (nextVerificationAttempt != null && nextVerificationAttempt > 0) {
final var timestamp = sessionResponse.getClientReceivedAtMilliseconds() + nextVerificationAttempt * 1000;
throw new CaptchaRequiredException(timestamp);
throw new CaptchaRequiredException(nextVerificationAttempt * 1000L);
}
if (sessionResponse.getMetadata().getRequestedInformation().contains("captcha")) {

View File

@ -42,6 +42,19 @@ public class PhoneNumberFormatter {
throw new InvalidNumberException("No valid characters found.");
}
if (localNumber == null) {
if (!number.startsWith("+")) {
throw new InvalidNumberException(
"Use an international number including the country code for a numberless account.");
}
try {
final var util = PhoneNumberUtil.getInstance();
return util.format(util.parse(number, null), PhoneNumberFormat.E164);
} catch (NumberParseException e) {
throw new InvalidNumberException("Invalid international phone number.");
}
}
try {
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
PhoneNumber localNumberObject = util.parse(localNumber, null);

View File

@ -1,5 +1,6 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.Pair;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.net.BadRequestError;
@ -7,9 +8,9 @@ import org.signal.libsignal.net.RequestResult;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.fingerprint.Fingerprint;
import org.signal.libsignal.protocol.fingerprint.NumericFingerprintGenerator;
import org.signal.network.NetworkResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.NetworkResult;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.whispersystems.signalservice.api.util.StreamDetails;
@ -17,7 +18,6 @@ import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.Proxy;
import java.net.ProxySelector;
@ -39,6 +39,9 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import kotlin.coroutines.Continuation;
import kotlin.coroutines.EmptyCoroutineContext;
import kotlinx.coroutines.BuildersKt;
import okio.ByteString;
public class Utils {
@ -53,7 +56,7 @@ public class Utils {
}
public static StreamDetails createStreamDetailsFromFile(final File file) throws IOException {
final InputStream stream = new FileInputStream(file);
final var stream = new FileInputStream(file);
final var size = file.length();
final var mime = MimeUtils.getFileMimeType(file).orElse(MimeUtils.OCTET_STREAM);
return new StreamDetails(stream, mime, size);
@ -161,6 +164,24 @@ public class Utils {
return NetworkResultUtil.toBasicLegacy(response);
}
@SuppressWarnings("unchecked")
public static <T> T runSuspendBlocking(final Function<Continuation<? super T>, Object> call) {
try {
return (T) BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, cont) -> call.apply((Continuation<? super T>) cont));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while waiting for suspend function", e);
}
}
@SuppressWarnings("unchecked")
public static <T, E extends BadRequestError> T handleResponseExceptionSuspend(
final Function<Continuation<? super RequestResult>, Object> call
) throws IOException {
return handleResponseException((RequestResult<T, E>) runSuspendBlocking((Function) call));
}
@SuppressWarnings("unchecked")
public static <T, E extends BadRequestError> T handleResponseException(final RequestResult<T, E> result) throws IOException {
if (result instanceof RequestResult.Success<?> success) {
@ -174,8 +195,8 @@ public class Utils {
}
} else if (result instanceof RequestResult.RetryableNetworkError e) {
throw e.getNetworkError();
} else if (result instanceof RequestResult.NonSuccess) {
throw new AssertionError();
} else if (result instanceof RequestResult.NonSuccess<?> e) {
throw new BadRequestException(e.getError());
}
throw new IllegalStateException("Unexpected value: " + result);
}

View File

@ -0,0 +1,57 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.signal.core.models.ServiceId.ACI;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NumberlessGroupTest {
@TempDir
Path directory;
@Test
void findsOurGroupMembershipByAciWhenPniIsAbsent() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var otherAci = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT); final var context = new Context(account, null, null, null, null, null)) {
account.setProvisioningData(null,
aci,
null,
"test-password",
new byte[]{1},
KeyUtils.generateIdentityKeyPair(),
null,
KeyUtils.createProfileKey(),
null,
new byte[32],
null);
final var otherMember = new DecryptedMember.Builder().aciBytes(otherAci.toByteString())
.joinedAtRevision(1)
.build();
final var selfMember = new DecryptedMember.Builder().aciBytes(aci.toByteString())
.joinedAtRevision(4)
.build();
final var group = new DecryptedGroup.Builder().revision(9)
.members(List.of(otherMember, selfMember))
.build();
assertEquals(4, context.getGroupV2Helper().findRevisionWeWereAdded(group));
assertEquals(9,
context.getGroupV2Helper()
.findRevisionWeWereAdded(group.newBuilder().members(List.of(otherMember)).build()));
}
}
}

View File

@ -0,0 +1,53 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.syncStorage.WriteOperationResult;
import org.junit.jupiter.api.Test;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StorageHelperTest {
@Test
void removesKnownTypeLocalOnlyIdsStoredAsUnknown() {
final var staleStickerPackId = StorageId.forStickerPack(new byte[]{1});
final var localContactId = StorageId.forContact(new byte[]{2});
final var result = StorageHelper.findUnknownOnlyLocalStorageIds(List.of(staleStickerPackId, localContactId),
Set.of(staleStickerPackId));
assertEquals(List.of(staleStickerPackId), result);
}
@Test
void defersWritesContainingOnlyIdentityConflictsPendingRepair() {
final var pendingId = StorageId.forContact(new byte[]{1});
final var otherId = StorageId.forContact(new byte[]{2});
final var pendingRecord = record(pendingId);
final var otherRecord = record(otherId);
assertTrue(StorageHelper.containsOnlyIdentityConflictsPendingRepair(write(List.of(pendingRecord)),
Set.of(pendingId)));
assertFalse(StorageHelper.containsOnlyIdentityConflictsPendingRepair(write(List.of(pendingRecord, otherRecord)),
Set.of(pendingId)));
assertFalse(StorageHelper.containsOnlyIdentityConflictsPendingRepair(write(List.of()), Set.of(pendingId)));
assertFalse(StorageHelper.containsOnlyIdentityConflictsPendingRepair(new WriteOperationResult(null,
List.of(pendingRecord),
List.of(new byte[]{3})), Set.of(pendingId)));
}
private static SignalStorageRecord record(final StorageId id) {
return new SignalStorageRecord(id, new StorageRecord.Builder().build());
}
private static WriteOperationResult write(final List<SignalStorageRecord> inserts) {
return new WriteOperationResult(null, inserts, List.of());
}
}

View File

@ -0,0 +1,182 @@
package org.asamk.signal.manager.internal;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.rest.SignalRestClient;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
import java.io.IOException;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicReference;
import okhttp3.Credentials;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.ByteString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class NumberlessProvisioningTest {
private static final ACI ACI_ID = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
private static final PNI PNI_ID = PNI.parseOrThrow("22222222-2222-4222-8222-222222222222");
@TempDir
Path directory;
@Test
void numberlessProvisioningRequiresGroupCredentialSalt() {
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().build()));
final var emptySalt = new ProvisionMessage.Builder().authCredentialSalt(ByteString.EMPTY).build();
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(emptySalt));
assertNull(ProvisioningManagerImpl.toRegistrationPreKeys(null));
}
@Test
void acceptsNumberlessProvisioningButRejectsInconsistentPhoneIdentity() throws Exception {
final var message = new ProvisionMessage.Builder().authCredentialSalt(ByteString.of(new byte[32]));
assertNull(ProvisioningManagerImpl.parsePni(message.build()));
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(message.pni(PNI_ID.toString()).build()));
assertThrows(IllegalArgumentException.class,
() -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123").build()));
assertEquals(PNI_ID,
ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123")
.pni(PNI_ID.toString())
.build()));
}
@Test
void numberlessLinkRequestOmitsPniAndAuthenticatesWithAci() throws Exception {
checkLinkRequest(null, null, 200, null);
}
@Test
void numberedLinkRequestStillIncludesPniKeys() throws Exception {
checkLinkRequest("+12025550123", PNI_ID, 200, null);
}
@ParameterizedTest
@CsvSource({
"422, Signal rejected the device linking request",
"403, [403] Device verification failed",
"409, Linked device is missing a required account capability",
"411, Account has reached its linked device limit",
"429, Device linking rate limited; try again later"
})
void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody(
final int statusCode,
final String errorMessage
) throws Exception {
checkLinkRequest(null, null, statusCode, errorMessage);
}
private void checkLinkRequest(
final String number,
final PNI pni,
final int statusCode,
final String errorMessage
) throws Exception {
final var request = new AtomicReference<Request>();
final var body = new AtomicReference<JsonNode>();
final var mapper = new ObjectMapper();
final var client = new OkHttpClient.Builder().addInterceptor(chain -> {
request.set(chain.request());
final var buffer = new Buffer();
chain.request().body().writeTo(buffer);
body.set(mapper.readTree(buffer.readUtf8()));
return new Response.Builder().request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(statusCode)
.message("Test response")
.body(ResponseBody.create(statusCode == 200 ? "{\"deviceId\":2}" : "secret payload",
MediaType.get("application/json")))
.build();
}).build();
final var config = ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test");
final var restClient = new SignalRestClient(config.signalServiceConfiguration(),
"signal-cli-test",
null,
false,
1000L,
new SecureRandom(),
client);
final var api = new RegistrationApiV2(restClient, false);
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT)) {
account.setProvisioningData(number,
ACI_ID,
pni,
"test-password",
new byte[]{1},
KeyUtils.generateIdentityKeyPair(),
pni == null ? null : KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
null,
new byte[32],
null);
final var aciKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniKeys = pni == null
? null
: KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
if (statusCode == 200) {
assertEquals(2,
ProvisioningManagerImpl.registerLinkedDevice(api, account, "test-code", aciKeys, pniKeys));
} else {
final var error = assertThrows(IOException.class,
() -> ProvisioningManagerImpl.registerLinkedDevice(api,
account,
"test-code",
aciKeys,
pniKeys));
assertEquals(errorMessage, error.getMessage());
if (statusCode == 403) {
assertInstanceOf(AuthorizationFailedException.class, error);
}
}
assertEquals("PUT", request.get().method());
assertEquals("/v1/devices/link", request.get().url().encodedPath());
assertEquals(Credentials.basic(ACI_ID.toString(), "test-password"), request.get().header("Authorization"));
assertTrue(body.get()
.path("accountAttributes")
.path("capabilities")
.path("optionalPhoneNumber")
.asBoolean());
assertTrue(body.get().hasNonNull("aciSignedPreKey"));
assertTrue(body.get().hasNonNull("aciPqLastResortPreKey"));
assertEquals(pni != null, body.get().has("pniSignedPreKey"));
assertEquals(pni != null, body.get().has("pniPqLastResortPreKey"));
assertEquals(pni != null, body.get().path("accountAttributes").has("pniRegistrationId"));
} finally {
client.dispatcher().executorService().shutdown();
client.connectionPool().evictAll();
}
}
}

View File

@ -0,0 +1,115 @@
package org.asamk.signal.manager.storage;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.util.KeyUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.signal.core.models.AccountEntropyPool;
import org.signal.core.models.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class NumberlessAccountTest {
@TempDir
Path directory;
@Test
void numberlessLinkedAccountSurvivesReload() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var identity = KeyUtils.generateIdentityKeyPair();
final var salt = new byte[32];
salt[0] = 42;
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT)) {
account.setProvisioningData(null,
aci,
null,
"test-password",
new byte[]{1},
identity,
null,
KeyUtils.createProfileKey(),
null,
salt,
null);
account.finishLinking(2, KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)), null);
}
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
assertTrue(account.isRegistered());
assertFalse(account.isPrimaryDevice());
assertEquals(2, account.getDeviceId());
assertEquals(aci, account.getAci());
assertNull(account.getNumber());
assertNull(account.getPni());
assertNull(account.getPniIdentityKeyPair());
assertNull(account.getSignalServiceDataStore().pniOrNull());
assertArrayEquals(identity.serialize(), account.getAciIdentityKeyPair().serialize());
assertArrayEquals(salt, account.getAuthCredentialSalt());
assertNull(account.getAccountAttributesV2().getPniRegistrationId());
assertNull(account.getAccountAttributesV2().getDiscoverableByPhoneNumber());
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
}
}
@Test
void recoveredNumberlessPrimaryPreservesRecoveryMaterialAndDropsPniIdentity() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var entropyPool = AccountEntropyPool.Companion.generate();
final var salt = new byte[32];
salt[0] = 42;
try (final var account = SignalAccount.create(directory.toFile(),
"account",
null,
aci,
ServiceEnvironment.STAGING,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
Settings.DEFAULT)) {
final var aciPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
account.finishRecoveryRegistration(aci, null, null, entropyPool, salt, aciPreKeys, pniPreKeys);
}
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
assertTrue(account.isRegistered());
assertTrue(account.isPrimaryDevice());
assertEquals(aci, account.getAci());
assertNull(account.getNumber());
assertNull(account.getPni());
assertNull(account.getPniIdentityKeyPair());
assertEquals(entropyPool.getValue(), account.getAccountEntropyPool().getValue());
assertArrayEquals(salt, account.getAuthCredentialSalt());
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
}
}
@Test
void recoveryAttributesOmitPhoneNumberDiscoverabilityForNumberedAccount() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
try (final var account = SignalAccount.create(directory.toFile(),
"account",
"+12025550123",
aci,
ServiceEnvironment.STAGING,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
Settings.DEFAULT)) {
assertNull(account.getAccountAttributesV2ForRecovery(null, "recovery-password")
.getDiscoverableByPhoneNumber());
}
}
}

View File

@ -0,0 +1,81 @@
package org.asamk.signal.manager.storage.accounts;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.signal.core.models.ServiceId.ACI;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AccountsStoreTest {
private static final ACI OLD_ACI = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
private static final ACI NEW_ACI = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
@TempDir
Path directory;
@Test
void discoversNumberlessAccountsByAciAndKeepsEnvironmentsSeparate() throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var path = store.addAccount(null, aci);
store.addAccount("+12025550123", null);
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, ignored -> null);
assertEquals(2, reopened.getAllAccounts().size());
assertEquals(path, reopened.getPathByAci(aci));
assertNull(reopened.getPathByNumber(null));
assertEquals(Set.of("+12025550123"), reopened.getAllNumbers());
assertTrue(new AccountsStore(directory.toFile(), ServiceEnvironment.LIVE, ignored -> null).getAllAccounts()
.isEmpty());
}
@Test
void updatingAnAciDoesNotLeaveDuplicateNumberlessAccounts() throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var oldPath = store.addAccount(null, aci);
Files.createFile(directory.resolve(oldPath));
final var newPath = store.addAccount(null, null);
store.updateAccount(newPath, null, aci);
assertEquals(newPath, store.getPathByAci(aci));
assertEquals(1, store.getAllAccounts().size());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void replacingANumberKeepsTheOldAccountAvailableByAci(final boolean update) throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var oldPath = store.addAccount("+12025550123", OLD_ACI);
Files.createFile(directory.resolve(oldPath));
final String newPath;
if (update) {
newPath = store.addAccount("+12025550124", NEW_ACI);
store.updateAccount(newPath, "+12025550123", NEW_ACI);
} else {
newPath = store.addAccount("+12025550123", NEW_ACI);
}
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
assertEquals(Set.of(oldPath, newPath), getAccountPaths(reopened));
assertEquals(newPath, reopened.getPathByNumber("+12025550123"));
assertEquals(oldPath, reopened.getPathByAci(OLD_ACI));
}
private Set<String> getAccountPaths(final AccountsStore store) throws IOException {
return store.getAllAccounts().stream().map(AccountsStorage.Account::path).collect(Collectors.toSet());
}
}

Some files were not shown because too many files have changed in this diff Show More