Так уж вышло, что у меня есть внутренняя софтина, утилизирующая touch policy в YubiKey при аутентификации в SSH и около. Это когда для подтверждения действия (подписи в моём случае) пользователь должен потискать юбикей за контактную площадку (не зря же он называется именно Yubi Key). А любой софтине нужны интеграционные тесты, и чем эти тесты ближе к реальности — тем лучше. Ничего не поделаешь, придётся учиться “тискать” YubiKey программно, о чём и будет этот постик.

Каков план?

Итак, нам понадобится:

  • сами юбикеи, у меня это крошки YubiKey 5 Nano с capacitive touch сенсором;
  • какой-то способ их “трогать” с хоста;
  • USB-хаб, в который будем вставлять наши юбики. По моим прикидкам нагрузки, 2–3 штуки должно быть в самый раз;
  • всякая мелочёвка, в духе “шапок” для юбикеев.

Вроде ничего не забыл. Погнали экспериментировать!

Про колхоз прототип

Странно было бы думать, что я первый, кому понадобилось симулировать прикосновения к юбику. Поэтому в интернетах можно найти множество решений этой задачки. Например:

После серии экспериментов с конденсаторами, оптопарой и NPN-транзистором я остановился на последнем. С конденсатором (как в посте выше) я вообще не смог добиться стабильной работы, оптопара работала неплохо, а транзистор завёлся совсем отлично — плюс крохотный :) Единственное, важно учесть, что у самого транзистора есть выходная (переходная?) ёмкость, поэтому критично подобрать правильный транзистор — YubiKey очень чувствителен.

Для сборки прототипа пришлось отправиться в ближайший магазин радиодеталей (в моём столе подходящего транзистора не нашлось), из которого выплыл с парочкой BRF90A (Ccb и Cbe около 0.3pF) — точно должен подойти.

Осталось набросать небольшую схемку подключения на базе RP2040-One от Waveshare (выбор MCU тут не принципиален — что нашлось в столе, то и пошло в дело):

И подсобрать:

Волнительный момент истины с попыткой стриггерить OTP у юбикея:

Ну не красота ли? Красота! А значит — самое время начинать собирать целевое решение.

Про USB-хаб

Хаб я долго не искал, у меня завалялся четырёхпортовый MH4PU-P от ORICO на базе VL817:

Сам хаб я не могу рекомендовать — он просто лежал у меня в столе без дела. ORICO, как и большая часть производителей подобных хабов, сэкономили целый доллар (или меньше?) и не распаяли управление питанием портов (что сам VL817 вполне себе умеет). Ну да не суть — зато у него отличная выемка для крепления, куда отлично встанет наш аддон!

Про аддон

Разумеется, без изменений в изначальный прототип не обошлось. Я решил:

  • заменить RP2040-One на RP2040-Zero от тех же Waveshare;
  • заменить BFR90A на BRF182 — последний меньше и SMD, а характеристики плюс-минус одинаковые для нашей задачки;
  • увеличить количество триггеров до 4 (иначе зачем всё это);
  • добавить каждому триггеру свою индикацию;
  • ну и влепить дисплей (WeAct 0.96 Inch OLED IIC) — всё становится лучше на 20% с дисплеем!

С изменениями определились, можно рисовать схемку аддона: И разводить платку:

Кому интересно поштырить поближе — вот копия в онлайн редакторе.

Дальше — заказ на JLCPCB, неделька на производство + доставку, и вот он, будущий аддон:

Осталось чуть запаять и подсобрать:

Момент истины:

Прекрасная вторая жизнь для хаба, я считаю.

О фирмваре

Железка — это хорошо. Но что такое железка без души? То-то же… Душу для H4ptiX я решил написать на базе Zephyr Project. Не потому что он тут сильно выделяется — просто мне он больше нравится, чем Raspberry Pi Pico SDK ;)

Сама firmware вышла незамысловатой и предоставляет:

  • HID-интерфейс для общения с хостом, используя protobuf в качестве формата сообщений (см. rpc.proto);
  • возможность кастомизировать продолжительность прикосновения и его задержку;
  • индикацию состояния как встроенным RGB на RP2040 Zero, так и дисплеем.

Вместо HID можно было бы использовать CDC. Но:

  • я захотел HID, и что вы мне сделаете? Я в другом городе;
  • HID выглядит удобнее для небольших бинарных сообщений;
  • HID мне всё равно нужен для общения с YubiKey (дискавери, софт-ребут).

Firmware для H4ptiX можно собрать самому или скачать из релиза. Есть три варианта сборки:

  • h4ptix_rp2040_lonely.u2f — для одного юбика (прототип);
  • h4ptix_rp2040_basic.u2f — базовый аддон на 4 порта;
  • h4ptix_rp2040_display.u2f — основной аддон с дисплеем.

О софте

Софтовая часть тоже незамысловатая и состоит из двух частей.

Библиотечка на Golang для общения с H4ptix по HID, минимальный пример:

h, err := h4ptix.NewH4ptix()
if err != nil {
	log.Fatalf("create haptix: %v\n", err)
}

usbPort := 1
err = h.Trigger(h4ptix.TriggerReq{
	Port: usbPort,
})
if err != nil {
	log.Fatalf("ooops, ship happens: %v\n", err)
}

И демонёнок yubictld (тоже на Golang офк), который умеет:

  • дискаверить юбики, вставленные в тот же хаб, что и H4ptiX (пока только Linux, но будет и остальное);
  • выбирать случайный свободный юбик и лочить его за клиентом, дабы запуски разных тестов не конкурировали между собой;
  • софтово (аппаратно хаб не умеет) ребутать YubiKey с помощью FIDO2 интерфейса;
  • и, конечно, “тискает” юбик по команде.

Как пример, простенький test suite с использованием yubictld, генерацией ключа в PIV с touch policy Always (см. PIV PIN, touch and bio policies):

package test

import (
	"context"
	"crypto"
	"crypto/rand"
	"crypto/sha256"
	"strings"
	"testing"
	"time"

	"github.com/go-piv/piv-go/v2/piv"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"

	"github.com/buglloc/yubictld/pkg/yubictl"
)

type ExampleTestSuite struct {
	suite.Suite
	yc *yubictl.Yubikey
	yk *piv.YubiKey
}

func (s *ExampleTestSuite) setupYubictl() {
	svc := yubictl.NewSvcClient("http://localhost:3000")
	yk, err := svc.Acquire(context.Background())
	s.Require().NoError(err)

	s.T().Logf("acquired yubikey: %d", yk.Serial())
	s.yc = yk
}

func (s *ExampleTestSuite) setupYubikey() {
	cards, err := piv.Cards()
	require.NoError(s.T(), err)
	var yk *piv.YubiKey

	for _, card := range cards {
		if !strings.Contains(strings.ToLower(card), "yubikey") {
			continue
		}

		if yk, err = piv.Open(card); err != nil {
			s.T().Logf("unable to open yubikey %s: %v", card, err)
			continue
		}

		serial, err := yk.Serial()
		s.Require().NoError(err)

		if serial != s.yc.Serial() {
			_ = yk.Close()
			s.T().Logf("skip yubikey: %d", serial)
			continue
		}

		s.T().Logf("open yubikey: %d", serial)
		s.yk = yk
		break
	}
}

func (s *ExampleTestSuite) SetupSuite() {
	s.setupYubictl()
	s.setupYubikey()
}

func (s *ExampleTestSuite) TearDownSuite() {
	s.T().Logf("release yubikey: %d", s.yc.Serial())
	err := s.yc.Release(context.Background())
	s.Assert().NoError(err)

	s.T().Logf("close yubikey")
	err = s.yk.Close()
	s.Assert().NoError(err)
}

func (s *ExampleTestSuite) SetupTest() {
	err := s.yk.Reset()
	s.Require().NoError(err)
}

func (s *ExampleTestSuite) TestExample() {
	key := piv.Key{
		Algorithm:   piv.AlgorithmEC256,
		PINPolicy:   piv.PINPolicyAlways,
		TouchPolicy: piv.TouchPolicyAlways,
	}

	pub, err := s.yk.GenerateKey(piv.DefaultManagementKey, piv.SlotAuthentication, key)
	s.Require().NoError(err)

	auth := piv.KeyAuth{PIN: piv.DefaultPIN}
	priv, err := s.yk.PrivateKey(piv.SlotAuthentication, pub, auth)
	s.Require().NoError(err)

	signer, ok := priv.(crypto.Signer)
	s.Require().True(ok)

	data := sha256.Sum256([]byte("foo"))

	for {
		s.Run("should_ok", func() {
			// шедулим тисканье через 1 секунду
			err := s.yc.Touch(context.Background(), yubictl.TouchWithDelay(time.Second))
			s.Require().NoError(err)

			// просим юбик подписать на ключе, который требует ласки
			signature, err := signer.Sign(rand.Reader, data[:], crypto.SHA256)

			// проверяем, что все пошло хорошо
			s.Require().NoError(err)
			s.Require().NotEmpty(signature)
			time.Sleep(time.Second)
		})
	}
}

func TestExampleTestSuite(t *testing.T) {
	suite.Run(t, new(ExampleTestSuite))
}

В действии:

На этом всё, ура :)

Заключение

Не знаю, как вам, а мне кажется, что получилось довольно просто, но не дурно. Сейчас yubictld + H4ptiX в тестовом режиме работают вместе уже около недели — планирую ещё погонять, собрать валенки и встраивать в CI/CD процесс выкатки.

Конечно, очень не хватает управления питанием USB-портов хаба (особенно если гонять тесты для FIDO2), но об этом — в следующих сериях. Не переключайтесь ;)

На всякий случай уточню, что использовать подобную схему не для тестового окружения идея, прямо скажем, не очень, потому что вы этим сводите всю суть проверки присутствие пользователя на нет.

А пока у меня всё. Всем кота! (づ˶•༝•˶)づ♡