Results.java (25664B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2024 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 13 You should have received a copy of the GNU General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.donauverificator; 18 19 import android.content.Intent; 20 import android.content.SharedPreferences; 21 import android.net.Uri; 22 import android.os.Bundle; 23 import android.util.Log; 24 import android.view.View; 25 import android.widget.TextView; 26 27 import androidx.annotation.ColorRes; 28 import androidx.annotation.StringRes; 29 import androidx.appcompat.app.AppCompatActivity; 30 import androidx.core.content.ContextCompat; 31 import androidx.preference.PreferenceManager; 32 33 import com.google.android.material.button.MaterialButton; 34 import com.google.android.material.card.MaterialCardView; 35 import com.google.android.material.dialog.MaterialAlertDialogBuilder; 36 37 import net.taler.donauverificator.network.CrockfordBase32; 38 import net.taler.donauverificator.network.DonauNetworkClient; 39 import net.taler.donauverificator.network.DonauNetworkClient.DonationStatement; 40 import net.taler.donauverificator.network.DonauNetworkClient.HttpStatusException; 41 42 import org.json.JSONException; 43 44 import java.io.IOException; 45 import java.net.HttpURLConnection; 46 import java.nio.charset.StandardCharsets; 47 import java.security.MessageDigest; 48 import java.security.NoSuchAlgorithmException; 49 import java.util.ArrayList; 50 import java.util.List; 51 import java.util.Locale; 52 53 public class Results extends AppCompatActivity { 54 static { 55 System.loadLibrary("verification"); 56 } 57 58 private static final String TAG = "Results"; 59 60 // Format: donau://base/year/taxid-enc/salt?total=...&sig=ED25519:... 61 // CrockfordBase32 encoded: SIGNATURE, PUBLICKEY 62 63 private String uriScheme; 64 private String host; 65 private int port = -1; 66 private final List<String> authorityPathSegments = new ArrayList<>(); 67 private String year; 68 private String totalAmount; 69 private String taxId; 70 private String hostDisplay; 71 private String salt; 72 private String eddsaSignature; 73 private String publicKey; 74 private final List<String> publicKeys = new ArrayList<>(); 75 private DonauNetworkClient networkClient; 76 TextView sigStatusView; 77 View summaryContainer; 78 TextView hostLabelView; 79 TextView hostValueView; 80 TextView yearLabelView; 81 TextView yearValueView; 82 TextView taxLabelView; 83 TextView taxValueView; 84 TextView amountLabelView; 85 TextView amountValueView; 86 MaterialCardView statusCard; 87 MaterialButton signatureButton; 88 89 public enum SignatureStatus { 90 INVALID_SCHEME, 91 INVALID_NUMBER_OF_ARGUMENTS, 92 MALFORMED_ARGUMENT, 93 INSECURE_HTTP_UNSUPPORTED, 94 INSECURE_HTTP_DISABLED, 95 KEY_DOWNLOAD_FAILED, 96 KEY_NOT_FOUND, 97 DONATION_STATEMENT_DOWNLOAD_FAILED, 98 DONATION_STATEMENT_NOT_FOUND, 99 DONATION_STATEMENT_INVALID, 100 SIGNATURE_INVALID, 101 SIGNATURE_VALID; 102 } 103 104 105 @Override 106 protected void onCreate(Bundle savedInstanceState) { 107 super.onCreate(savedInstanceState); 108 setContentView(R.layout.fragment_results); 109 sigStatusView = findViewById(R.id.sigStatus); 110 statusCard = findViewById(R.id.statusCard); 111 summaryContainer = findViewById(R.id.summaryContainer); 112 hostLabelView = findViewById(R.id.hostLabel); 113 hostValueView = findViewById(R.id.hostValue); 114 yearLabelView = findViewById(R.id.yearLabel); 115 yearValueView = findViewById(R.id.yearValue); 116 taxLabelView = findViewById(R.id.taxLabel); 117 taxValueView = findViewById(R.id.taxValue); 118 amountLabelView = findViewById(R.id.amountLabel); 119 amountValueView = findViewById(R.id.amountValue); 120 signatureButton = findViewById(R.id.signatureButton); 121 if (signatureButton != null) { 122 signatureButton.setVisibility(View.GONE); 123 signatureButton.setEnabled(false); 124 signatureButton.setOnClickListener(v -> showSignatureDialog()); 125 } 126 if (statusCard != null) { 127 statusCard.setCardBackgroundColor(ContextCompat.getColor(this, R.color.validation_surface_neutral)); 128 } 129 sigStatusView.setTextColor(ContextCompat.getColor(this, R.color.text_primary)); 130 setSummaryLabels(); 131 setupBackNavigation(); 132 133 Intent intent = getIntent(); 134 Uri uri = resolveUri(intent); 135 if (uri == null) { 136 statusHandling(SignatureStatus.INVALID_SCHEME); 137 return; 138 } 139 140 uriScheme = uri.getScheme(); 141 if (!isSupportedScheme(uriScheme)) { 142 statusHandling(SignatureStatus.INVALID_SCHEME); 143 return; 144 } 145 146 resetParsedFields(); 147 SignatureStatus parseStatus = parseDonauUri(uri); 148 if (parseStatus != null) { 149 statusHandling(parseStatus); 150 return; 151 } 152 startVerificationAsync(); 153 } 154 155 private void checkSignature() throws Exception{ 156 if (!isEmpty(publicKey)) { 157 int res = ed25519_verify(year, totalAmount, taxId, salt, eddsaSignature, publicKey); 158 if (res == 0) { 159 statusHandling(SignatureStatus.SIGNATURE_VALID); 160 } else { 161 statusHandling(SignatureStatus.SIGNATURE_INVALID); 162 } 163 return; 164 } 165 166 if (publicKeys.isEmpty()) { 167 statusHandling(SignatureStatus.SIGNATURE_INVALID); 168 return; 169 } 170 171 for (String candidate : publicKeys) { 172 if (isEmpty(candidate)) continue; 173 int res = ed25519_verify(year, totalAmount, taxId, salt, eddsaSignature, candidate); 174 if (res == 0) { 175 publicKey = candidate; // remember the matching key for UI/details 176 statusHandling(SignatureStatus.SIGNATURE_VALID); 177 return; 178 } 179 } 180 statusHandling(SignatureStatus.SIGNATURE_INVALID); 181 } 182 183 private void startVerificationAsync() { 184 new Thread(() -> { 185 SignatureStatus statusResult = ensurePublicKeyAvailable(); 186 if (statusResult == null) { 187 statusResult = ensureDonationStatementAvailable(); 188 } 189 SignatureStatus finalStatus = statusResult; 190 runOnUiThread(() -> { 191 if (isFinishing() || isDestroyed()) { 192 return; 193 } 194 if (finalStatus != null) { 195 statusHandling(finalStatus); 196 return; 197 } 198 try { 199 checkSignature(); 200 } catch (Exception e) { 201 Log.e(TAG, "Signature verification failed", e); 202 statusHandling(SignatureStatus.SIGNATURE_INVALID); 203 } 204 }); 205 }).start(); 206 } 207 208 private void statusHandling(SignatureStatus es) { 209 switch (es) { 210 case INVALID_SCHEME: 211 updateStatusCard(R.string.invalid_scheme, R.color.validation_surface_error, R.color.red, false); 212 break; 213 case INVALID_NUMBER_OF_ARGUMENTS: 214 updateStatusCard(R.string.invalid_number_of_arguments, R.color.validation_surface_error, R.color.red, false); 215 break; 216 case MALFORMED_ARGUMENT: 217 updateStatusCard(R.string.malformed_argument, R.color.validation_surface_error, R.color.red, false); 218 break; 219 case INSECURE_HTTP_UNSUPPORTED: 220 updateStatusCard(R.string.status_insecure_http_unsupported, R.color.validation_surface_neutral, R.color.text_primary, false); 221 break; 222 case INSECURE_HTTP_DISABLED: 223 updateStatusCard(R.string.status_insecure_http_disabled, R.color.validation_surface_neutral, R.color.text_primary, false); 224 break; 225 case KEY_DOWNLOAD_FAILED: 226 updateStatusCard(R.string.status_key_download_failed, R.color.validation_surface_error, R.color.red, false); 227 break; 228 case KEY_NOT_FOUND: 229 updateStatusCard(R.string.status_key_not_found, R.color.validation_surface_info, R.color.colorSecondary, false); 230 break; 231 case DONATION_STATEMENT_DOWNLOAD_FAILED: 232 updateStatusCard(R.string.status_donation_statement_download_failed, R.color.validation_surface_error, R.color.red, false); 233 break; 234 case DONATION_STATEMENT_NOT_FOUND: 235 updateStatusCard(R.string.status_donation_statement_not_found, R.color.validation_surface_info, R.color.colorSecondary, false); 236 break; 237 case DONATION_STATEMENT_INVALID: 238 updateStatusCard(R.string.status_donation_statement_invalid, R.color.validation_surface_error, R.color.red, false); 239 break; 240 case SIGNATURE_INVALID: 241 updateStatusCard(R.string.invalid_signature, R.color.validation_surface_error, R.color.red, false); 242 break; 243 case SIGNATURE_VALID: 244 updateStatusCard(R.string.valid_signature, R.color.validation_surface_success, R.color.green, true); 245 setSummaryValues(year, taxId, totalAmount); 246 break; 247 } 248 } 249 250 private void updateStatusCard(@StringRes int messageRes, 251 @ColorRes int backgroundColorRes, 252 @ColorRes int textColorRes, 253 boolean showDetails) { 254 sigStatusView.setText(messageRes); 255 if (statusCard != null) { 256 statusCard.setCardBackgroundColor(ContextCompat.getColor(this, backgroundColorRes)); 257 } 258 sigStatusView.setTextColor(ContextCompat.getColor(this, textColorRes)); 259 if (summaryContainer != null) { 260 summaryContainer.setVisibility(showDetails ? View.VISIBLE : View.GONE); 261 } 262 if (signatureButton != null) { 263 signatureButton.setVisibility(showDetails ? View.VISIBLE : View.GONE); 264 signatureButton.setEnabled(showDetails); 265 } 266 if (showDetails) { 267 setSummaryValues(year, taxId, totalAmount); 268 } else { 269 clearSummaryValues(); 270 } 271 } 272 273 private void setSummaryLabels() { 274 if (hostLabelView != null) { 275 hostLabelView.setText(R.string.label_host); 276 hostLabelView.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)); 277 } 278 if (yearLabelView != null) { 279 yearLabelView.setText(R.string.label_year); 280 yearLabelView.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)); 281 } 282 if (taxLabelView != null) { 283 taxLabelView.setText(R.string.label_tax_id); 284 taxLabelView.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)); 285 } 286 if (amountLabelView != null) { 287 amountLabelView.setText(R.string.label_amount); 288 amountLabelView.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)); 289 } 290 clearSummaryValues(); 291 } 292 293 private void clearSummaryValues() { 294 if (hostValueView != null) { 295 hostValueView.setText(""); 296 } 297 if (yearValueView != null) { 298 yearValueView.setText(""); 299 } 300 if (taxValueView != null) { 301 taxValueView.setText(""); 302 } 303 if (amountValueView != null) { 304 amountValueView.setText(""); 305 } 306 } 307 308 private void setSummaryValues(String yearValue, String taxValue, String amountValue) { 309 if (hostValueView != null) { 310 hostValueView.setText(valueOrUnknown(hostDisplay)); 311 } 312 if (yearValueView != null) { 313 yearValueView.setText(valueOrUnknown(yearValue)); 314 } 315 if (taxValueView != null) { 316 taxValueView.setText(valueOrUnknown(taxValue)); 317 } 318 if (amountValueView != null) { 319 amountValueView.setText(valueOrUnknown(amountValue)); 320 } 321 } 322 323 private void setupBackNavigation() { 324 View backButton = findViewById(R.id.backButton); 325 if (backButton != null) { 326 backButton.setOnClickListener(v -> finish()); 327 } 328 } 329 330 private void showSignatureDialog() { 331 String lineBreak = "\n"; 332 String message = getString(R.string.signature_info_salt, valueOrUnknown(salt)) 333 + lineBreak 334 + getString(R.string.signature_info_signature, valueOrUnknown(eddsaSignature)) 335 + lineBreak 336 + getString(R.string.signature_info_public_key, valueOrUnknown(publicKey)); 337 new MaterialAlertDialogBuilder(this) 338 .setTitle(R.string.signature_info_title) 339 .setMessage(message) 340 .setPositiveButton(android.R.string.ok, null) 341 .show(); 342 } 343 344 private String valueOrUnknown(String candidate) { 345 return isEmpty(candidate) ? getString(R.string.value_unknown) : candidate; 346 } 347 348 public native int ed25519_verify(String year, String totalAmount, 349 String taxId, String salt, 350 String eddsaSignature, String publicKey); 351 352 private Uri resolveUri(Intent intent) { 353 Uri data = intent.getData(); 354 if (data != null) { 355 return data; 356 } 357 String raw = intent.getStringExtra("QR-String"); 358 if (raw == null) { 359 return null; 360 } 361 return Uri.parse(raw); 362 } 363 364 private boolean isSupportedScheme(String scheme) { 365 if (scheme == null) { 366 return false; 367 } 368 String lowered = scheme.toLowerCase(Locale.ROOT); 369 return "donau".equals(lowered) || "donau+http".equals(lowered); 370 } 371 372 private void resetParsedFields() { 373 authorityPathSegments.clear(); 374 year = null; 375 totalAmount = null; 376 taxId = null; 377 hostDisplay = null; 378 salt = null; 379 eddsaSignature = null; 380 publicKey = null; 381 publicKeys.clear(); 382 networkClient = null; 383 } 384 385 private SignatureStatus parseDonauUri(Uri uri) { 386 host = uri.getHost(); 387 port = uri.getPort(); 388 if (isEmpty(host)) { 389 return SignatureStatus.MALFORMED_ARGUMENT; 390 } 391 392 List<String> segments = uri.getPathSegments(); 393 if (segments == null) { 394 return SignatureStatus.INVALID_NUMBER_OF_ARGUMENTS; 395 } 396 397 authorityPathSegments.clear(); 398 399 if (segments.size() < 3) { 400 return SignatureStatus.INVALID_NUMBER_OF_ARGUMENTS; 401 } 402 403 int yearIndex = segments.size() - 3; 404 for (int i = 0; i < yearIndex; i++) { 405 String segment = segments.get(i); 406 String trimmed = segment != null ? segment.trim() : null; 407 if (!isEmpty(trimmed)) { 408 authorityPathSegments.add(trimmed); 409 } 410 } 411 412 hostDisplay = buildHostDisplay(); 413 414 String yearCandidate = segments.get(yearIndex); 415 if (yearCandidate != null) { 416 yearCandidate = yearCandidate.trim(); 417 } 418 if (!isFourDigitYear(yearCandidate)) { 419 return SignatureStatus.MALFORMED_ARGUMENT; 420 } 421 422 year = yearCandidate; 423 424 // Tax ID: use exact UTF-8 bytes from percent-decoded segment without trimming 425 String taxIdCandidate = segments.get(yearIndex + 1); 426 if (isEmpty(taxIdCandidate)) { 427 return SignatureStatus.MALFORMED_ARGUMENT; 428 } 429 taxId = taxIdCandidate; 430 431 String saltCandidate = segments.get(yearIndex + 2); 432 if (saltCandidate != null) { 433 saltCandidate = saltCandidate.trim(); 434 } 435 if (isEmpty(saltCandidate)) { 436 return SignatureStatus.MALFORMED_ARGUMENT; 437 } 438 salt = saltCandidate; 439 440 String totalParam = uri.getQueryParameter("total"); 441 if (totalParam != null) { 442 String trimmedTotal = totalParam.trim(); 443 if (trimmedTotal.isEmpty()) { 444 return SignatureStatus.MALFORMED_ARGUMENT; 445 } 446 totalAmount = trimmedTotal; 447 } else { 448 totalAmount = null; 449 } 450 451 String sigParam = uri.getQueryParameter("sig"); 452 if (sigParam != null) { 453 eddsaSignature = extractEd25519Signature(sigParam); 454 if (isEmpty(eddsaSignature)) { 455 return SignatureStatus.MALFORMED_ARGUMENT; 456 } 457 } else { 458 eddsaSignature = null; 459 } 460 461 String publicKeyParam = uri.getQueryParameter("pub"); 462 publicKey = isEmpty(publicKeyParam) ? null : publicKeyParam.trim(); 463 464 networkClient = new DonauNetworkClient(isInsecureScheme(), host, port, authorityPathSegments); 465 466 return null; 467 } 468 469 private SignatureStatus ensurePublicKeyAvailable() { 470 boolean insecureScheme = isInsecureScheme(); 471 boolean hasEmbeddedPub = !isEmpty(publicKey); 472 if (insecureScheme) { 473 if (!BuildConfig.ALLOW_INSECURE_HTTP) { 474 return SignatureStatus.INSECURE_HTTP_UNSUPPORTED; 475 } 476 if (!isDeveloperModeEnabled()) { 477 return SignatureStatus.INSECURE_HTTP_DISABLED; 478 } 479 if (hasEmbeddedPub && isTestingHost()) { 480 return null; 481 } 482 } 483 484 if (!insecureScheme && hasEmbeddedPub) { 485 return null; 486 } 487 488 try { 489 DonauNetworkClient client = getNetworkClient(); 490 List<String> fetched = client.fetchSigningKeys(); 491 if (fetched == null || fetched.isEmpty()) { 492 return SignatureStatus.KEY_NOT_FOUND; 493 } 494 publicKeys.clear(); 495 publicKeys.addAll(fetched); 496 return null; 497 } catch (HttpStatusException e) { 498 Log.e(TAG, "Failed to download Donau signing keys, HTTP " + e.getStatusCode(), e); 499 return SignatureStatus.KEY_DOWNLOAD_FAILED; 500 } catch (IOException | JSONException e) { 501 Log.e(TAG, "Failed to download Donau signing keys", e); 502 return SignatureStatus.KEY_DOWNLOAD_FAILED; 503 } 504 } 505 506 private SignatureStatus ensureDonationStatementAvailable() { 507 boolean needsTotal = isEmpty(totalAmount); 508 boolean needsSignature = isEmpty(eddsaSignature); 509 if (!needsTotal && !needsSignature) { 510 return null; 511 } 512 if (isEmpty(taxId) || isEmpty(salt) || isEmpty(year)) { 513 return SignatureStatus.MALFORMED_ARGUMENT; 514 } 515 try { 516 String donorHash = computeDonorHash(taxId, salt); 517 DonauNetworkClient client = getNetworkClient(); 518 int donationYear = parseYearOrDefault(year); 519 DonationStatement statement = client.fetchDonationStatement(donationYear, donorHash); 520 String statementTotal = statement.total(); 521 String statementSignature = statement.signature(); 522 String statementPublicKey = statement.publicKey(); 523 if (isEmpty(statementTotal) || isEmpty(statementSignature) || isEmpty(statementPublicKey)) { 524 Log.e(TAG, "Donation statement response missing required fields"); 525 return SignatureStatus.DONATION_STATEMENT_INVALID; 526 } 527 if (!needsTotal && totalAmount != null && !totalAmount.equals(statementTotal)) { 528 Log.e(TAG, "Donation statement total mismatch"); 529 return SignatureStatus.DONATION_STATEMENT_INVALID; 530 } 531 if (!isEmpty(publicKey) && !publicKey.equals(statementPublicKey)) { 532 Log.e(TAG, "Donation statement public key mismatch"); 533 return SignatureStatus.DONATION_STATEMENT_INVALID; 534 } 535 String extractedSignature = extractEd25519Signature(statementSignature); 536 String normalizedSignature; 537 if (extractedSignature != null) { 538 normalizedSignature = extractedSignature; 539 } else { 540 if (statementSignature.contains(":") || statementSignature.contains("=")) { 541 Log.e(TAG, "Donation statement signature format invalid"); 542 return SignatureStatus.DONATION_STATEMENT_INVALID; 543 } 544 normalizedSignature = statementSignature.trim(); 545 } 546 if (!needsSignature && eddsaSignature != null && !eddsaSignature.equals(normalizedSignature)) { 547 Log.e(TAG, "Donation statement signature mismatch"); 548 return SignatureStatus.DONATION_STATEMENT_INVALID; 549 } 550 totalAmount = statementTotal; 551 eddsaSignature = normalizedSignature; 552 publicKey = statementPublicKey; 553 return null; 554 } catch (HttpStatusException e) { 555 if (e.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { 556 return SignatureStatus.DONATION_STATEMENT_NOT_FOUND; 557 } 558 Log.e(TAG, "Donation statement download failed, HTTP " + e.getStatusCode(), e); 559 return SignatureStatus.DONATION_STATEMENT_DOWNLOAD_FAILED; 560 } catch (IOException | JSONException e) { 561 Log.e(TAG, "Donation statement download failed", e); 562 return SignatureStatus.DONATION_STATEMENT_DOWNLOAD_FAILED; 563 } catch (NoSuchAlgorithmException e) { 564 Log.e(TAG, "Unable to hash donor identifier", e); 565 return SignatureStatus.DONATION_STATEMENT_INVALID; 566 } 567 } 568 569 private DonauNetworkClient getNetworkClient() { 570 if (networkClient == null) { 571 networkClient = new DonauNetworkClient(isInsecureScheme(), host, port, authorityPathSegments); 572 } 573 return networkClient; 574 } 575 576 private int parseYearOrDefault(String value) { 577 if (isEmpty(value)) { 578 return Integer.MIN_VALUE; 579 } 580 try { 581 return Integer.parseInt(value); 582 } catch (NumberFormatException e) { 583 return Integer.MIN_VALUE; 584 } 585 } 586 587 private String computeDonorHash(String taxIdValue, String saltValue) throws NoSuchAlgorithmException { 588 MessageDigest digest = MessageDigest.getInstance("SHA-512"); 589 // H = SHA-512( UTF-8(taxId) || 0x00 || UTF-8(salt) || 0x00 ) 590 digest.update(taxIdValue.getBytes(StandardCharsets.UTF_8)); 591 digest.update(new byte[]{0}); 592 digest.update(saltValue.getBytes(StandardCharsets.UTF_8)); 593 digest.update(new byte[]{0}); 594 byte[] hash = digest.digest(); 595 return CrockfordBase32.encode(hash); 596 } 597 598 private String buildHostDisplay() { 599 if (isEmpty(host)) { 600 return null; 601 } 602 StringBuilder builder = new StringBuilder(host); 603 if (port != -1) { 604 builder.append(":").append(port); 605 } 606 for (String segment : authorityPathSegments) { 607 if (!isEmpty(segment)) { 608 builder.append("/").append(segment); 609 } 610 } 611 return builder.toString(); 612 } 613 614 private boolean isInsecureScheme() { 615 return uriScheme != null && "donau+http".equalsIgnoreCase(uriScheme); 616 } 617 618 private boolean isDeveloperModeEnabled() { 619 if (!BuildConfig.ENABLE_DEVELOPER_MODE) { 620 return false; 621 } 622 SharedPreferences prefs = 623 PreferenceManager.getDefaultSharedPreferences(this); 624 return prefs.getBoolean(SettingsActivity.KEY_DEVELOPER_MODE, false); 625 } 626 627 private boolean isTestingHost() { 628 return host != null && host.equalsIgnoreCase("example.com"); 629 } 630 631 private boolean isFourDigitYear(String value) { 632 if (value == null || value.length() != 4) { 633 return false; 634 } 635 for (int i = 0; i < value.length(); i++) { 636 if (!Character.isDigit(value.charAt(i))) { 637 return false; 638 } 639 } 640 return true; 641 } 642 643 private boolean isEmpty(String value) { 644 return value == null || value.trim().isEmpty(); 645 } 646 647 private String extractEd25519Signature(String raw) { 648 if (raw == null) { 649 return null; 650 } 651 String trimmed = raw.trim(); 652 int separatorIndex = trimmed.indexOf(':'); 653 if (separatorIndex < 0) { 654 separatorIndex = trimmed.indexOf('='); 655 } 656 if (separatorIndex <= 0 || separatorIndex >= trimmed.length() - 1) { 657 return null; 658 } 659 String algorithm = trimmed.substring(0, separatorIndex).trim(); 660 if (!"ED25519".equalsIgnoreCase(algorithm)) { 661 return null; 662 } 663 String signature = trimmed.substring(separatorIndex + 1).trim(); 664 if (signature.isEmpty()) { 665 return null; 666 } 667 return signature; 668 } 669 670 private boolean isDigitsOnly(String value) { 671 if (isEmpty(value)) { 672 return false; 673 } 674 for (int i = 0; i < value.length(); i++) { 675 if (!Character.isDigit(value.charAt(i))) { 676 return false; 677 } 678 } 679 return true; 680 } 681 }