fix(node): clear consumed invitations after confirmed pairing

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 21:37:56 +03:00
parent e82d012907
commit 3616acc648
6 changed files with 41 additions and 3 deletions
+9
View File
@@ -81,6 +81,13 @@ func OpenPairing(store *Store, dir, version string, inventory func() Inventory)
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation == nil { if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation == nil {
return nil, errors.New("incomplete invitation") return nil, errors.New("incomplete invitation")
} }
if (p.state.Phase == "paired" || p.state.Phase == "revoked") && p.state.Invitation != nil {
next := p.state
next.Invitation = nil
if e := p.save(next); e != nil {
return nil, e
}
}
return p, nil return p, nil
} }
func savePrivateJSON(path string, value any) error { func savePrivateJSON(path string, value any) error {
@@ -186,6 +193,8 @@ func (p *Pairing) invite(address string) (map[string]any, error) {
if e := p.save(PairState{Schema: PairSchema, Phase: "inviting", Invitation: i, Revocations: p.state.Revocations}); e != nil { if e := p.save(PairState{Schema: PairSchema, Phase: "inviting", Invitation: i, Revocations: p.state.Revocations}); e != nil {
return nil, e return nil, e
} }
p.connection = "offline"
p.lastSeen = 0
id, _ := p.store.Public() id, _ := p.store.Public()
code, _ := json.Marshal(map[string]any{"schema": PairSchema, "node_id": id, "id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt, "secret": secret}) code, _ := json.Marshal(map[string]any{"schema": PairSchema, "node_id": id, "id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt, "secret": secret})
return map[string]any{"code": "MCN1." + base64.RawURLEncoding.EncodeToString(code), "expires_at": i.ExpiresAt}, nil return map[string]any{"code": "MCN1." + base64.RawURLEncoding.EncodeToString(code), "expires_at": i.ExpiresAt}, nil
@@ -103,6 +103,9 @@ func TestPairingDurableCommitConflictAndReplay(t *testing.T) {
if _, e = restored.invite("192.168.10.4"); e == nil { if _, e = restored.invite("192.168.10.4"); e == nil {
t.Fatal("paired Node offered another invitation") t.Fatal("paired Node offered another invitation")
} }
if restored.state.Invitation != nil {
t.Fatal("consumed invitation retained")
}
raw, _ := json.Marshal(restored.status()) raw, _ := json.Marshal(restored.status())
if strings.Contains(string(raw), i["secret"].(string)) || strings.Contains(string(raw), "client_pem") { if strings.Contains(string(raw), i["secret"].(string)) || strings.Contains(string(raw), "client_pem") {
t.Fatal("status leaked trust") t.Fatal("status leaked trust")
@@ -138,6 +138,7 @@ func (p *Pairing) remoteHandler() http.Handler {
} }
next := p.state next := p.state
next.Phase = "paired" next.Phase = "paired"
next.Invitation = nil
if p.save(next) != nil { if p.save(next) != nil {
http.Error(w, "State unavailable", 503) http.Error(w, "State unavailable", 503)
return return
+1 -1
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.5.0" VERSION = "0.5.1"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af" BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
@@ -57,7 +57,7 @@ export function CoreConnectionView({ failure }: { failure: (error: unknown) => v
<Select label="Адрес БК для подключения" value={address} options={value.addresses.map(item => ({ value: item, label: item }))} onChange={setAddress} disabled={pending || !available} /> <Select label="Адрес БК для подключения" value={address} options={value.addresses.map(item => ({ value: item, label: item }))} onChange={setAddress} disabled={pending || !available} />
{value.addresses.length === 0 && <p className="node-note">Подключите БК к частной сети. Доступные адреса появятся автоматически.</p>} {value.addresses.length === 0 && <p className="node-note">Подключите БК к частной сети. Доступные адреса появятся автоматически.</p>}
<Button disabled={pending || !available || !address} onClick={() => void create()}>{pending ? "Создаём…" : value.phase === "inviting" ? "Создать новое приглашение" : "Создать приглашение"}</Button> <Button disabled={pending || !available || !address} onClick={() => void create()}>{pending ? "Создаём…" : value.phase === "inviting" ? "Создать новое приглашение" : "Создать приглашение"}</Button>
{value.invitation && <p className="node-note">Действует до {new Date(value.invitation.expires_at * 1000).toLocaleTimeString("ru-RU")}. Приглашение позволяет одному Core получить доверие этого БК. Передавайте его только нужному оператору.</p>} {value.phase === "inviting" && value.invitation && <p className="node-note">Действует до {new Date(value.invitation.expires_at * 1000).toLocaleTimeString("ru-RU")}. Приглашение позволяет одному Core получить доверие этого БК. Передавайте его только нужному оператору.</p>}
{code && <><TextAreaField label="Код приглашения" value={code} readOnly rows={5} spellCheck={false} onFocus={event => event.target.select()} /><Button onClick={async () => { try { await navigator.clipboard.writeText(code); setNotice("Код скопирован"); } catch { setNotice("Выделите код в поле и скопируйте его сочетанием Ctrl+C."); } }}>Скопировать код</Button></>} {code && <><TextAreaField label="Код приглашения" value={code} readOnly rows={5} spellCheck={false} onFocus={event => event.target.select()} /><Button onClick={async () => { try { await navigator.clipboard.writeText(code); setNotice("Код скопирован"); } catch { setNotice("Выделите код в поле и скопируйте его сочетанием Ctrl+C."); } }}>Скопировать код</Button></>}
{value.phase === "inviting" && !code && <p className="node-note">Код показывается только при создании. Создайте новое приглашение, если он не сохранился у вас.</p>} {value.phase === "inviting" && !code && <p className="node-note">Код показывается только при создании. Создайте новое приглашение, если он не сохранился у вас.</p>}
</div>} </div>}
+26 -1
View File
@@ -1,6 +1,6 @@
# Привязка БК к Mission Core — протокол v1 # Привязка БК к Mission Core — протокол v1
Дата: 05.09.2026. Инкремент Node 0.5.0. Основание: MISSIONCOR-76 и согласованная Дата: 05.09.2026. Инкремент Node 0.5.0; исправление отображения и очистки приглашения 0.5.1. Основание: MISSIONCOR-76 и согласованная
поверхность `03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md`. Чистая установка ОС поверхность `03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md`. Чистая установка ОС
отложена владельцем; она не закрыта текущими проверками и не блокирует этот этап. отложена владельцем; она не закрыта текущими проверками и не блокирует этот этап.
@@ -153,3 +153,28 @@ Bootstrap ограничен 16 одновременными соединени
автономные миссии. Следующий отдельный инкремент: реальный RealSense D455 за Node; автономные миссии. Следующий отдельный инкремент: реальный RealSense D455 за Node;
после него K1 через Wi-Fi Bridge. Их полномочия и API добавляются к существующим после него K1 через Wi-Fi Bridge. Их полномочия и API добавляются к существующим
границам Node/Core, а не к SSH или UI браузера. границам Node/Core, а не к SSH или UI браузера.
## Фактическая проверка 05.09.2026
На тестовом Mini с установленным 0.5.0 через UI созданы приглашение и аппарат,
подтверждены идентичность, исходящий mTLS и живые сведения БК. Через UI Core
проверены список, глаз конфигурации, отзыв доверия; через UI Node — получение
отзыва и создание нового приглашения. Повторная привязка вернула тот же аппарат
в сеть без второй строки. Управление датчиками при этом не запускалось.
Для проверки отказа служба Node была кратко остановлена инженерным SSH:
интерфейс Core показал «Нет связи»; после запуска служба сама восстановила пару
и UI показал «В сети». Это проверка сохранения пары после перезапуска службы,
не квалификация чистой ОС или полного холодного старта Mini.
GUI-прогон использовал реальные интерфейсы Core и Node (к последнему — временный
loopback-туннель и штатный одноразовый вход). Встроенное desktop-окно Node 0.5.0
также запущено на Mini. Туннель относится только к инженерному управлению UI;
сам канал Node → Core работал непосредственно через частную LAN с mTLS.
Выявленная при GUI-проверке неточность: после отзыва показывался срок уже
использованного приглашения. В 0.5.1 приглашение удаляется при commit и при
чтении старого paired/revoked-состояния; его срок UI показывает только пока оно
открыто. Новое приглашение сбрасывает время связи предыдущей пары. Исправление
внесено в пакет и регрессионный тест, а не в настройки тестовой ОС.