Compare commits

...

68 Commits

Author SHA1 Message Date
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
146 changed files with 2710 additions and 937 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

View File

@ -5,106 +5,35 @@ 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
- name: Extract archive
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-*/
mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* .
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create release
id: create_release
@ -112,8 +41,8 @@ jobs:
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`
tag_name: v${{ steps.version.outputs.version }} # note: added `v`
release_name: v${{ steps.version.outputs.version }} # note: added `v`
draft: true
- name: Upload archive
@ -122,19 +51,9 @@ jobs:
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
asset_path: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux native archive
uses: actions/upload-release-asset@v1
@ -142,9 +61,9 @@ jobs:
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
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.version.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
@ -152,35 +71,14 @@ jobs:
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
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
build-container:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -188,28 +86,19 @@ 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
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
@ -227,10 +116,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,26 +126,20 @@ 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
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
@ -275,10 +157,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,26 +167,20 @@ 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
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

7
.gitignore vendored
View File

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

View File

@ -1,5 +1,50 @@
# Changelog
## [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

@ -148,6 +148,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.2"
}
allprojects {
group = "org.asamk"
version = "0.14.2"
version = "0.14.5"
}
java {
@ -72,6 +74,11 @@ val excludePatterns = mapOf(
)
)
val schemaAnnotationProcessor by configurations.creating {
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 {

323
client/Cargo.lock generated
View File

@ -4,9 +4,9 @@ version = 4
[[package]]
name = "anstream"
version = "0.6.21"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
@ -19,15 +19,15 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.13"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
@ -83,9 +83,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bytes"
@ -95,9 +95,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
version = "1.2.56"
version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"shlex",
@ -117,9 +117,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.60"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
@ -127,9 +127,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.60"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
@ -140,9 +140,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.55"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
@ -152,15 +152,15 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "1.0.0"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
@ -326,9 +326,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.16.1"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heck"
@ -377,9 +377,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@ -391,7 +391,6 @@ dependencies = [
"httparse",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@ -399,16 +398,15 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.7"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
@ -436,12 +434,13 @@ dependencies = [
[[package]]
name = "icu_collections"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@ -449,9 +448,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@ -462,9 +461,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@ -476,15 +475,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.1.2"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@ -496,15 +495,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.1.2"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@ -538,9 +537,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.13.0"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
@ -554,9 +553,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
@ -567,7 +566,7 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
@ -576,9 +575,31 @@ dependencies = [
[[package]]
name = "jni-sys"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "jsonrpsee"
@ -668,9 +689,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "linux-raw-sys"
@ -680,9 +701,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
@ -698,9 +719,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
@ -709,9 +730,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.3"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
@ -757,26 +778,20 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "potential_utf"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
@ -792,9 +807,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.44"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@ -815,9 +830,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
version = "2.1.1"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
@ -834,9 +849,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
dependencies = [
"log",
"once_cell",
@ -897,9 +912,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
@ -917,9 +932,9 @@ dependencies = [
[[package]]
name = "schannel"
version = "0.1.28"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
@ -1026,12 +1041,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -1082,12 +1097,12 @@ dependencies = [
[[package]]
name = "terminal_size"
version = "0.4.3"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -1132,9 +1147,9 @@ dependencies = [
[[package]]
name = "tinystr"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@ -1142,9 +1157,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.49.0"
version = "1.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776"
dependencies = [
"bytes",
"libc",
@ -1157,9 +1172,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@ -1202,18 +1217,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
version = "0.25.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [
"indexmap",
"toml_datetime",
@ -1223,9 +1238,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
@ -1360,14 +1375,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
dependencies = [
"webpki-root-certs 1.0.6",
"webpki-root-certs 1.0.7",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
@ -1414,15 +1429,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -1456,30 +1462,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@ -1492,12 +1481,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@ -1510,12 +1493,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@ -1528,24 +1505,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@ -1558,12 +1523,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@ -1576,12 +1535,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@ -1594,12 +1547,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@ -1612,32 +1559,26 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
[[package]]
name = "writeable"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yoke"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@ -1646,9 +1587,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@ -1658,18 +1599,18 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@ -1685,9 +1626,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@ -1696,9 +1637,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.5"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@ -1707,9 +1648,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.11.2"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",

View File

@ -45,6 +45,15 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<releases>
<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,19 +1,26 @@
[versions]
slf4j = "2.0.17"
junit = "6.0.2"
slf4j = "2.0.18"
junit = "6.1.0"
micronaut-json-schema = "2.0.1"
micronaut-core = "5.0.0"
signal-service = "2.15.3_unofficial_148"
[libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.83"
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.20.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"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_142"
sqlite = "org.xerial:sqlite-jdbc:3.51.2.0"
signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
sqlite = "org.xerial:sqlite-jdbc:3.53.1.0"
hikari = "com.zaxxer:HikariCP:7.0.2"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", 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.5.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

31
gradlew.bat vendored
View File

@ -23,8 +23,8 @@
@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,7 +65,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
:execute
@rem Setup the command line
@ -73,21 +73,10 @@ goto fail
@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 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))

View File

@ -259,7 +259,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

@ -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

@ -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)),
@ -268,19 +267,19 @@ public record MessageEnvelope(
quote.getMentions() == null
? List.of()
: quote.getMentions()
.stream()
.map(m -> Mention.from(m, recipientResolver, addressResolver))
.toList(),
.stream()
.map(m -> Mention.from(m, recipientResolver, addressResolver))
.toList(),
quote.getAttachments() == null
? List.of()
: quote.getAttachments().stream().map(a -> Attachment.from(a, fileProvider)).toList(),
quote.getBodyRanges() == null
? List.of()
: quote.getBodyRanges()
.stream()
.filter(r -> r.style != null)
.map(TextStyle::from)
.toList());
.stream()
.filter(r -> r.style != null)
.map(TextStyle::from)
.toList());
}
}
@ -599,7 +598,7 @@ public record MessageEnvelope(
Boolean.TRUE.equals(pinnedMessage.getForever())
? -1
: pinnedMessage.getPinDurationInSeconds() == null
? 0
? 0
: pinnedMessage.getPinDurationInSeconds());
}
}
@ -1028,18 +1027,18 @@ 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
? recipientResolver.resolveRecipient(content.getSender())
? recipientResolver.resolveRecipient(content.getSender())
: exception instanceof ProtocolException e
? recipientResolver.resolveRecipient(e.getSender())
? recipientResolver.resolveRecipient(e.getSender())
: null;
final var sourceDevice = envelope.hasSourceDevice()
? envelope.getSourceDevice()
: content != null
? content.getSenderDevice()
? content.getSenderDevice()
: exception instanceof ProtocolException e ? e.getSenderDevice() : 0;
Optional<Receipt> receipt;

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

@ -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

@ -30,7 +30,9 @@ 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 = "ced8217b26228e4b210c985786999d095c4958a94faf37b14acaf25c4cbb02a4";
private static final String SVR2_MRENCLAVE_LEGACY = "1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708";
private static final String SVR2_MRENCLAVE_OLD_LEGACY = "29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb";
private static final String URL = "https://chat.signal.org";
private static final String CDN_URL = "https://cdn.signal.org";
@ -93,7 +95,7 @@ class LiveConfig {
createDefaultServiceConfiguration(interceptors),
getUnidentifiedSenderTrustRoots(),
CDSI_MRENCLAVE,
List.of(SVR2_MRENCLAVE));
List.of(SVR2_MRENCLAVE, SVR2_MRENCLAVE_LEGACY, SVR2_MRENCLAVE_OLD_LEGACY));
}
private LiveConfig() {

View File

@ -31,7 +31,8 @@ public class ServiceConfig {
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
final var attachmentBackfill = !isPrimaryDevice;
final var spqr = true;
return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr);
final var usernameSyncChangeMessage = !isPrimaryDevice;
return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr, usernameSyncChangeMessage);
}
public static ServiceEnvironmentConfig getServiceEnvironmentConfig(

View File

@ -30,7 +30,9 @@ 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 = "3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302";
private static final String SVR2_MRENCLAVE_LEGACY = "97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535";
private static final String SVR2_MRENCLAVE_OLD_LEGACY = "a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036";
private static final String URL = "https://chat.staging.signal.org";
private static final String CDN_URL = "https://cdn-staging.signal.org";
@ -93,7 +95,7 @@ class StagingConfig {
createDefaultServiceConfiguration(interceptors),
getUnidentifiedSenderTrustRoots(),
CDSI_MRENCLAVE,
List.of(SVR2_MRENCLAVE));
List.of(SVR2_MRENCLAVE, SVR2_MRENCLAVE_LEGACY, SVR2_MRENCLAVE_OLD_LEGACY));
}
private StagingConfig() {

View File

@ -25,6 +25,7 @@ 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.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
@ -37,7 +38,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;

View File

@ -6,15 +6,19 @@ 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 +34,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,7 +50,10 @@ public class AttachmentHelper {
return attachmentStore.retrieveAttachment(id);
}
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments, boolean voiceNote) throws AttachmentInvalidException, IOException {
public List<SignalServiceAttachment> uploadAttachments(
final List<String> attachments,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
try {
@ -65,21 +74,63 @@ public class AttachmentHelper {
return uploadAttachments(attachments, false);
}
private List<SignalServiceAttachmentStream> createAttachmentStreams(List<String> attachments, boolean voiceNote) throws AttachmentInvalidException, IOException {
private List<SignalServiceAttachmentStream> createAttachmentStreams(
List<String> attachments,
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));
final var attachmentStream = getAttachmentStream(attachment, voiceNote);
signalServiceAttachments.add(attachmentStream);
}
return signalServiceAttachments;
}
private SignalServiceAttachmentStream getAttachmentStream(
final String attachment,
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,
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, 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

@ -55,7 +55,7 @@ public class ContactHelper {
final var version = contact == null
? 1
: contact.messageExpirationTimeVersion() == Integer.MAX_VALUE
? Integer.MAX_VALUE
? Integer.MAX_VALUE
: contact.messageExpirationTimeVersion() + 1;
account.getContactStore()
.storeContact(recipientId,

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));
}
@ -558,16 +558,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 +734,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 +776,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 +785,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;
@ -501,18 +501,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(

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,7 +332,12 @@ 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);
@ -874,11 +900,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 +1068,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 +1079,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 +1091,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 (!account.getAci().equals(destination) && !account.getPni().equals(destination)) {
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;
@ -134,7 +134,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;
}
@ -191,9 +193,11 @@ public final class ProfileHelper {
if (uploadProfile) {
final var streamDetails = avatar != null && avatar.isPresent()
? Utils.createStreamDetails(avatar.get())
.first()
: forceUploadAvatar && avatar == null ? context.getAvatarStore()
.retrieveProfileAvatar(account.getSelfRecipientAddress()) : null;
.first()
: forceUploadAvatar && avatar == null
? context.getAvatarStore()
.retrieveProfileAvatar(account.getSelfRecipientAddress())
: null;
try (streamDetails) {
final var avatarUploadParams = streamDetails != null
? AvatarUploadParams.forAvatar(streamDetails)
@ -261,7 +265,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 +385,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,7 +9,6 @@ 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;
@ -150,10 +149,10 @@ public class ReceiveHelper {
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);
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);
@ -238,7 +237,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 +291,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

@ -524,11 +524,11 @@ public class SendHelper {
Set<RecipientId> senderKeyTargets = groupInfo.getDistributionId() == null || groupSendEndorsements == null
? Set.of()
: recipientIds.stream()
.filter(s -> this.isSenderKeyCapable(s,
addressesMap.get(s),
unidentifiedAccessesMap.get(s),
groupSendEndorsements))
.collect(Collectors.toSet());
.filter(s -> this.isSenderKeyCapable(s,
addressesMap.get(s),
unidentifiedAccessesMap.get(s),
groupSendEndorsements))
.collect(Collectors.toSet());
if (senderKeyTargets.size() < 2) {
logger.debug("Too few sender-key-capable users ({}). Doing all legacy sends.", senderKeyTargets.size());
senderKeyTargets = Set.of();
@ -590,11 +590,11 @@ public class SendHelper {
final var expirationMs = Instant.ofEpochMilli(groupSendEndorsementsExpirationMs);
final var groupSendTokens = groupSendEndorsements != null && groupSecretParams != null
? legacyTargets.stream()
.map(groupSendEndorsements::get)
.map(endorsement -> Optional.ofNullable(endorsement)
.map(e -> e.toFullToken(groupSecretParams, expirationMs))
.orElse(null))
.toList()
.map(groupSendEndorsements::get)
.map(endorsement -> Optional.ofNullable(endorsement)
.map(e -> e.toFullToken(groupSecretParams, expirationMs))
.orElse(null))
.toList()
: null;
final var sealedSenderAccesses = SealedSenderAccess.forFanOutGroupSend(groupSendTokens,
senderCertificate,

View File

@ -2,6 +2,7 @@ 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.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
@ -17,6 +18,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 +29,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,6 +39,7 @@ 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.stream.Collectors;
@ -211,20 +213,23 @@ 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);
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);
if (updated > 0) {
logger.warn(
"Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.",
updated);
}
}
final var unknownDeletes = idDifference.localOnlyIds()
.stream()
.filter(id -> !KNOWN_TYPES.contains(id.getType()))
@ -480,13 +485,13 @@ 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 void storeManifestLocally(
@ -504,7 +509,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 +519,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);
};
}
@ -630,16 +635,17 @@ 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
) 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());
for (final var record : records) {
if (record.getProto().account != null) {
@ -662,8 +668,12 @@ public class StorageHelper {
unknownRecords.add(record.getId());
}
}
processedRecords.addAll(accountRecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(groupV1RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(contactRecordProcessor.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();

View File

@ -101,6 +101,7 @@ import org.signal.core.util.Base64;
import org.signal.core.util.Hex;
import org.signal.libsignal.protocol.InvalidMessageException;
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.messages.SignalServiceAttachment;
@ -117,7 +118,6 @@ import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMess
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;
@ -190,6 +190,7 @@ public class ManagerImpl implements Manager {
userAgent,
account.getCredentialsProvider(),
account.getSignalServiceDataStore(),
account.getDeviceId(),
executor,
sessionLock);
final var avatarStore = new AvatarStore(pathConfig.avatarsPath());
@ -278,7 +279,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 -> {
@ -288,7 +289,7 @@ public class ManagerImpl implements Manager {
final var profile = serviceId == null
? null
: context.getProfileHelper()
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
return new UserStatus(number.isEmpty() ? null : number,
serviceId == null ? null : serviceId.getRawUuid(),
profile != null
@ -315,7 +316,7 @@ public class ManagerImpl implements Manager {
final var profile = serviceId == null
? null
: context.getProfileHelper()
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
return new UsernameStatus(username,
serviceId == null ? null : serviceId.getRawUuid(),
profile != null
@ -694,7 +695,10 @@ public class ManagerImpl implements Manager {
)) {
final var result = notifySelf
? context.getSendHelper()
.sendMessage(messageBuilder, account.getSelfRecipientId(), editTargetTimestamp, urgent)
.sendMessage(messageBuilder,
account.getSelfRecipientId(),
editTargetTimestamp,
urgent)
: context.getSendHelper().sendSelfMessage(messageBuilder, editTargetTimestamp);
results.put(recipient, List.of(toSendMessageResult(result)));
} else if (recipient instanceof RecipientIdentifier.Single single) {
@ -833,10 +837,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);
@ -904,7 +908,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);
@ -918,7 +922,7 @@ public class ManagerImpl implements Manager {
final var previews = new ArrayList<SignalServicePreview>(message.previews().size());
for (final var p : message.previews()) {
final var image = p.image().isPresent() ? context.getAttachmentHelper()
.uploadAttachment(p.image().get()) : null;
.uploadAttachment(p.image().get()) : null;
previews.add(new SignalServicePreview(p.url(),
p.title(),
p.description(),
@ -1088,30 +1092,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());
}
}
}

View File

@ -231,6 +231,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
userAgent,
account.getCredentialsProvider(),
account.getSignalServiceDataStore(),
0,
null,
new ReentrantSignalSessionLock());
handleResponseException(dependencies.getAccountApi()

View File

@ -3,9 +3,21 @@ 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.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 +26,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 +65,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;
@ -82,6 +87,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 +101,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 +115,7 @@ public class SignalDependencies {
this.userAgent = userAgent;
this.credentialsProvider = credentialsProvider;
this.dataStore = dataStore;
this.deviceId = deviceId;
this.executor = executor;
this.sessionLock = sessionLock;
}
@ -243,8 +253,8 @@ public class SignalDependencies {
getPushServiceSocket()));
}
public StorageServiceRepository getStorageServiceRepository() {
return new StorageServiceRepository(getStorageServiceApi());
public StorageServiceService getStorageServiceRepository() {
return new StorageServiceService(getStorageServiceApi());
}
public CertificateApi getCertificateApi() {
@ -301,7 +311,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 +326,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 +336,33 @@ 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,
Runnable::run));
}
public SignalServiceMessageSender getMessageSender() {
return getOrCreate(() -> messageSender,
() -> messageSender = new SignalServiceMessageSender(getPushServiceSocket(),
dataStore,
sessionLock,
getAttachmentApi(),
getMessageApi(),
getKeysApi(),
Optional.empty(),
@ -339,8 +370,7 @@ public class SignalDependencies {
ServiceConfig.MAX_ENVELOPE_SIZE,
ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE,
() -> true,
true,
true));
getPreKeyRepository()));
}
public List<SecureValueRecovery> getSecureValueRecovery() {
@ -368,7 +398,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,9 +1,10 @@
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;
@ -94,6 +95,14 @@ 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));
}
/**
* 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

@ -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

@ -192,6 +192,10 @@ public class SignalAccount implements Closeable {
this.lock = lock;
}
public File getDataPath() {
return dataPath;
}
public static SignalAccount load(
File dataPath,
String accountPath,
@ -941,7 +945,7 @@ public class SignalAccount implements Closeable {
profile.isUnrestrictedUnidentifiedAccess()
? Profile.UnidentifiedAccessMode.UNRESTRICTED
: profile.getUnidentifiedAccess() != null
? Profile.UnidentifiedAccessMode.ENABLED
? Profile.UnidentifiedAccessMode.ENABLED
: Profile.UnidentifiedAccessMode.DISABLED,
capabilities,
null);

View File

@ -152,6 +152,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();
@ -876,9 +877,9 @@ public class GroupStore {
final var members = membersString == null
? Set.<RecipientId>of()
: Arrays.stream(membersString.split(","))
.map(Integer::valueOf)
.map(recipientIdCreator::create)
.collect(Collectors.toSet());
.map(Integer::valueOf)
.map(recipientIdCreator::create)
.collect(Collectors.toSet());
final var expirationTime = resultSet.getInt("expiration_time");
final var blocked = resultSet.getBoolean("blocked");
final var archived = resultSet.getBoolean("archived");

View File

@ -115,7 +115,7 @@ public class LegacyJsonIdentityKeyStore {
var trustLevel = trustedKey.hasNonNull("trustLevel") ? TrustLevel.fromInt(trustedKey.get(
"trustLevel").asInt()) : TrustLevel.TRUSTED_UNVERIFIED;
var added = trustedKey.hasNonNull("addedTimestamp") ? new Date(trustedKey.get("addedTimestamp")
.asLong()) : new Date();
.asLong()) : new Date();
identities.add(new LegacyIdentityInfo(address, id, trustLevel, added));
} catch (InvalidKeyException e) {
logger.warn("Error while decoding key for {}: {}", trustedKeyName, e.getMessage());

View File

@ -28,6 +28,7 @@ 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.List;
import java.util.Map;
@ -50,7 +51,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private final Map<Long, Long> recipientsMerged = new HashMap<>();
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = new HashMap<>();
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(new HashMap<>());
public static void createSql(Connection connection) throws SQLException {
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java
@ -184,12 +185,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;
@ -877,7 +878,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
public int removeStorageIdsFromLocalOnlyUnregisteredRecipients(
final Connection connection,
final List<StorageId> storageIds
final Collection<StorageId> storageIds
) throws SQLException {
final var sql = (
"""
@ -1605,9 +1606,9 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
profileCapabilities == null
? Set.of()
: Arrays.stream(profileCapabilities.split(","))
.map(Profile.Capability::valueOfOrNull)
.filter(Objects::nonNull)
.collect(Collectors.toSet()),
.map(Profile.Capability::valueOfOrNull)
.filter(Objects::nonNull)
.collect(Collectors.toSet()),
PhoneNumberSharingMode.valueOfOrNull(resultSet.getString("profile_phone_number_sharing")));
}

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);
@ -320,10 +321,10 @@ public class MessageSendLogStore implements AutoCloseable {
return content.dataMessage == null
? null
: content.dataMessage.group != null && content.dataMessage.group.id != null
? content.dataMessage.group.id.toByteArray()
? content.dataMessage.group.id.toByteArray()
: content.dataMessage.groupV2 != null && content.dataMessage.groupV2.masterKey != null
? GroupUtils.getGroupIdV2(new GroupMasterKey(content.dataMessage.groupV2.masterKey.toByteArray()))
.serialize()
? GroupUtils.getGroupIdV2(new GroupMasterKey(content.dataMessage.groupV2.masterKey.toByteArray()))
.serialize()
: null;
} catch (InvalidInputException e) {
logger.warn("Failed to parse groupId id from content");

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

@ -194,8 +194,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);
}
@ -402,7 +403,7 @@ public class SessionStore implements SignalServiceSessionStore {
}
private static boolean isActive(SessionRecord record) {
return record != null && record.hasSenderChain();
return record != null && record.hasSenderChain(0.0);
}
record Key(String address, int deviceId) {}

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,6 +80,10 @@ abstract class DefaultStorageRecordProcessor<E extends SignalRecord<?>> implemen
}
}
public Set<StorageId> getUpdatedStorageIds() {
return Collections.unmodifiableSet(updatedStorageIds);
}
private void debug(StorageId i, E record, String message) {
logger.debug("[{}][{}] {}", i, record.getClass().getSimpleName(), message);
}

View File

@ -1,38 +1,15 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.AttachmentInvalidException;
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.IOException;
import java.util.Optional;
import java.util.UUID;
public class AttachmentUtils {
public static SignalServiceAttachmentStream createAttachmentStream(
String attachment,
boolean voiceNote,
ResumableUploadSpec resumableUploadSpec
) throws AttachmentInvalidException {
try {
final var streamDetails = Utils.createStreamDetails(attachment);
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);
}
public static SignalServiceAttachmentStream createAttachmentStream(
StreamDetails streamDetails,
Optional<String> name,

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

@ -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

@ -78,8 +78,10 @@ public class StickerUtils {
throw new StickerPackInvalidException("Could not find find " + pack.cover().file());
}
var contentType = pack.cover().contentType() != null && !pack.cover().contentType().isEmpty() ? pack.cover()
.contentType() : getContentType(rootPath, zip, pack.cover().file());
var contentType = pack.cover().contentType() != null && !pack.cover().contentType().isEmpty()
? pack.cover()
.contentType()
: getContentType(rootPath, zip, pack.cover().file());
cover = new SignalServiceStickerManifestUpload.StickerInfo(data.first(),
data.second(),
Optional.ofNullable(pack.cover().emoji()).orElse(""),

View File

@ -7,9 +7,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;

View File

@ -1 +1 @@
0.90.0
0.94.4

View File

@ -14,8 +14,8 @@ all: $(MANPAGESRC)
.PHONY: install
install: all
$(MKDIR) -p man1 man5
for f in *.1; do $(GZIP) < "$$f" > man1/"$$f".gz ; done
for f in *.5; do $(GZIP) < "$$f" > man5/"$$f".gz ; done
for f in *.1; do $(GZIP) -n < "$$f" > man1/"$$f".gz ; done
for f in *.5; do $(GZIP) -n < "$$f" > man5/"$$f".gz ; done
.PHONY: clean
clean:

View File

@ -13,7 +13,7 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger
== Synopsis
*signal-cli* [--verbose] [--config CONFIG] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_<phonenumber>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]

View File

@ -13,9 +13,9 @@ signal-cli-jsonrpc - A commandline and dbus interface for the Signal messenger
== Synopsis
*signal-cli* [--verbose] [--config CONFIG] [-a ACCOUNT] daemon [--socket[=SOCKET_PATH]] [--tcp[=HOST:PORT]] [--http[=HOST:PORT]]
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] daemon [--socket[=SOCKET_PATH]] [--tcp[=HOST:PORT]] [--http[=HOST:PORT]]
*signal-cli* [--verbose] [--config CONFIG] [-a ACCOUNT] jsonRpc
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] jsonRpc
== Description

View File

@ -13,7 +13,7 @@ signal-cli - A commandline interface for the Signal messenger
== Synopsis
*signal-cli* [--config CONFIG] [-h | -v | -a ACCOUNT | --dbus | --dbus-system] command [command-options]
*signal-cli* [--data-dir DATA_DIR] [-h | -v | -a ACCOUNT | --dbus | --dbus-system] command [command-options]
== Description
@ -57,8 +57,8 @@ If `--verbose` is also given, the detailed logs will only be written to the log
Scrub possibly sensitive information from the log, like phone numbers and UUIDs.
Doesn't work reliably on dbus logs with very verbose logging (`-vvv`)
*--config* CONFIG::
Set the path, where to store the config.
*-d* DATA_DIR, *--data-dir* DATA_DIR, *-c* CONFIG, *--config* CONFIG::
Set the path where to store account data and local configuration.
Make sure you have full read/write access to the given directory.
(Default: `$XDG_DATA_HOME/signal-cli` (`$HOME/.local/share/signal-cli`))
@ -1168,13 +1168,29 @@ signal-cli -a ACCOUNT trust -a RECIPIENT
* *3*: Server or IO error
* *4*: Sending failed due to untrusted key
* *5*: Server rate limiting error
* *6*: CAPTCHA was rejected
== Files
The password and cryptographic keys are created when registering and stored in the current users home directory, the directory can be changed with *--config*:
The password and cryptographic keys are created when registering and stored in the current users home directory, the directory can be changed with *--data-dir* (legacy *--config*):
`$XDG_DATA_HOME/signal-cli/` (`$HOME/.local/share/signal-cli/`)
=== Configuration file
signal-cli supports a JSON-based global configuration file that provides defaults for CLI options.
Keys use camelCase and generally match the long CLI parameter names (for example `dataDir`, `verbose`, `logFile`, `serviceEnvironment`, `trustNewIdentities`, `output`, `disableSendLog`, `account`).
Configuration files are read and merged in this order; later files override earlier ones:
- `/etc/signal-cli/config.json` (system-wide defaults)
- the path in the `SIGNAL_CLI_CONFIG` environment variable (if set)
- `$XDG_CONFIG_HOME/signal-cli/config.json` (per-user; defaults to `$HOME/.config/signal-cli/config.json`)
When multiple configuration files are present their settings are merged; values from later files override earlier values.
Command-line options always take precedence over configuration file values.
Overall precedence (highest → lowest): command-line options → per-user config → system config → built-in defaults.
== Authors
Maintained by AsamK <asamk@gmx.de>, who is assisted by other open source contributors.

View File

@ -0,0 +1,34 @@
# Reproducible builds
This process lets you verify that the version of signal-cli that was downloaded from the Github Releases matches the source code in the public repository.
This is achieved by replicating the build environment as Docker images.
Currently, only the following binaries are reproducible:
- [x] JAR package (`signal-cli-XXX.tar.gz`)
- [ ] Native binary (`signal-cli-XXX-Linux-native.tar.gz`)
- [x] Rust client binary (`signal-cli-XXX-Linux-client.tar.gz`)
In the following section, we will use signal-cli version 0.14.2 as the reference example. Simply replace all occurrences of 0.14.2 with the version number you are about to verify.
## Step-by-step instructions
### 0. Prerequisites
Before you begin, ensure you have the following installed:
- git
- docker (or podman)
### 1. Verifying reproducibility
```bash
git clone --depth 1 --branch v0.14.2 https://github.com/AsamK/signal-cli
cd ./signal-cli
./reproducible-builds/verify.sh
```
If each one ends with `... matches!` for every binary (except the native one for now), you're good to go! You've successfully verified that the Github Release binaries were built from exactly the same code as is in the signal-cli git repository.
If you get `... doesn't match!`, it means something went wrong (except for the native one for now). Please [open an issue](https://github.com/AsamK/signal-cli/issues/new/choose).

View File

@ -0,0 +1,12 @@
ARG ZULU_TAG="25-latest@sha256:8eca9375451a392bff01efe946f2e9263c50aa71a9d68423c068cc1061a41b7e"
FROM docker.io/azul/zulu-openjdk:$ZULU_TAG
ARG SOURCE_DATE_EPOCH="1776889382"
ENV SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
RUN SNAPSHOT="$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y%m%dT%H%M%SZ)" \
&& sed -i 's/^deb /deb [snapshot=yes] /' /etc/apt/sources.list && apt update --snapshot "$SNAPSHOT" && apt install -y make asciidoc-base --snapshot "$SNAPSHOT" --no-install-recommends --no-install-suggests
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "build" ]

49
reproducible-builds/build.sh Executable file
View File

@ -0,0 +1,49 @@
#!/bin/bash
set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
rm -rf "$ROOT_DIR/dist"
mkdir -p "$ROOT_DIR/dist"
if command -v podman >/dev/null; then
ENGINE=podman
USER=
else
ENGINE=docker
USER="--user $(id -u):$(id -g)"
fi
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "$VERSION" >dist/VERSION
# Build jar
$ENGINE build -t signal-cli:build ${OVERRIDE_JAVA_VERSION:+--build-arg ZULU_TAG=$OVERRIDE_JAVA_VERSION} -f reproducible-builds/build.Containerfile .
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:build
mv build/distributions/signal-cli-*.tar.gz dist/
if [ -n "${OVERRIDE_JAVA_VERSION:-}" ]; then
echo -e "\e[33mBuild was performed with overridden Java version $OVERRIDE_JAVA_VERSION, native-image and client will not be built.\e[0m"
exit 0
fi
# Build native-image
$ENGINE build -t signal-cli:native -f reproducible-builds/native.Containerfile .
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:native
mv build/signal-cli-*-Linux-native.tar.gz dist/
# Build rust client
$ENGINE build -t signal-cli:client -f reproducible-builds/client.Containerfile .
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:client
mv build/signal-cli-*-Linux-client.tar.gz dist/
ls -lsh dist/
echo -e "\e[32mBuild successful!\e[0m"

View File

@ -0,0 +1,10 @@
ARG RUST_TAG="1-slim@sha256:715efd1ccdc4a63bd6a6e2f54387fff73f904b70e610d41b4d9d74ff38e13ad3"
FROM docker.io/rust:$RUST_TAG
ARG SOURCE_DATE_EPOCH="1776889382"
ENV SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "client" ]

View File

@ -0,0 +1,78 @@
#!/bin/bash
set -eu
echo "Build '$1' variant $VERSION ..."
function reset_file_dates() {
find . -exec touch -m -d "@$SOURCE_DATE_EPOCH" {} \;
}
reset_file_dates
if [ "$1" == "build" ]; then
./gradlew build \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
--no-build-cache \
-Dorg.gradle.caching=false \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
cd man
make install
cd ..
tar_archive="build/distributions/signal-cli-${VERSION}.tar"
tar --transform="flags=r;s|man|signal-cli-${VERSION}/man|" -rf "$tar_archive" man/man{1,5}
# Remake the tarball to ensure reproducible file order and timestamps
mkdir -p build/extracted
tar -xf "$tar_archive" -C build/extracted/
reset_file_dates
rm -f "$tar_archive"
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --transform='s|^\./||' --owner=0 --group=0 --numeric-owner -cf "$tar_archive" -C build/extracted .
gzip -n -9 "$tar_archive"
elif [ "$1" == "native" ]; then
./gradlew nativeCompile \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
--no-build-cache \
-Dorg.gradle.caching=false \
-Dgraalvm.native-image.build-time=2026-01-01T00:00:00Z \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
strip --strip-all \
--remove-section=.note.gnu.build-id \
--remove-section=.comment \
--remove-section=.gnu_debuglink \
--remove-section=.annobin.notes \
--remove-section=.gnu.build.attributes \
--remove-section=.note.ABI-tag \
build/native/nativeCompile/signal-cli
chmod +x build/native/nativeCompile/signal-cli
reset_file_dates
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner -cf "build/signal-cli-${VERSION}-Linux-native.tar" -C build/native/nativeCompile signal-cli
gzip -n -9 "build/signal-cli-${VERSION}-Linux-native.tar"
elif [ "$1" == "client" ]; then
cd client
cargo build --release --locked
cd ..
chmod +x client/target/release/signal-cli-client
mkdir -p build
reset_file_dates
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner -cf "build/signal-cli-${VERSION}-Linux-client.tar" -C client/target/release signal-cli-client
gzip -n -9 "build/signal-cli-${VERSION}-Linux-client.tar"
else
echo "Unknown build variant '$1'"
exit 1
fi

View File

@ -0,0 +1,10 @@
ARG GRAALVM_TAG="25@sha256:38f835ccb37d4a106c37376a98e8713999077a8c8173d9876505f77da438332c"
FROM container-registry.oracle.com/graalvm/native-image:$GRAALVM_TAG
ARG SOURCE_DATE_EPOCH="1776889382"
ENV SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "native" ]

View File

@ -0,0 +1,57 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
if command -v podman >/dev/null; then
ENGINE=podman
elif command -v docker >/dev/null; then
ENGINE=docker
else
echo "error: neither podman nor docker is available" >&2
exit 1
fi
resolve_digest() {
local image_ref="$1"
"$ENGINE" pull "$image_ref" >/dev/null
"$ENGINE" image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image_ref" \
| grep -m1 -E '@sha256:[0-9a-f]{64}$' \
| sed -E 's|.*(@sha256:[0-9a-f]{64})$|\1|'
}
update_arg_tag() {
local file="$1"
local arg_name="$2"
local image_prefix="$3"
local current
current="$(sed -n "s/^ARG ${arg_name}=\"\([^\"]*\)\"$/\\1/p" "$file")"
if [[ -z "$current" ]]; then
echo "error: could not find ARG ${arg_name} in $file" >&2
exit 1
fi
local tag
tag="${current%@*}"
local digest
digest="$(resolve_digest "${image_prefix}${tag}")"
sed -i -E "s|^ARG ${arg_name}=\"[^\"]+\"$|ARG ${arg_name}=\"${tag}${digest}\"|" "$file"
echo "updated $file -> ${tag}${digest}"
}
update_source_date_epoch() {
local file="$1"
local current_timestamp
current_timestamp="$(date +%s)"
sed -i -E "s|^ARG SOURCE_DATE_EPOCH=\"[^\"]+\"$|ARG SOURCE_DATE_EPOCH=\"${current_timestamp}\"|" "$file"
echo "updated $file SOURCE_DATE_EPOCH -> ${current_timestamp}"
}
update_arg_tag reproducible-builds/build.Containerfile ZULU_TAG docker.io/azul/zulu-openjdk:
update_arg_tag reproducible-builds/native.Containerfile GRAALVM_TAG container-registry.oracle.com/graalvm/native-image:
update_arg_tag reproducible-builds/client.Containerfile RUST_TAG docker.io/rust:
update_source_date_epoch reproducible-builds/build.Containerfile
update_source_date_epoch reproducible-builds/native.Containerfile
update_source_date_epoch reproducible-builds/client.Containerfile

44
reproducible-builds/verify.sh Executable file
View File

@ -0,0 +1,44 @@
#!/bin/bash
set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
rm -rf "$ROOT_DIR/github"
mkdir -p "$ROOT_DIR/github"
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "Download latest release from GitHub..."
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}.tar.gz" -o "github/signal-cli-${VERSION}.tar.gz"
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" -o "github/signal-cli-${VERSION}-Linux-native.tar.gz"
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-client.tar.gz" -o "github/signal-cli-${VERSION}-Linux-client.tar.gz"
./reproducible-builds/build.sh
rm -f {github,dist}/VERSION
echo "commit: $(git rev-parse HEAD)"
echo "sha256 hashes of GitHub release:"
sha256sum github/*
echo "sha256 hashes of locally built files:"
sha256sum dist/*
reproducible=true
for file in $(cd github && find . -type f); do
if diff "github/$file" "dist/$file" >/dev/null 2>&1; then
echo -e "\e[32m[+] '$(basename "$file")' matches!\e[0m"
elif [[ "$file" =~ "native" ]]; then
echo -e "\e[33m[-] '$(basename "$file")' doesn't match! (not supported yet)\e[0m"
reproducible=false
else
echo -e "\e[31m[-] '$(basename "$file")' doesn't match!\e[0m"
reproducible=false
fi
done
if [ "$reproducible" = false ]; then
exit 1
fi

View File

@ -38,6 +38,13 @@ else
SIGNAL_CLI="$PWD/build/install/signal-cli/bin/signal-cli"
fi
# Prefer line-buffered output for external commands when available
if command -v stdbuf >/dev/null 2>&1; then
STD_BUF="stdbuf -oL -eL --"
else
STD_BUF=""
fi
run() {
# To update graalvm config, set GRAALVM_HOME, e.g:
# export GRAALVM_HOME=/usr/lib/jvm/java-25-graalvm
@ -48,11 +55,23 @@ run() {
set -x
if [ "$JSON_RPC" -eq 1 ]; then
"$SIGNAL_CLI" $@
if [ -n "$STD_BUF" ]; then
$STD_BUF "$SIGNAL_CLI" $@
else
"$SIGNAL_CLI" $@
fi
elif [ "$DBUS" -eq 1 ]; then
"$SIGNAL_CLI" --dbus --verbose --verbose $@ | grep -v 'Warning:' | grep -v 'at org'
if [ -n "$STD_BUF" ]; then
$STD_BUF "$SIGNAL_CLI" --dbus --verbose --verbose $@ | grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org'
else
"$SIGNAL_CLI" --dbus --verbose --verbose $@ | grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org'
fi
else
"$SIGNAL_CLI" --service-environment="staging" --verbose --verbose $@ | grep -v 'Warning:' | grep -v 'at org'
if [ -n "$STD_BUF" ]; then
$STD_BUF "$SIGNAL_CLI" --service-environment="staging" --verbose --verbose $@ | grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org'
else
"$SIGNAL_CLI" --service-environment="staging" --verbose --verbose $@ | grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org'
fi
fi
set +x
}
@ -98,9 +117,10 @@ link() {
rm -f "$LINK_CODE_FILE"
mkfifo "$LINK_CODE_FILE"
run_linked link -n "test-device" >"$LINK_CODE_FILE" &
read LINK_CODE <"$LINK_CODE_FILE"
LINK_PID=$!
read -r LINK_CODE <"$LINK_CODE_FILE"
run_main -a "$NUMBER" addDevice --uri "$LINK_CODE"
wait
wait $LINK_PID
run_linked -a "$NUMBER" send --note-to-self -m hi
run_main -a "$NUMBER" receive
run_linked -a "$NUMBER" receive
@ -180,6 +200,7 @@ run_main -a "$NUMBER_2" updateContact "$NUMBER_1" -n NUMBER_1 -e 10
run_main -a "$NUMBER_2" block "$NUMBER_1"
run_main -a "$NUMBER_2" unblock "$NUMBER_1"
run_main -a "$NUMBER_2" listContacts
run_main -a "$NUMBER_2" listContacts "$NUMBER_1"
run_main -a "$NUMBER_1" send "$NUMBER_2" -m hi
run_main -a "$NUMBER_2" receive
@ -207,6 +228,7 @@ run_main -a "$NUMBER_1" updateGroup -g "$GROUP_ID" -m "$NUMBER_2"
run_main -a "$NUMBER_1" listGroups -d
run_main -a "$NUMBER_1" --output=json listGroups -d
run_main -a "$NUMBER_2" receive
run_main -a "$NUMBER_2" listGroups -g "$GROUP_ID"
run_main -a "$NUMBER_2" quitGroup -g "$GROUP_ID"
run_main -a "$NUMBER_2" listGroups -d
run_main -a "$NUMBER_2" --output=json listGroups -d
@ -228,6 +250,7 @@ for OUTPUT in "plain-text" "json"; do
run_main -a "$NUMBER_2" --output="$OUTPUT" receive
run_main -a "$NUMBER_1" --output="$OUTPUT" receive
run_main -a "$NUMBER_1" --output="$OUTPUT" send -e "$NUMBER_2"
run_main -a "$NUMBER_1" --output="$OUTPUT" send "$NUMBER_2" -m test
run_main -a "$NUMBER_2" --output="$OUTPUT" receive
done
@ -235,8 +258,8 @@ done
run_main -a "$NUMBER_1" updateProfile --given-name=GIVEN --family-name=FAMILY --about=ABOUT --about-emoji=EMOJI --avatar=LICENSE --mobile-coin-address="YWJjCg=="
## Provisioning
link "$NUMBER_1"
link "$NUMBER_2"
link "$NUMBER_1" || true
link "$NUMBER_2" || true
run_main -a "$NUMBER_1" listDevices
run_linked -a "$NUMBER_1" sendSyncRequest
run_main -a "$NUMBER_1" sendContacts

View File

@ -46,7 +46,9 @@ public class App {
private final Namespace ns;
static ArgumentParser buildArgumentParser() {
static ArgumentParser buildArgumentParser(GlobalConfig config) {
final var cfg = config == null ? GlobalConfig.DEFAULT : config;
var parser = ArgumentParsers.newFor("signal-cli", VERSION_0_9_0_DEFAULT_SETTINGS)
.includeArgumentNamesAsKeysInResult(true)
.build()
@ -57,47 +59,60 @@ public class App {
parser.addArgument("--version").help("Show package version.").action(Arguments.version());
parser.addArgument("-v", "--verbose")
.help("Raise log level and include lib signal logs. Specify multiple times for even more logs.")
.action(Arguments.count());
.action(Arguments.count())
.setDefault(cfg.verbose() == null ? 0 : cfg.verbose());
parser.addArgument("--log-file")
.type(File.class)
.help("Write log output to the given file. If --verbose is also given, the detailed logs will only be written to the log file.");
.help("Write log output to the given file. If --verbose is also given, the detailed logs will only be written to the log file.")
.setDefault(cfg.logFile() == null ? null : new File(cfg.logFile()));
parser.addArgument("--scrub-log")
.action(Arguments.storeTrue())
.help("Scrub possibly sensitive information from the log, like phone numbers and UUIDs.");
parser.addArgument("-c", "--config")
.help("Set the path, where to store the config (Default: $XDG_DATA_HOME/signal-cli , $HOME/.local/share/signal-cli).");
.help("Scrub possibly sensitive information from the log, like phone numbers and UUIDs.")
.setDefault(cfg.scrubLog() == null ? false : cfg.scrubLog());
parser.addArgument("-d", "--data-dir", "-c", "--config")
.help("Set the path where to store data (Default: $XDG_DATA_HOME/signal-cli , $HOME/.local/share/signal-cli).")
.setDefault(cfg.dataDir());
parser.addArgument("-a", "--account", "-u", "--username")
.help("Specify your phone number, that will be your identifier.");
var mut = parser.addMutuallyExclusiveGroup();
mut.addArgument("--dbus").dest("global-dbus").help("Make request via user dbus.").action(Arguments.storeTrue());
mut.addArgument("--dbus")
.dest("global-dbus")
.help("Make request via user dbus.")
.action(Arguments.storeTrue())
.setDefault(cfg.dbus() == null ? false : cfg.dbus());
mut.addArgument("--dbus-system")
.dest("global-dbus-system")
.help("Make request via system dbus.")
.action(Arguments.storeTrue());
.action(Arguments.storeTrue())
.setDefault(cfg.dbusSystem() == null ? false : cfg.dbusSystem());
parser.addArgument("--bus-name")
.dest("global-bus-name")
.setDefault(DbusConfig.getBusname())
.setDefault(cfg.busName() != null ? cfg.busName() : DbusConfig.getBusname())
.help("Specify the D-Bus bus name to connect to.");
parser.addArgument("-o", "--output")
.help("Choose to output in plain text or JSON")
.type(Arguments.enumStringType(OutputType.class));
.type(Arguments.enumStringType(OutputType.class))
.setDefault(cfg.output() == null ? null : cfg.output());
parser.addArgument("--service-environment")
.help("Choose the server environment to use.")
.type(Arguments.enumStringType(ServiceEnvironmentCli.class))
.setDefault(ServiceEnvironmentCli.LIVE);
.setDefault(cfg.serviceEnvironment() != null ? cfg.serviceEnvironment() : ServiceEnvironmentCli.LIVE);
parser.addArgument("--trust-new-identities")
.help("Choose when to trust new identities.")
.type(Arguments.enumStringType(TrustNewIdentityCli.class))
.setDefault(TrustNewIdentityCli.ON_FIRST_USE);
.setDefault(cfg.trustNewIdentities() != null
? cfg.trustNewIdentities()
: TrustNewIdentityCli.ON_FIRST_USE);
parser.addArgument("--disable-send-log")
.help("Disable message send log (for resending messages that recipient couldn't decrypt)")
.action(Arguments.storeTrue());
.action(Arguments.storeTrue())
.setDefault(cfg.disableSendLog() != null ? cfg.disableSendLog() : false);
parser.epilog(
"The global arguments are shown with 'signal-cli -h' and need to come before the subcommand, while the subcommand-specific arguments (shown with 'signal-cli SUBCOMMAND -h') need to be given after the subcommand.");
@ -204,9 +219,9 @@ public class App {
private OutputWriter getOutputWriter(final Command command) throws UserErrorException {
final var outputTypeInput = ns.<OutputType>get("output");
final var outputType = outputTypeInput == null ? command.getSupportedOutputTypes()
.stream()
.findFirst()
.orElse(null) : outputTypeInput;
.stream()
.findFirst()
.orElse(null) : outputTypeInput;
final var writer = new BufferedWriter(new OutputStreamWriter(System.out, IOUtils.getConsoleCharset()));
final var outputWriter = outputType == null
? null
@ -219,12 +234,12 @@ public class App {
}
private SignalAccountFiles loadSignalAccountFiles() throws IOErrorException {
final File configPath;
final var config = ns.getString("config");
if (config != null) {
configPath = new File(config);
final File dataPath;
final var dataDir = ns.getString("data-dir");
if (dataDir != null) {
dataPath = new File(dataDir);
} else {
configPath = getDefaultConfigPath();
dataPath = getDefaultDataPath();
}
final var serviceEnvironmentCli = ns.<ServiceEnvironmentCli>get("service-environment");
@ -240,7 +255,7 @@ public class App {
final var disableSendLog = Boolean.TRUE.equals(ns.getBoolean("disable-send-log"));
try {
return new SignalAccountFiles(configPath,
return new SignalAccountFiles(dataPath,
serviceEnvironment,
BaseConfig.USER_AGENT,
new Settings(trustNewIdentity, disableSendLog));
@ -339,7 +354,7 @@ public class App {
/**
* @return the default data directory to be used by signal-cli.
*/
private static File getDefaultConfigPath() {
private static File getDefaultDataPath() {
return new File(IOUtils.getDataHomeDir(), "signal-cli");
}
}

View File

@ -8,7 +8,7 @@ public class BaseConfig {
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
.orElse("Signal-Android/8.6.1");
.orElse("Signal-Android/8.15.0");
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
? "signal-cli"
: PROJECT_NAME + "/" + PROJECT_VERSION;

View File

@ -0,0 +1,81 @@
package org.asamk.signal;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.asamk.signal.commands.exceptions.UserErrorException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Loads and merges configuration files. Merge order (later files override earlier):
* - /etc/signal-cli/config.json
* - file pointed to by SIGNAL_CLI_CONFIG (if set)
* - $XDG_CONFIG_HOME/signal-cli/config.json or $HOME/.config/signal-cli/config.json
*/
public final class ConfigLoader {
private ConfigLoader() {
}
public static GlobalConfig load() throws UserErrorException {
final ObjectMapper mapper = new ObjectMapper();
final ObjectNode merged = mapper.createObjectNode();
// System config
addIfExists(merged, mapper, Paths.get("/etc/signal-cli/config.json"));
// User config via env (if set) else XDG or ~/.config
final String env = System.getenv("SIGNAL_CLI_CONFIG");
if (env != null && !env.isEmpty()) {
addIfExists(merged, mapper, Paths.get(env));
} else {
final String xdg = System.getenv("XDG_CONFIG_HOME");
if (xdg != null && !xdg.isEmpty()) {
addIfExists(merged, mapper, Paths.get(xdg, "signal-cli", "config.json"));
} else {
addIfExists(merged,
mapper,
Paths.get(System.getProperty("user.home"), ".config", "signal-cli", "config.json"));
}
}
try {
if (merged.isEmpty()) {
return GlobalConfig.DEFAULT;
}
return mapper.treeToValue(merged, GlobalConfig.class);
} catch (Exception e) {
throw new UserErrorException("Failed to parse configuration file(s): " + e.getMessage(), e);
}
}
private static void addIfExists(ObjectNode merged, ObjectMapper mapper, Path p) throws UserErrorException {
if (p == null) return;
try {
if (Files.exists(p)) {
final JsonNode node = mapper.readTree(p.toFile());
merge(merged, node);
}
} catch (IOException e) {
throw new UserErrorException("Failed to load config from " + p + ": " + e.getMessage(), e);
}
}
private static void merge(ObjectNode target, JsonNode source) {
source.properties().forEach(entry -> {
final String name = entry.getKey();
final JsonNode value = entry.getValue();
final JsonNode existing = target.get(name);
if (existing != null && existing.isObject() && value.isObject()) {
merge((ObjectNode) existing, value);
} else {
target.set(name, value);
}
});
}
}

View File

@ -0,0 +1,36 @@
package org.asamk.signal;
import com.fasterxml.jackson.annotation.JsonProperty;
public record GlobalConfig(
@JsonProperty("verbose") Integer verbose,
@JsonProperty("logFile") String logFile,
@JsonProperty("scrubLog") Boolean scrubLog,
@JsonProperty("dataDir") String dataDir,
@JsonProperty("busName") String busName,
@JsonProperty("dbus") Boolean dbus,
@JsonProperty("dbusSystem") Boolean dbusSystem,
@JsonProperty("output") OutputType output,
@JsonProperty("serviceEnvironment") ServiceEnvironmentCli serviceEnvironment,
@JsonProperty("trustNewIdentities") TrustNewIdentityCli trustNewIdentities,
@JsonProperty("disableSendLog") Boolean disableSendLog,
@JsonProperty("account") String account
) {
public static final GlobalConfig DEFAULT = new GlobalConfig(null,
null,
null,
null,
null,
null,
null,
null,
ServiceEnvironmentCli.LIVE,
TrustNewIdentityCli.ON_FIRST_USE,
null,
null);
public static GlobalConfig empty() {
return new GlobalConfig(null, null, null, null, null, null, null, null, null, null, null, null);
}
}

View File

@ -22,6 +22,7 @@ import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.ArgumentParserException;
import net.sourceforge.argparse4j.inf.Namespace;
import org.asamk.signal.commands.exceptions.CaptchaRejectedErrorException;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.RateLimitErrorException;
@ -39,27 +40,32 @@ import java.security.Security;
public class Main {
public static void main(String[] args) {
static void main(String[] args) {
// enable unlimited strength crypto via Policy, supported on relevant JREs
Security.setProperty("crypto.policy", "unlimited");
installSecurityProviderWorkaround();
// Load global config early so we can use its values as parser defaults
final GlobalConfig globalConfig;
try {
globalConfig = ConfigLoader.load();
} catch (UserErrorException e) {
System.exit(handleCommandException(e, null));
return;
}
// Configuring the logger needs to happen before any logger is initialized
final var loggingConfig = parseLoggingConfig(args);
final var loggingConfig = parseLoggingConfig(args, globalConfig);
configureLogging(loggingConfig);
final var parser = App.buildArgumentParser();
final var parser = App.buildArgumentParser(globalConfig);
final var ns = parser.parseArgsOrFail(args);
int status = 0;
try {
new App(ns).init();
} catch (CommandException e) {
System.err.println(e.getMessage());
if (loggingConfig.verboseLevel > 0 && e.getCause() != null) {
e.getCause().printStackTrace(System.err);
}
status = getStatusForError(e);
status = handleCommandException(e, loggingConfig);
} catch (Throwable e) {
e.printStackTrace(System.err);
status = 2;
@ -68,16 +74,27 @@ public class Main {
System.exit(status);
}
private static int handleCommandException(final CommandException e, final LoggingConfig loggingConfig) {
System.err.println(e.getMessage());
if (loggingConfig != null && loggingConfig.verboseLevel > 0 && e.getCause() != null) {
e.getCause().printStackTrace(System.err);
}
return getStatusForError(e);
}
private static void installSecurityProviderWorkaround() {
// Register our own security provider
Security.insertProviderAt(new SecurityProvider(), 1);
Security.addProvider(new BouncyCastleProvider());
}
private static LoggingConfig parseLoggingConfig(final String[] args) {
final var nsLog = parseArgs(args);
private static LoggingConfig parseLoggingConfig(final String[] args, final GlobalConfig config) {
final var nsLog = parseArgs(args, config);
if (nsLog == null) {
return new LoggingConfig(0, null, false);
final var verbose = config != null && config.verbose() != null ? config.verbose() : 0;
final var logFile = config != null && config.logFile() != null ? new File(config.logFile()) : null;
final var scrubLog = config != null && Boolean.TRUE.equals(config.scrubLog());
return new LoggingConfig(verbose, logFile, scrubLog);
}
final var verboseLevel = nsLog.getInt("verbose");
@ -89,14 +106,20 @@ public class Main {
/**
* This method only parses commandline args relevant for logging configuration.
*/
private static Namespace parseArgs(String[] args) {
private static Namespace parseArgs(String[] args, final GlobalConfig config) {
var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
.includeArgumentNamesAsKeysInResult(true)
.build()
.defaultHelp(false);
parser.addArgument("-v", "--verbose").action(Arguments.count());
parser.addArgument("--log-file").type(File.class);
parser.addArgument("--scrub-log").action(Arguments.storeTrue());
parser.addArgument("-v", "--verbose")
.action(Arguments.count())
.setDefault(config == null || config.verbose() == null ? 0 : config.verbose());
parser.addArgument("--log-file")
.type(File.class)
.setDefault(config == null || config.logFile() == null ? null : new File(config.logFile()));
parser.addArgument("--scrub-log")
.action(Arguments.storeTrue())
.setDefault(config == null || config.scrubLog() == null ? false : config.scrubLog());
try {
return parser.parseKnownArgs(args, null);
@ -123,11 +146,12 @@ public class Main {
private static int getStatusForError(final CommandException e) {
return switch (e) {
case UserErrorException userErrorException -> 1;
case UnexpectedErrorException unexpectedErrorException -> 2;
case IOErrorException ioErrorException -> 3;
case UntrustedKeyErrorException untrustedKeyErrorException -> 4;
case RateLimitErrorException rateLimitErrorException -> 5;
case UserErrorException _ -> 1;
case UnexpectedErrorException _ -> 2;
case IOErrorException _ -> 3;
case UntrustedKeyErrorException _ -> 4;
case RateLimitErrorException _ -> 5;
case CaptchaRejectedErrorException _ -> 6;
case null -> 2;
};
}

View File

@ -1,5 +1,7 @@
package org.asamk.signal;
import com.fasterxml.jackson.annotation.JsonCreator;
public enum OutputType {
PLAIN_TEXT {
@Override
@ -12,5 +14,16 @@ public enum OutputType {
public String toString() {
return "json";
}
},
};
@JsonCreator
public static OutputType fromString(String value) {
if (value == null) return null;
final var norm = value.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
return switch (norm) {
case "plaintext" -> PLAIN_TEXT;
case "json" -> JSON;
default -> throw new IllegalArgumentException("Invalid output type: " + value);
};
}
}

View File

@ -612,8 +612,8 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
writer.println("Size: {}{}",
attachment.size().isPresent() ? attachment.size().get() + " bytes" : "<unavailable>",
attachment.preview().isPresent() ? " (Preview is available: "
+ attachment.preview().get().length
+ " bytes)" : "");
+ attachment.preview().get().length
+ " bytes)" : "");
}
if (attachment.thumbnail().isPresent()) {
writer.println("Thumbnail:");

View File

@ -1,5 +1,7 @@
package org.asamk.signal;
import com.fasterxml.jackson.annotation.JsonCreator;
public enum ServiceEnvironmentCli {
LIVE {
@Override
@ -12,5 +14,16 @@ public enum ServiceEnvironmentCli {
public String toString() {
return "staging";
}
},
};
@JsonCreator
public static ServiceEnvironmentCli fromString(String value) {
if (value == null) return null;
final var norm = value.trim().toLowerCase();
return switch (norm) {
case "live" -> LIVE;
case "staging" -> STAGING;
default -> throw new IllegalArgumentException("Invalid service-environment: " + value);
};
}
}

View File

@ -1,5 +1,7 @@
package org.asamk.signal;
import com.fasterxml.jackson.annotation.JsonCreator;
public enum TrustNewIdentityCli {
ALWAYS {
@Override
@ -18,5 +20,17 @@ public enum TrustNewIdentityCli {
public String toString() {
return "never";
}
},
};
@JsonCreator
public static TrustNewIdentityCli fromString(String value) {
if (value == null) return null;
final var norm = value.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
return switch (norm) {
case "always" -> ALWAYS;
case "onfirstuse" -> ON_FIRST_USE;
case "never" -> NEVER;
default -> throw new IllegalArgumentException("Invalid trust-new-identities: " + value);
};
}
}

View File

@ -23,10 +23,7 @@ public class AcceptCallCommand implements JsonRpcLocalCommand {
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Accept an incoming voice call.");
subparser.addArgument("--call-id")
.type(long.class)
.required(true)
.help("The call ID to accept.");
subparser.addArgument("--call-id").type(long.class).required(true).help("The call ID to accept.");
}
@Override

View File

@ -21,10 +21,7 @@ public class HangupCallCommand implements JsonRpcLocalCommand {
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Hang up an active voice call.");
subparser.addArgument("--call-id")
.type(long.class)
.required(true)
.help("The call ID to hang up.");
subparser.addArgument("--call-id").type(long.class).required(true).help("The call ID to hang up.");
}
@Override

View File

@ -5,13 +5,10 @@ import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.output.PlainTextWriter;
import java.util.List;
public class ListCallsCommand implements JsonRpcLocalCommand {
@Override

View File

@ -109,7 +109,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
r.getProfile().getPhoneNumberSharingMode() == null
? ""
: String.valueOf(r.getProfile().getPhoneNumberSharingMode()
== PhoneNumberSharingMode.EVERYBODY),
== PhoneNumberSharingMode.EVERYBODY),
r.getDiscoverable() == null ? "" : String.valueOf(r.getDiscoverable()));
}
}
@ -121,17 +121,17 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
final var jsonInternal = !internal
? null
: new JsonContact.JsonInternal(r.getProfile()
.getCapabilities()
.stream()
.map(Enum::name)
.toList(),
.getCapabilities()
.stream()
.map(Enum::name)
.toList(),
r.getProfile().getUnidentifiedAccessMode() == Profile.UnidentifiedAccessMode.UNKNOWN
? null
? null
: r.getProfile().getUnidentifiedAccessMode().name(),
r.getProfile().getPhoneNumberSharingMode() == null
? null
? null
: r.getProfile().getPhoneNumberSharingMode()
== PhoneNumberSharingMode.EVERYBODY,
== PhoneNumberSharingMode.EVERYBODY,
r.getDiscoverable());
return new JsonContact(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null),
@ -159,9 +159,9 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
r.getProfile().getAboutEmoji(),
r.getProfile().getAvatarUrlPath() != null,
r.getProfile().getMobileCoinAddress() == null
? null
? null
: Base64.getEncoder()
.encodeToString(r.getProfile().getMobileCoinAddress())),
.encodeToString(r.getProfile().getMobileCoinAddress())),
jsonInternal);
}).toList();
writer.write(jsonContacts);

View File

@ -21,10 +21,7 @@ public class RejectCallCommand implements JsonRpcLocalCommand {
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Reject an incoming voice call.");
subparser.addArgument("--call-id")
.type(long.class)
.required(true)
.help("The call ID to reject.");
subparser.addArgument("--call-id").type(long.class).required(true).help("The call ID to reject.");
}
@Override

View File

@ -144,8 +144,7 @@ public class SendCommand implements JsonRpcLocalCommand {
}
try {
final var results = m.sendEndSessionMessage(singleRecipients);
outputResult(outputWriter, results);
m.sendEndSessionMessage(singleRecipients);
return;
} catch (IOException e) {
throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass()

View File

@ -82,7 +82,11 @@ public class SendPollCreateCommand implements JsonRpcLocalCommand {
throw new UserErrorException("Poll options must not be empty");
}
if (option.length() > MAX_POLL_OPTION_LENGTH) {
throw new UserErrorException("Poll option \"" + option + "\" exceeds the maximum length of " + MAX_POLL_OPTION_LENGTH + " characters");
throw new UserErrorException("Poll option \""
+ option
+ "\" exceeds the maximum length of "
+ MAX_POLL_OPTION_LENGTH
+ " characters");
}
}

View File

@ -3,9 +3,9 @@ package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CaptchaRejectedErrorException;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.output.OutputWriter;
@ -41,8 +41,8 @@ public class SubmitRateLimitChallengeCommand implements JsonRpcLocalCommand {
} catch (IOException e) {
throw new IOErrorException("Submit challenge error: " + e.getMessage(), e);
} catch (CaptchaRejectedException e) {
throw new UserErrorException(
"Captcha rejected, it may be outdated, already used or solved from a different IP address.");
throw new CaptchaRejectedErrorException(
"Captcha rejected, it may be outdated, already used or solved from a different IP address.", e);
}
}
}

View File

@ -0,0 +1,10 @@
package org.asamk.signal.commands.exceptions;
import org.asamk.signal.manager.api.CaptchaRejectedException;
public final class CaptchaRejectedErrorException extends CommandException {
public CaptchaRejectedErrorException(final String message, final CaptchaRejectedException cause) {
super(message, cause);
}
}

View File

@ -1,6 +1,6 @@
package org.asamk.signal.commands.exceptions;
public sealed abstract class CommandException extends Exception permits IOErrorException, RateLimitErrorException, UnexpectedErrorException, UntrustedKeyErrorException, UserErrorException {
public sealed abstract class CommandException extends Exception permits CaptchaRejectedErrorException, IOErrorException, RateLimitErrorException, UnexpectedErrorException, UntrustedKeyErrorException, UserErrorException {
public CommandException(final String message) {
super(message);

View File

@ -543,9 +543,8 @@ public class DbusManagerImpl implements Manager {
}
@Override
public SendMessageResults sendEndSessionMessage(final Set<RecipientIdentifier.Single> recipients) throws IOException {
public void sendEndSessionMessage(final Set<RecipientIdentifier.Single> recipients) throws IOException {
signal.sendEndSessionMessage(recipients.stream().map(RecipientIdentifier.Single::getIdentifier).toList());
return new SendMessageResults(0, Map.of());
}
@Override

View File

@ -430,8 +430,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
@Override
public void sendEndSessionMessage(final List<String> recipients) {
try {
final var results = m.sendEndSessionMessage(getSingleRecipientIdentifiers(recipients, m.getSelfNumber()));
checkSendMessageResults(results);
m.sendEndSessionMessage(getSingleRecipientIdentifiers(recipients, m.getSelfNumber()));
} catch (IOException e) {
throw new Error.Failure(e.getMessage());
}
@ -700,9 +699,13 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
} catch (IOException e) {
throw new Error.Failure(e.getMessage());
} catch (RateLimitException e) {
throw new Error.Failure(e.getMessage()
+ ", retry at "
+ DateUtils.formatTimestamp(e.getNextAttemptTimestamp()));
final var retryAfterMilliseconds = e.getRetryAfterMilliseconds();
throw new Error.Failure(e.getMessage() + (
retryAfterMilliseconds == null
? ""
: ", retry at " + DateUtils.formatTimestamp(System.currentTimeMillis()
+ retryAfterMilliseconds)
));
}
return numbers.stream().map(number -> registered.get(number).uuid() != null).toList();

View File

@ -19,8 +19,11 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
@ -37,12 +40,14 @@ public class HttpServerHandler implements AutoCloseable {
private final Manager m;
private HttpServer server;
private final AtomicBoolean shutdown = new AtomicBoolean(false);
private final Set<String> allowedHosts;
public HttpServerHandler(final InetSocketAddress address, final Manager m) {
this.address = address;
commandHandler = new SignalJsonRpcCommandHandler(m, Commands::getCommand);
this.c = null;
this.m = m;
this.allowedHosts = buildAllowedHosts(address);
}
public HttpServerHandler(final InetSocketAddress address, final MultiAccountManager c) {
@ -50,6 +55,7 @@ public class HttpServerHandler implements AutoCloseable {
commandHandler = new SignalJsonRpcCommandHandler(c, Commands::getCommand);
this.c = c;
this.m = null;
this.allowedHosts = buildAllowedHosts(address);
}
public void init() throws IOException {
@ -67,6 +73,13 @@ public class HttpServerHandler implements AutoCloseable {
server.start();
logger.info("Started HTTP server on {}", address);
// If we're listening on any local address (0.0.0.0 or ::), skip Host header validation
final var addr = address == null ? null : address.getAddress();
if (addr != null && addr.isAnyLocalAddress()) {
logger.warn("HTTP server has no authentication; Host header validation DISABLED because listening on {}", address);
} else {
logger.warn("HTTP server has no authentication; Host header is pinned to {}", allowedHosts);
}
}
@Override
@ -99,6 +112,12 @@ public class HttpServerHandler implements AutoCloseable {
}
private void handleRpcEndpoint(HttpExchange httpExchange) throws IOException {
if (!isHostAllowed(httpExchange)) {
logger.warn("Rejected RPC request with invalid Host header: {} from {}",
httpExchange.getRequestHeaders().getFirst("Host"), httpExchange.getRemoteAddress());
sendResponse(421, null, httpExchange);
return;
}
if (!"/api/v1/rpc".equals(httpExchange.getRequestURI().getPath())) {
sendResponse(404, null, httpExchange);
return;
@ -146,6 +165,12 @@ public class HttpServerHandler implements AutoCloseable {
}
private void handleEventsEndpoint(HttpExchange httpExchange) throws IOException {
if (!isHostAllowed(httpExchange)) {
logger.warn("Rejected Events request with invalid Host header: {} from {}",
httpExchange.getRequestHeaders().getFirst("Host"), httpExchange.getRemoteAddress());
sendResponse(421, null, httpExchange);
return;
}
if (!"/api/v1/events".equals(httpExchange.getRequestURI().getPath())) {
sendResponse(404, null, httpExchange);
return;
@ -169,6 +194,12 @@ public class HttpServerHandler implements AutoCloseable {
httpExchange.sendResponseHeaders(200, 0);
final var sender = new ServerSentEventSender(httpExchange.getResponseBody());
// Flush HTTP response headers to the client immediately.
// Without this, the JVM HttpServer buffers everything until a later write
// in the keep-alive loop (15 s), causing clients with shorter timeouts
// (e.g. 10 s) to abort before receiving the initial response.
httpExchange.getResponseBody().flush();
final var shouldStop = new AtomicBoolean(false);
final var handlers = subscribeReceiveHandlers(managers, sender, () -> {
shouldStop.set(true);
@ -267,4 +298,71 @@ public class HttpServerHandler implements AutoCloseable {
void call();
}
private Set<String> buildAllowedHosts(final InetSocketAddress address) {
final var s = new HashSet<String>();
final var host = address == null ? null : address.getHostString();
if (host != null && !host.isEmpty()) {
s.add(host.toLowerCase(Locale.ROOT));
}
s.add("localhost");
s.add("127.0.0.1");
s.add("::1");
return s;
}
private boolean isHostAllowed(final HttpExchange httpExchange) {
// If the server is bound to any local address (0.0.0.0 or ::), skip host header validation
if (address != null) {
final var addr = address.getAddress();
if (addr != null && addr.isAnyLocalAddress()) {
return true;
}
final var hostStr = address.getHostString();
if ("0.0.0.0".equals(hostStr) || "::".equals(hostStr)) {
return true;
}
}
final var hostHeader = httpExchange.getRequestHeaders().getFirst("Host");
if (hostHeader == null || hostHeader.isEmpty()) {
return false;
}
String hostPart = hostHeader;
String portPart = null;
if (hostHeader.startsWith("[")) {
final var idx = hostHeader.indexOf(']');
if (idx == -1) return false;
hostPart = hostHeader.substring(1, idx);
if (hostHeader.length() > idx + 1 && hostHeader.charAt(idx + 1) == ':') {
portPart = hostHeader.substring(idx + 2);
}
} else {
final var colon = hostHeader.lastIndexOf(':');
if (colon != -1) {
final var possiblePort = hostHeader.substring(colon + 1);
if (possiblePort.chars().allMatch(Character::isDigit)) {
hostPart = hostHeader.substring(0, colon);
portPart = possiblePort;
}
}
}
hostPart = hostPart.toLowerCase(Locale.ROOT);
if (!allowedHosts.contains(hostPart)) {
return false;
}
if (portPart != null) {
try {
final var port = Integer.parseInt(portPart);
if (port != address.getPort()) return false;
} catch (NumberFormatException e) {
return false;
}
}
return true;
}
}

View File

@ -1,9 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "AdminDelete")
public record JsonAdminDelete(
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
) {

View File

@ -1,7 +1,10 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "Attachment")
record JsonAttachment(
String contentType,
String filename,

View File

@ -1,5 +1,8 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "AttachmentData")
public record JsonAttachmentData(
String data
) {}

View File

@ -18,15 +18,13 @@ public record JsonCallEvent(
) {
public static JsonCallEvent from(CallInfo callInfo, String reason) {
return new JsonCallEvent(
callInfo.callId(),
return new JsonCallEvent(callInfo.callId(),
callInfo.state().name(),
callInfo.recipient().number().orElse(null),
callInfo.recipient().aci().orElse(null),
callInfo.isOutgoing(),
callInfo.inputDeviceName(),
callInfo.outputDeviceName(),
reason
);
reason);
}
}

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