001// SPDX-License-Identifier: GPL-3.0-or-later 002// Copyright © 2026 Aurélien Pierre 003 004package com.aurelienpierre.sioul; 005 006import android.app.Activity; 007import android.app.Application; 008import android.app.PendingIntent; 009import android.content.ActivityNotFoundException; 010import android.content.BroadcastReceiver; 011import android.content.Context; 012import android.content.Intent; 013import android.content.IntentFilter; 014import android.content.pm.PackageManager; 015import android.hardware.usb.UsbConstants; 016import android.hardware.usb.UsbDevice; 017import android.hardware.usb.UsbDeviceConnection; 018import android.hardware.usb.UsbEndpoint; 019import android.hardware.usb.UsbInterface; 020import android.hardware.usb.UsbManager; 021import android.nfc.NfcAdapter; 022import android.nfc.Tag; 023import android.nfc.TagLostException; 024import android.nfc.tech.IsoDep; 025import android.os.Build; 026import android.os.Bundle; 027import android.provider.Settings; 028import android.view.WindowManager; 029 030import java.io.IOException; 031import java.util.Arrays; 032import java.util.HashSet; 033import java.util.Set; 034 035/** 036 * Your security key held against the phone or plugged into its USB-C port 037 * (crates/sioul-app/src/nfc.rs, crates/sioul-sync/src/securitykey/phone.rs, 038 * docs/android.md "Your security key"), for Rust through android/main.cpp. 039 * 040 * <ul> 041 * <li><b>Reader mode only while asked.</b> While a band of Sioul's waits for 042 * the key and Sioul is in front, NFC is read in reader mode 043 * ({@link NfcAdapter#enableReaderMode}: NFC-A and B, no NDEF check, a 044 * presence check every 50 ms), ahead of other apps; never otherwise, so that 045 * OpenKeychain, Yubico Authenticator and the key's Yubico OTP (its NDEF 046 * address) work as before. Android keeps it on the activity across a pause 047 * and a return, until Sioul lets it go. No tag filter in the manifest: 048 * holding the key never opens Sioul.</li> 049 * <li><b>The key held</b> is an {@link IsoDep}, connected at once, five 050 * seconds an exchange; its APDUs go through {@link #transceive}, on Rust's 051 * thread, never Android's main one. A key that leaves the field throws 052 * {@link TagLostException}, which Rust says as a key moved away.</li> 053 * <li><b>Let go</b> when Sioul no longer asks: {@link NfcAdapter#ignore}, so 054 * that Android hands the key, still in the field, to no other app until it 055 * has left it, then reader mode ends.</li> 056 * <li><b>Plugged in</b>: while a band asks for the key, a key plugged in 057 * (before or after) is looked for among the USB devices, by its smart card 058 * interface (class 0x0B) alone: never its keyboard (Yubico OTP types there, 059 * untouched) nor its FIDO interface. Android's permission is asked once for 060 * each time it is plugged in: refused, Sioul says so and asks again only 061 * once it has been unplugged. Its interface claimed, the APDUs go as CCID 062 * messages ({@link Ccid}), on Rust's thread; a key unplugged meanwhile is a 063 * key gone, as over NFC ({@link TagLostException}). Claimed by another app 064 * (Yubico Authenticator, OpenKeychain), it is said so. When Sioul no longer 065 * asks, the key is powered off, its interface released and its connection 066 * closed: other apps have it again. No USB filter in the manifest: plugging 067 * the key never opens Sioul.</li> 068 * <li><b>The screen kept from captures</b> (FLAG_SECURE) while Sioul asks 069 * for the key: its PIN is typed then.</li> 070 * <li><b>Nothing logged</b>: never a byte of an APDU, a PIN's among them; 071 * each command's array is wiped once sent.</li> 072 * </ul> 073 */ 074public final class SecurityKeyLink { 075 /** How long one exchange may take: Ed25519 and Curve25519 take far less; the margin is for RSA and slower keys. */ 076 static final int TIMEOUT_MS = 5000; 077 /** How long a key let go must stay away before Android reads it again. */ 078 static final int AWAY_MS = 1000; 079 080 private static final Object lock = new Object(); 081 /** The key held now. */ 082 private static IsoDep held; 083 /** Its tag, to let it go. */ 084 private static Tag heldTag; 085 /** The activity reader mode is on for. */ 086 private static Activity reading; 087 088 /** The permission's answer, for this package's receiver alone. */ 089 static final String PERMISSION = "com.aurelienpierre.sioul.SECURITY_KEY_USB"; 090 /** A key plugged in, open: its connection, its interface, its messages. */ 091 private static UsbDevice plugged; 092 private static UsbDeviceConnection connection; 093 private static UsbInterface claimed; 094 private static Ccid ccid; 095 private static boolean powered; 096 /** Unplugged while open: what is sent now fails as a key gone. */ 097 private static boolean unplugged; 098 /** Keys plugged in that Android was asked for (by device name), until unplugged. */ 099 private static final Set<String> asked = new HashSet<>(); 100 /** Those Android refused, until unplugged. */ 101 private static final Set<String> refused = new HashSet<>(); 102 /** One that another app holds, until unplugged or asked again. */ 103 private static String busy; 104 /** Android's USB service, once a key was wanted: what is plugged in now. */ 105 private static UsbManager usb; 106 /** The receiver of plugging, unplugging and Android's answer, while a key is wanted and Sioul is in front. */ 107 private static BroadcastReceiver usbReceiver; 108 /** A key Android was asked for and has not answered yet: its dialog is in front. */ 109 private static String pending; 110 /** Sioul's activity going to the background or coming back, heard once a key was wanted. */ 111 private static Application.ActivityLifecycleCallbacks lifecycle; 112 /** 113 * Before Android 13 a receiver registered in code is exported: only a 114 * sender that holds this permission (signature level: Sioul itself, for 115 * Android's answer, and the system, for plugging and unplugging) reaches 116 * it, so that no other app can play Android's answer or a key unplugged. 117 */ 118 static final String BROADCASTS = "com.aurelienpierre.sioul.permission.SECURITY_KEY_BROADCAST"; 119 120 private SecurityKeyLink() {} 121 122 /** Whether this phone takes a key plugged into its USB-C port (USB host). */ 123 static boolean usbHost(Context context) { 124 return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_USB_HOST); 125 } 126 127 /** NFC in this phone: 0 none, 1 switched off, 2 ready. */ 128 static int state(Context context) { 129 NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context); 130 if (adapter == null) 131 return 0; 132 return adapter.isEnabled() ? 2 : 1; 133 } 134 135 /** A key wanted (reader mode on, the screen kept from captures), or no longer (the key let go, reader mode off). Any thread. */ 136 static void want(Context context, boolean wanted) { 137 if (!(context instanceof Activity)) 138 return; 139 Activity activity = (Activity) context; 140 activity.runOnUiThread(() -> { 141 if (wanted) 142 start(activity); 143 else 144 stop(activity); 145 }); 146 } 147 148 private static void start(Activity activity) { 149 if (reading == activity) 150 return; 151 listen(activity); 152 NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity); 153 if (adapter == null) { 154 activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); 155 reading = activity; 156 return; 157 } 158 Bundle extras = new Bundle(); 159 extras.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 50); 160 int flags = NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_NFC_B | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK; 161 try { 162 adapter.enableReaderMode(activity, SecurityKeyLink::found, flags, extras); 163 } catch (IllegalStateException | SecurityException e) { 164 // NFC refused: a key plugged in still comes. 165 } 166 reading = activity; 167 activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); 168 } 169 170 private static void stop(Activity activity) { 171 NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity); 172 letGo(adapter); 173 unplug(activity); 174 if (reading != activity) 175 return; 176 if (adapter != null) { 177 try { 178 adapter.disableReaderMode(activity); 179 } catch (IllegalStateException e) { 180 // The activity went away meanwhile: Android ended reader mode with it. 181 } 182 } 183 reading = null; 184 activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); 185 } 186 187 /** The key held let go: ignored by Android until it has left the field, then closed. */ 188 private static void letGo(NfcAdapter adapter) { 189 IsoDep iso; 190 Tag tag; 191 synchronized (lock) { 192 iso = held; 193 tag = heldTag; 194 held = null; 195 heldTag = null; 196 } 197 if (adapter != null && tag != null) { 198 try { 199 adapter.ignore(tag, AWAY_MS, null, null); 200 } catch (RuntimeException e) { 201 // Gone already. 202 } 203 } 204 close(iso); 205 } 206 207 private static void close(IsoDep iso) { 208 if (iso == null) 209 return; 210 try { 211 iso.close(); 212 } catch (IOException e) { 213 // Gone already. 214 } 215 } 216 217 /** A tag in reach, on NFC's thread: kept when it speaks ISO-DEP (an OpenPGP card does), Rust told. */ 218 private static void found(Tag tag) { 219 IsoDep iso = IsoDep.get(tag); 220 if (iso == null) 221 return; 222 try { 223 iso.connect(); 224 iso.setTimeout(TIMEOUT_MS); 225 } catch (IOException | SecurityException e) { 226 return; 227 } 228 IsoDep before; 229 synchronized (lock) { 230 before = held; 231 held = iso; 232 heldTag = tag; 233 } 234 close(before); 235 told(); 236 } 237 238 /** The longest APDU sent to a key plugged in: short APDUs, chained by Rust, and their header. */ 239 static final int PLUGGED_LONGEST = 271; 240 241 /** 242 * The key in reach now, as {what (0 none, 1 a key, 2 refused by Android, 3 243 * held by another app), longest exchange, extended length (1) or not (0), 244 * plugged in (1) or held (0)}: a key plugged in first, else one held. 245 */ 246 static int[] held() { 247 synchronized (lock) { 248 if (ccid != null && !unplugged) 249 return new int[] { 1, PLUGGED_LONGEST, 0, 1 }; 250 if (held != null && held.isConnected()) 251 return new int[] { 1, held.getMaxTransceiveLength(), held.isExtendedLengthApduSupported() ? 1 : 0, 0 }; 252 // A key refused, then unplugged while Sioul did not listen: no longer refused. 253 if (!refused.isEmpty() && usb != null) 254 refused.retainAll(usb.getDeviceList().keySet()); 255 if (!refused.isEmpty()) 256 return new int[] { 2, 0, 0, 1 }; 257 if (busy != null) 258 return new int[] { 3, 0, 0, 1 }; 259 return new int[] { 0, 0, 0, 0 }; 260 } 261 } 262 263 /** One APDU to the key in reach, on Rust's thread; the command wiped once sent. */ 264 static byte[] transceive(byte[] command) throws IOException { 265 IsoDep iso; 266 Ccid messages; 267 synchronized (lock) { 268 iso = held; 269 messages = unplugged ? null : ccid; 270 } 271 try { 272 if (messages != null) 273 return plugged(messages, command); 274 if (iso == null) 275 throw new TagLostException(); 276 return iso.transceive(command); 277 } finally { 278 Arrays.fill(command, (byte) 0); 279 } 280 } 281 282 /** One APDU to the key plugged in, powered on first; unplugged meanwhile, a key gone. */ 283 private static byte[] plugged(Ccid messages, byte[] command) throws IOException { 284 // One message at a time: Rust's exchanges, the power off at the end. 285 synchronized (messages) { 286 return exchange(messages, command); 287 } 288 } 289 290 private static byte[] exchange(Ccid messages, byte[] command) throws IOException { 291 try { 292 boolean on; 293 synchronized (lock) { 294 on = powered; 295 } 296 if (!on) { 297 Arrays.fill(messages.powerOn(), (byte) 0); 298 synchronized (lock) { 299 powered = true; 300 } 301 } 302 return messages.transmit(command); 303 } catch (IOException e) { 304 // Unplugged: heard (closed meanwhile), or not yet heard but no longer there. 305 synchronized (lock) { 306 if (unplugged || ccid != messages || plugged == null || usb == null || !usb.getDeviceList().containsKey(plugged.getDeviceName())) 307 throw new TagLostException(); 308 } 309 throw e; 310 } 311 } 312 313 /** Plugging, unplugging and Android's answer heard while a key is wanted; a key plugged in already looked for. */ 314 private static void listen(Activity activity) { 315 UsbManager manager = (UsbManager) activity.getSystemService(Context.USB_SERVICE); 316 if (manager == null || !usbHost(activity)) 317 return; 318 usb = manager; 319 if (usbReceiver == null) { 320 usbReceiver = new BroadcastReceiver() { 321 @Override 322 public void onReceive(Context context, Intent intent) { 323 heard(activity, manager, intent); 324 } 325 }; 326 IntentFilter filter = new IntentFilter(); 327 filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); 328 filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); 329 filter.addAction(PERMISSION); 330 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) 331 activity.registerReceiver(usbReceiver, filter, Context.RECEIVER_NOT_EXPORTED); 332 else 333 activity.registerReceiver(usbReceiver, filter, BROADCASTS, null); 334 } 335 follow(activity); 336 // What was refused or asked for a key no longer plugged in is forgotten: plugged in again, it is asked again. 337 Set<String> there = manager.getDeviceList().keySet(); 338 synchronized (lock) { 339 asked.retainAll(there); 340 refused.retainAll(there); 341 if (busy != null && !there.contains(busy)) 342 busy = null; 343 } 344 for (UsbDevice device : manager.getDeviceList().values()) 345 consider(activity, manager, device); 346 } 347 348 private static void heard(Activity activity, UsbManager manager, Intent intent) { 349 UsbDevice device = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ? intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice.class) : deviceOf(intent); 350 if (device == null) 351 return; 352 String action = intent.getAction(); 353 if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { 354 // Believed only when Android no longer lists it. 355 if (manager.getDeviceList().containsKey(device.getDeviceName())) 356 return; 357 boolean ours; 358 synchronized (lock) { 359 asked.remove(device.getDeviceName()); 360 refused.remove(device.getDeviceName()); 361 if (device.getDeviceName().equals(pending)) 362 pending = null; 363 if (device.getDeviceName().equals(busy)) 364 busy = null; 365 ours = plugged != null && plugged.getDeviceName().equals(device.getDeviceName()); 366 if (ours) 367 unplugged = true; 368 } 369 if (ours) 370 close(); 371 told(); 372 } else if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { 373 consider(activity, manager, device); 374 } else if (PERMISSION.equals(action)) { 375 // An answer about a key Sioul asked for, alone. 376 synchronized (lock) { 377 if (!asked.contains(device.getDeviceName())) 378 return; 379 if (device.getDeviceName().equals(pending)) 380 pending = null; 381 } 382 if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) && manager.hasPermission(device)) { 383 open(manager, device); 384 } else { 385 synchronized (lock) { 386 refused.add(device.getDeviceName()); 387 } 388 told(); 389 } 390 } 391 } 392 393 /** The device an intent names, as Android before 13 gives it. */ 394 @SuppressWarnings("deprecation") 395 private static UsbDevice deviceOf(Intent intent) { 396 return intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); 397 } 398 399 /** A USB device: a security key when it has a smart card interface; opened, or Android asked once. */ 400 private static void consider(Activity activity, UsbManager manager, UsbDevice device) { 401 if (cardInterface(device) == null) 402 return; 403 synchronized (lock) { 404 if (ccid != null || refused.contains(device.getDeviceName())) 405 return; 406 } 407 if (manager.hasPermission(device)) { 408 open(manager, device); 409 return; 410 } 411 synchronized (lock) { 412 if (!asked.add(device.getDeviceName())) 413 return; 414 pending = device.getDeviceName(); 415 } 416 Intent answer = new Intent(PERMISSION).setPackage(activity.getPackageName()); 417 int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0; 418 manager.requestPermission(device, PendingIntent.getBroadcast(activity, 0, answer, flags)); 419 } 420 421 /** The device's smart card interface (CCID, class 0x0B), with its two bulk endpoints; null when it has none. */ 422 static UsbInterface cardInterface(UsbDevice device) { 423 for (int i = 0; i < device.getInterfaceCount(); i++) { 424 UsbInterface candidate = device.getInterface(i); 425 if (candidate.getInterfaceClass() == UsbConstants.USB_CLASS_CSCID && endpoint(candidate, UsbConstants.USB_DIR_IN) != null && endpoint(candidate, UsbConstants.USB_DIR_OUT) != null) 426 return candidate; 427 } 428 return null; 429 } 430 431 private static UsbEndpoint endpoint(UsbInterface face, int direction) { 432 for (int i = 0; i < face.getEndpointCount(); i++) { 433 UsbEndpoint point = face.getEndpoint(i); 434 if (point.getType() == UsbConstants.USB_ENDPOINT_XFER_BULK && point.getDirection() == direction) 435 return point; 436 } 437 return null; 438 } 439 440 /** The key plugged in, its smart card interface claimed alone; another app holding it, said so. */ 441 private static void open(UsbManager manager, UsbDevice device) { 442 UsbInterface face = cardInterface(device); 443 if (face == null) 444 return; 445 UsbDeviceConnection opened; 446 try { 447 opened = manager.openDevice(device); 448 } catch (SecurityException e) { 449 return; 450 } 451 if (opened == null || !opened.claimInterface(face, true)) { 452 if (opened != null) 453 opened.close(); 454 synchronized (lock) { 455 busy = device.getDeviceName(); 456 } 457 told(); 458 return; 459 } 460 UsbEndpoint in = endpoint(face, UsbConstants.USB_DIR_IN); 461 UsbEndpoint out = endpoint(face, UsbConstants.USB_DIR_OUT); 462 Ccid messages = new Ccid(new Ccid.Pipe() { 463 @Override 464 public int send(byte[] data, int offset, int length, int timeoutMs) { 465 return opened.bulkTransfer(out, data, offset, length, timeoutMs); 466 } 467 468 @Override 469 public int receive(byte[] into, int timeoutMs) { 470 return opened.bulkTransfer(in, into, into.length, timeoutMs); 471 } 472 473 @Override 474 public boolean present() { 475 synchronized (lock) { 476 if (unplugged || connection != opened) 477 return false; 478 } 479 return manager.getDeviceList().containsKey(device.getDeviceName()); 480 } 481 }, out.getMaxPacketSize()); 482 synchronized (lock) { 483 plugged = device; 484 connection = opened; 485 claimed = face; 486 ccid = messages; 487 powered = false; 488 unplugged = false; 489 busy = null; 490 } 491 told(); 492 } 493 494 /** 495 * Sioul in the background while a key is wanted: the key plugged in let 496 * go and the receiver unregistered, so that other apps have it meanwhile, 497 * unless Android's own dialog (its question for the key) is what came in 498 * front; back in front, looked for again (asked no more: Android's answer 499 * stands until it is unplugged). Reader mode Android ends and brings back 500 * itself. 501 */ 502 private static void follow(Activity activity) { 503 if (lifecycle != null) 504 return; 505 lifecycle = new Application.ActivityLifecycleCallbacks() { 506 @Override 507 public void onActivityPaused(Activity paused) { 508 boolean asking; 509 synchronized (lock) { 510 asking = pending != null; 511 } 512 if (paused == reading && !asking) 513 unplug(paused); 514 } 515 516 @Override 517 public void onActivityResumed(Activity resumed) { 518 if (resumed == reading) 519 listen(resumed); 520 } 521 522 @Override 523 public void onActivityCreated(Activity created, Bundle state) {} 524 525 @Override 526 public void onActivityStarted(Activity started) {} 527 528 @Override 529 public void onActivityStopped(Activity stopped) {} 530 531 @Override 532 public void onActivitySaveInstanceState(Activity saved, Bundle state) {} 533 534 @Override 535 public void onActivityDestroyed(Activity destroyed) { 536 if (destroyed == reading) 537 reading = null; 538 } 539 }; 540 activity.getApplication().registerActivityLifecycleCallbacks(lifecycle); 541 } 542 543 /** No longer asked: the key plugged in powered off, its interface given back, and Android's receiver let go. */ 544 private static void unplug(Activity activity) { 545 Ccid messages; 546 synchronized (lock) { 547 messages = powered && !unplugged ? ccid : null; 548 // Held by another app: looked at again the next time a key is wanted. 549 busy = null; 550 // Android's answer, if it comes now, is not heard: asked again next time. 551 if (pending != null) 552 asked.remove(pending); 553 pending = null; 554 } 555 if (messages != null) { 556 // On Android's main thread: the power off is one short message; a key gone fails at once. 557 synchronized (messages) { 558 try { 559 messages.powerOff(); 560 } catch (IOException e) { 561 // Gone already. 562 } 563 } 564 } 565 close(); 566 if (usbReceiver != null) { 567 try { 568 activity.unregisterReceiver(usbReceiver); 569 } catch (IllegalArgumentException e) { 570 // Not registered. 571 } 572 usbReceiver = null; 573 } 574 } 575 576 private static void close() { 577 UsbDeviceConnection opened; 578 UsbInterface face; 579 synchronized (lock) { 580 opened = connection; 581 face = claimed; 582 connection = null; 583 claimed = null; 584 ccid = null; 585 plugged = null; 586 powered = false; 587 } 588 if (opened != null) { 589 if (face != null) 590 opened.releaseInterface(face); 591 opened.close(); 592 } 593 } 594 595 /** Rust told that something changed: a key came, went, or was refused. */ 596 private static void told() { 597 try { 598 nativeHeld(); 599 } catch (UnsatisfiedLinkError e) { 600 // Sioul's library not loaded: no band waits. 601 } 602 } 603 604 /** Android's NFC settings, where it is switched on. */ 605 static void settings(Context context) { 606 Intent intent = new Intent(Settings.ACTION_NFC_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 607 try { 608 context.startActivity(intent); 609 } catch (ActivityNotFoundException e) { 610 try { 611 context.startActivity(new Intent(Settings.ACTION_WIRELESS_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); 612 } catch (ActivityNotFoundException none) { 613 // No settings to open: the band's words say what to do. 614 } 615 } 616 } 617 618 /** Rust told that a key came (android/main.cpp). */ 619 private static native void nativeHeld(); 620}