taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 2dcc36867561729c7b8bad2ead694ab0931d5cfe
parent d41c9b30baa545b352a8e2ff68fdf00a01421e6d
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Wed,  8 Oct 2025 00:48:16 +0200

[donau-verificator] small fix v2

Diffstat:
Mdonau-verificator/src/main/java/net/taler/donauverificator/Results.java | 263+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Adonau-verificator/src/main/java/net/taler/donauverificator/network/CrockfordBase32.java | 36++++++++++++++++++++++++++++++++++++
Adonau-verificator/src/main/java/net/taler/donauverificator/network/DonauNetworkClient.java | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdonau-verificator/src/main/res/layout/activity_main.xml | 2+-
Mdonau-verificator/src/main/res/values/strings.xml | 9++++++---
5 files changed, 434 insertions(+), 123 deletions(-)

diff --git a/donau-verificator/src/main/java/net/taler/donauverificator/Results.java b/donau-verificator/src/main/java/net/taler/donauverificator/Results.java @@ -24,7 +24,6 @@ import android.util.Log; import android.view.View; import android.widget.TextView; - import androidx.annotation.ColorRes; import androidx.annotation.StringRes; import androidx.appcompat.app.AppCompatActivity; @@ -35,22 +34,21 @@ import com.google.android.material.button.MaterialButton; import com.google.android.material.card.MaterialCardView; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import org.json.JSONArray; +import net.taler.donauverificator.network.CrockfordBase32; +import net.taler.donauverificator.network.DonauNetworkClient; +import net.taler.donauverificator.network.DonauNetworkClient.DonationStatement; +import net.taler.donauverificator.network.DonauNetworkClient.HttpStatusException; + import org.json.JSONException; -import org.json.JSONObject; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.util.zip.GZIPInputStream; -import java.util.zip.InflaterInputStream; public class Results extends AppCompatActivity { static { @@ -61,7 +59,6 @@ public class Results extends AppCompatActivity { // lsd0013 format: donau://host/year/taxid/salt?total=...&sig=ED25519:... // CrockfordBase32 encoded: SIGNATURE, PUBLICKEY - // TODO: Salt and taxId should maybe also be encoded private String uriScheme; private String host; @@ -74,6 +71,7 @@ public class Results extends AppCompatActivity { private String salt; private String eddsaSignature; private String publicKey; + private DonauNetworkClient networkClient; TextView sigStatusView; View summaryContainer; TextView hostLabelView; @@ -95,6 +93,9 @@ public class Results extends AppCompatActivity { INSECURE_HTTP_DISABLED, KEY_DOWNLOAD_FAILED, KEY_NOT_FOUND, + DONATION_STATEMENT_DOWNLOAD_FAILED, + DONATION_STATEMENT_NOT_FOUND, + DONATION_STATEMENT_INVALID, SIGNATURE_INVALID, SIGNATURE_VALID; } @@ -164,13 +165,17 @@ public class Results extends AppCompatActivity { private void startVerificationAsync() { new Thread(() -> { - SignatureStatus status = ensurePublicKeyAvailable(); + SignatureStatus statusResult = ensurePublicKeyAvailable(); + if (statusResult == null) { + statusResult = ensureDonationStatementAvailable(); + } + SignatureStatus finalStatus = statusResult; runOnUiThread(() -> { if (isFinishing() || isDestroyed()) { return; } - if (status != null) { - statusHandling(status); + if (finalStatus != null) { + statusHandling(finalStatus); return; } try { @@ -206,6 +211,15 @@ public class Results extends AppCompatActivity { case KEY_NOT_FOUND: updateStatusCard(R.string.status_key_not_found, R.color.validation_surface_info, R.color.colorSecondary, false); break; + case DONATION_STATEMENT_DOWNLOAD_FAILED: + updateStatusCard(R.string.status_donation_statement_download_failed, R.color.validation_surface_error, R.color.red, false); + break; + case DONATION_STATEMENT_NOT_FOUND: + updateStatusCard(R.string.status_donation_statement_not_found, R.color.validation_surface_info, R.color.colorSecondary, false); + break; + case DONATION_STATEMENT_INVALID: + updateStatusCard(R.string.status_donation_statement_invalid, R.color.validation_surface_error, R.color.red, false); + break; case SIGNATURE_INVALID: updateStatusCard(R.string.invalid_signature, R.color.validation_surface_error, R.color.red, false); break; @@ -297,13 +311,15 @@ public class Results extends AppCompatActivity { } private void showSignatureDialog() { + String lineBreak = "\n"; + String message = getString(R.string.signature_info_salt, valueOrUnknown(salt)) + + lineBreak + + getString(R.string.signature_info_signature, valueOrUnknown(eddsaSignature)) + + lineBreak + + getString(R.string.signature_info_public_key, valueOrUnknown(publicKey)); new MaterialAlertDialogBuilder(this) .setTitle(R.string.signature_info_title) - .setMessage(getString( - R.string.signature_info_message, - valueOrUnknown(salt), - valueOrUnknown(eddsaSignature), - valueOrUnknown(publicKey))) + .setMessage(message) .setPositiveButton(android.R.string.ok, null) .show(); } @@ -345,6 +361,7 @@ public class Results extends AppCompatActivity { salt = null; eddsaSignature = null; publicKey = null; + networkClient = null; } private SignatureStatus parseDonauUri(Uri uri) { @@ -407,23 +424,31 @@ public class Results extends AppCompatActivity { salt = saltCandidate; String totalParam = uri.getQueryParameter("total"); - if (isEmpty(totalParam)) { - return SignatureStatus.MALFORMED_ARGUMENT; + if (totalParam != null) { + String trimmedTotal = totalParam.trim(); + if (trimmedTotal.isEmpty()) { + return SignatureStatus.MALFORMED_ARGUMENT; + } + totalAmount = trimmedTotal; + } else { + totalAmount = null; } - totalAmount = totalParam.trim(); String sigParam = uri.getQueryParameter("sig"); - if (isEmpty(sigParam)) { - return SignatureStatus.MALFORMED_ARGUMENT; - } - eddsaSignature = extractEd25519Signature(sigParam); - if (isEmpty(eddsaSignature)) { - return SignatureStatus.MALFORMED_ARGUMENT; + if (sigParam != null) { + eddsaSignature = extractEd25519Signature(sigParam); + if (isEmpty(eddsaSignature)) { + return SignatureStatus.MALFORMED_ARGUMENT; + } + } else { + eddsaSignature = null; } String publicKeyParam = uri.getQueryParameter("pub"); publicKey = isEmpty(publicKeyParam) ? null : publicKeyParam.trim(); + networkClient = new DonauNetworkClient(isInsecureScheme(), host, port, authorityPathSegments); + return null; } @@ -447,116 +472,126 @@ public class Results extends AppCompatActivity { } try { - String fetchedKey = fetchSigningKey(insecureScheme); + DonauNetworkClient client = getNetworkClient(); + int targetYear = parseYearOrDefault(year); + String fetchedKey = client.fetchSigningKey(targetYear); if (isEmpty(fetchedKey)) { return SignatureStatus.KEY_NOT_FOUND; } publicKey = fetchedKey; return null; + } catch (HttpStatusException e) { + Log.e(TAG, "Failed to download Donau signing keys, HTTP " + e.getStatusCode(), e); + return SignatureStatus.KEY_DOWNLOAD_FAILED; } catch (IOException | JSONException e) { Log.e(TAG, "Failed to download Donau signing keys", e); return SignatureStatus.KEY_DOWNLOAD_FAILED; } } - private String buildHostDisplay() { - if (isEmpty(host)) { + private SignatureStatus ensureDonationStatementAvailable() { + boolean needsTotal = isEmpty(totalAmount); + boolean needsSignature = isEmpty(eddsaSignature); + if (!needsTotal && !needsSignature) { return null; } - StringBuilder builder = new StringBuilder(host); - if (port != -1) { - builder.append(":").append(port); - } - for (String segment : authorityPathSegments) { - if (!isEmpty(segment)) { - builder.append("/").append(segment); - } - } - return builder.toString(); - } - - private String fetchSigningKey(boolean insecure) throws IOException, JSONException { - URL keysUrl = buildKeysUrl(insecure); - if (keysUrl == null) { - return null; + if (isEmpty(taxId) || isEmpty(salt) || isEmpty(year)) { + return SignatureStatus.MALFORMED_ARGUMENT; } - HttpURLConnection connection = (HttpURLConnection) keysUrl.openConnection(); - connection.setRequestMethod("GET"); - connection.setConnectTimeout(5000); - connection.setReadTimeout(5000); - connection.setInstanceFollowRedirects(false); - connection.setRequestProperty("Accept", "application/json"); - connection.setRequestProperty("Accept-Encoding", "gzip, deflate"); try { - int status = connection.getResponseCode(); - if (status != HttpURLConnection.HTTP_OK) { - throw new IOException("HTTP " + status); + String donorHash = computeDonorHash(taxId, salt); + DonauNetworkClient client = getNetworkClient(); + int donationYear = parseYearOrDefault(year); + DonationStatement statement = client.fetchDonationStatement(donationYear, donorHash); + String statementTotal = statement.getTotal(); + String statementSignature = statement.getSignature(); + String statementPublicKey = statement.getPublicKey(); + if (isEmpty(statementTotal) || isEmpty(statementSignature) || isEmpty(statementPublicKey)) { + Log.e(TAG, "Donation statement response missing required fields"); + return SignatureStatus.DONATION_STATEMENT_INVALID; } - InputStream input = connection.getInputStream(); - String encoding = connection.getHeaderField("Content-Encoding"); - if (encoding != null) { - if ("gzip".equalsIgnoreCase(encoding)) { - input = new GZIPInputStream(input); - } else if ("deflate".equalsIgnoreCase(encoding)) { - input = new InflaterInputStream(input); - } + if (!needsTotal && totalAmount != null && !totalAmount.equals(statementTotal)) { + Log.e(TAG, "Donation statement total mismatch"); + return SignatureStatus.DONATION_STATEMENT_INVALID; } - String body; - try { - body = readStream(input); - } finally { - input.close(); + if (!isEmpty(publicKey) && !publicKey.equals(statementPublicKey)) { + Log.e(TAG, "Donation statement public key mismatch"); + return SignatureStatus.DONATION_STATEMENT_INVALID; } - JSONObject json = new JSONObject(body); - JSONArray signkeys = json.optJSONArray("signkeys"); - if (signkeys == null) { - return null; - } - for (int i = 0; i < signkeys.length(); i++) { - JSONObject entry = signkeys.optJSONObject(i); - if (entry != null) { - String keyCandidate = entry.optString("key", null); - if (isEmpty(keyCandidate) && entry.has("key")) { - JSONObject keyObj = entry.optJSONObject("key"); - if (keyObj != null) { - keyCandidate = keyObj.optString("eddsa_pub", null); - } - } - if (!isEmpty(keyCandidate)) { - return keyCandidate.trim(); - } - } else { - String keyCandidate = signkeys.optString(i, null); - if (!isEmpty(keyCandidate)) { - return keyCandidate.trim(); - } + String extractedSignature = extractEd25519Signature(statementSignature); + String normalizedSignature; + if (extractedSignature != null) { + normalizedSignature = extractedSignature; + } else { + if (statementSignature.contains(":") || statementSignature.contains("=")) { + Log.e(TAG, "Donation statement signature format invalid"); + return SignatureStatus.DONATION_STATEMENT_INVALID; } + normalizedSignature = statementSignature.trim(); } + if (!needsSignature && eddsaSignature != null && !eddsaSignature.equals(normalizedSignature)) { + Log.e(TAG, "Donation statement signature mismatch"); + return SignatureStatus.DONATION_STATEMENT_INVALID; + } + totalAmount = statementTotal; + eddsaSignature = normalizedSignature; + publicKey = statementPublicKey; return null; - } finally { - connection.disconnect(); + } catch (HttpStatusException e) { + if (e.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { + return SignatureStatus.DONATION_STATEMENT_NOT_FOUND; + } + Log.e(TAG, "Donation statement download failed, HTTP " + e.getStatusCode(), e); + return SignatureStatus.DONATION_STATEMENT_DOWNLOAD_FAILED; + } catch (IOException | JSONException e) { + Log.e(TAG, "Donation statement download failed", e); + return SignatureStatus.DONATION_STATEMENT_DOWNLOAD_FAILED; + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "Unable to hash donor identifier", e); + return SignatureStatus.DONATION_STATEMENT_INVALID; + } + } + + private DonauNetworkClient getNetworkClient() { + if (networkClient == null) { + networkClient = new DonauNetworkClient(isInsecureScheme(), host, port, authorityPathSegments); } + return networkClient; + } + + private int parseYearOrDefault(String value) { + if (isEmpty(value)) { + return Integer.MIN_VALUE; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return Integer.MIN_VALUE; + } + } + + private String computeDonorHash(String taxIdValue, String saltValue) throws NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(taxIdValue.getBytes(StandardCharsets.UTF_8)); + digest.update(saltValue.getBytes(StandardCharsets.UTF_8)); + byte[] hash = digest.digest(); + return CrockfordBase32.encode(hash); } - private URL buildKeysUrl(boolean insecure) { + private String buildHostDisplay() { if (isEmpty(host)) { return null; } - try { - Uri.Builder builder = new Uri.Builder() - .scheme(insecure ? "http" : "https") - .encodedAuthority(port != -1 ? host + ":" + port : host); - for (String segment : authorityPathSegments) { - if (!isEmpty(segment)) { - builder.appendPath(segment.trim()); - } + StringBuilder builder = new StringBuilder(host); + if (port != -1) { + builder.append(":").append(port); + } + for (String segment : authorityPathSegments) { + if (!isEmpty(segment)) { + builder.append("/").append(segment); } - builder.appendPath("keys"); - return new URL(builder.build().toString()); - } catch (MalformedURLException | IllegalArgumentException e) { - Log.e(TAG, "Invalid /keys URL", e); - return null; } + return builder.toString(); } private boolean isInsecureScheme() { @@ -576,16 +611,6 @@ public class Results extends AppCompatActivity { return host != null && host.equalsIgnoreCase("example.com"); } - private String readStream(InputStream input) throws IOException { - BufferedReader reader = new BufferedReader(new InputStreamReader(input, java.nio.charset.StandardCharsets.UTF_8)); - StringBuilder builder = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - builder.append(line); - } - return builder.toString(); - } - private boolean isFourDigitYear(String value) { if (value == null || value.length() != 4) { return false; diff --git a/donau-verificator/src/main/java/net/taler/donauverificator/network/CrockfordBase32.java b/donau-verificator/src/main/java/net/taler/donauverificator/network/CrockfordBase32.java @@ -0,0 +1,36 @@ +package net.taler.donauverificator.network; + +/** + * Minimal encoder for the Crockford Base32 alphabet as specified for Donau. + * Padding is omitted because the Donau specification does not require it. + */ +public final class CrockfordBase32 { + + private static final char[] ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray(); + + private CrockfordBase32() { + } + + public static String encode(byte[] data) { + if (data == null || data.length == 0) { + return ""; + } + StringBuilder encoded = new StringBuilder((data.length * 8 + 4) / 5); + int buffer = 0; + int bitsLeft = 0; + for (byte value : data) { + buffer = (buffer << 8) | (value & 0xFF); + bitsLeft += 8; + while (bitsLeft >= 5) { + int index = (buffer >> (bitsLeft - 5)) & 0x1F; + bitsLeft -= 5; + encoded.append(ALPHABET[index]); + } + } + if (bitsLeft > 0) { + int index = (buffer << (5 - bitsLeft)) & 0x1F; + encoded.append(ALPHABET[index]); + } + return encoded.toString(); + } +} diff --git a/donau-verificator/src/main/java/net/taler/donauverificator/network/DonauNetworkClient.java b/donau-verificator/src/main/java/net/taler/donauverificator/network/DonauNetworkClient.java @@ -0,0 +1,247 @@ +package net.taler.donauverificator.network; + +import android.net.Uri; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; + +public final class DonauNetworkClient { + + private static final String TAG = "DonauNetworkClient"; + private static final int TIMEOUT_MS = 5000; + + private final boolean insecureScheme; + private final String host; + private final int port; + private final List<String> authorityPathSegments; + + public DonauNetworkClient(boolean insecureScheme, String host, int port, List<String> authorityPathSegments) { + this.insecureScheme = insecureScheme; + this.host = host; + this.port = port; + if (authorityPathSegments == null || authorityPathSegments.isEmpty()) { + this.authorityPathSegments = Collections.emptyList(); + } else { + this.authorityPathSegments = Collections.unmodifiableList(new ArrayList<>(authorityPathSegments)); + } + } + + public String fetchSigningKey(int targetYear) throws IOException, JSONException { + URL url = buildUrl("keys"); + HttpURLConnection connection = openGetConnection(url); + connection.setRequestProperty("Accept", "application/json"); + try { + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + throw new HttpStatusException(status, "Unexpected status for /keys: " + status); + } + String body = readStream(connection); + JSONObject json = new JSONObject(body); + JSONArray signkeys = json.optJSONArray("signkeys"); + if (signkeys == null) { + return null; + } + String fallback = null; + for (int i = 0; i < signkeys.length(); i++) { + Object entry = signkeys.get(i); + String keyCandidate = extractSigningKey(entry); + if (keyCandidate == null) { + continue; + } + int yearCandidate = extractYear(entry); + if (targetYear != Integer.MIN_VALUE && yearCandidate == targetYear) { + return keyCandidate; + } + if (fallback == null) { + fallback = keyCandidate; + } + } + return fallback; + } finally { + connection.disconnect(); + } + } + + public DonationStatement fetchDonationStatement(int year, String hashedDonorId) throws IOException, JSONException { + URL url = buildUrl("donation-statement", String.valueOf(year), hashedDonorId); + HttpURLConnection connection = openGetConnection(url); + connection.setRequestProperty("Accept", "application/json"); + try { + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + throw new HttpStatusException(status, "Unexpected status for /donation-statement: " + status); + } + String body = readStream(connection); + JSONObject json = new JSONObject(body); + String total = trimToNull(json.optString("total", null)); + String signature = trimToNull(json.optString("donation_statement_sig", null)); + String pub = trimToNull(json.optString("donau_pub", null)); + if (total == null || signature == null || pub == null) { + throw new JSONException("Incomplete donation statement response"); + } + return new DonationStatement(total, signature, pub); + } finally { + connection.disconnect(); + } + } + + private HttpURLConnection openGetConnection(URL url) throws IOException { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(TIMEOUT_MS); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("Accept-Encoding", "gzip, deflate"); + return connection; + } + + private URL buildUrl(String... extraSegments) throws MalformedURLException { + if (host == null || host.isEmpty()) { + throw new MalformedURLException("Missing host"); + } + Uri.Builder builder = new Uri.Builder() + .scheme(insecureScheme ? "http" : "https") + .encodedAuthority(port != -1 ? host + ":" + port : host); + for (String segment : authorityPathSegments) { + if (segment != null && !segment.trim().isEmpty()) { + builder.appendPath(segment.trim()); + } + } + for (String segment : extraSegments) { + builder.appendPath(segment); + } + return new URL(builder.build().toString()); + } + + private String readStream(HttpURLConnection connection) throws IOException { + InputStream input = null; + try { + input = maybeWrap(connection.getInputStream(), connection.getHeaderField("Content-Encoding")); + BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + return builder.toString(); + } finally { + if (input != null) { + try { + input.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close input stream", e); + } + } + } + } + + private InputStream maybeWrap(InputStream input, String encoding) throws IOException { + if (encoding == null) { + return input; + } + if ("gzip".equalsIgnoreCase(encoding)) { + return new GZIPInputStream(input); + } + if ("deflate".equalsIgnoreCase(encoding)) { + return new InflaterInputStream(input); + } + return input; + } + + private static String extractSigningKey(Object entry) { + if (entry instanceof JSONObject) { + JSONObject object = (JSONObject) entry; + String direct = trimToNull(object.optString("key", null)); + if (direct != null) { + return direct; + } + JSONObject keyObject = object.optJSONObject("key"); + if (keyObject != null) { + String nested = trimToNull(keyObject.optString("eddsa_pub", null)); + if (nested != null) { + return nested; + } + } + return trimToNull(object.optString("eddsa_pub", null)); + } + if (entry instanceof String) { + return trimToNull((String) entry); + } + return null; + } + + private static int extractYear(Object entry) { + if (entry instanceof JSONObject) { + JSONObject object = (JSONObject) entry; + if (object.has("year")) { + try { + return object.getInt("year"); + } catch (JSONException e) { + return Integer.MIN_VALUE; + } + } + } + return Integer.MIN_VALUE; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + public static final class DonationStatement { + private final String total; + private final String signature; + private final String publicKey; + + public DonationStatement(String total, String signature, String publicKey) { + this.total = total; + this.signature = signature; + this.publicKey = publicKey; + } + + public String getTotal() { + return total; + } + + public String getSignature() { + return signature; + } + + public String getPublicKey() { + return publicKey; + } + } + + public static final class HttpStatusException extends IOException { + private final int statusCode; + + public HttpStatusException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + } +} diff --git a/donau-verificator/src/main/res/layout/activity_main.xml b/donau-verificator/src/main/res/layout/activity_main.xml @@ -11,7 +11,7 @@ android:id="@+id/appTitle" android:layout_width="0dp" android:layout_height="wrap_content" - android:text="@string/title_donau_verifier" + android:text="@string/app_name" android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5" android:textColor="@color/text_primary" app:layout_constraintHorizontal_chainStyle="packed" diff --git a/donau-verificator/src/main/res/values/strings.xml b/donau-verificator/src/main/res/values/strings.xml @@ -16,6 +16,9 @@ <string name="status_insecure_http_unsupported">This build does not support insecure Donau URIs.</string> <string name="status_key_download_failed">Unable to fetch Donau signing keys.</string> <string name="status_key_not_found">Signing key for this donation statement not found.</string> + <string name="status_donation_statement_download_failed">Unable to fetch donation statement.</string> + <string name="status_donation_statement_not_found">Donation statement not found for this taxpayer and year.</string> + <string name="status_donation_statement_invalid">Donation statement response was invalid.</string> <string name="label_host">Host</string> <string name="label_year">Year</string> <string name="label_tax_id">Tax ID</string> @@ -23,9 +26,9 @@ <string name="action_back">Back</string> <string name="action_signature_info">Signature info</string> <string name="signature_info_title">Signature details</string> - <string name="signature_info_message">Salt: %1$s -Signature: %2$s -Public key: %3$s</string> + <string name="signature_info_salt">Salt: %1$s</string> + <string name="signature_info_signature">Signature: %1$s</string> + <string name="signature_info_public_key">Public key: %1$s</string> <string name="value_unknown">Unknown</string> <string name="title_donau_verifier">Donau Verifier</string> </resources>