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}