Metadata-Version: 2.4
Name: s-leasekit
Version: 0.1.0
Summary: Единый верификатор лиза способности (JWS/EdDSA, монотонный пол времени, fail-closed) — один и тот же код на CLI и gateway.
Author: Dmitry
License: MIT
License-File: LICENSE
Keywords: capability,ed25519,eddsa,jws,lease,offline-verification
Requires-Python: >=3.11
Requires-Dist: cryptography>=41.0.0
Provides-Extra: dev
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# s-leasekit

Единый верификатор **лиза способности** — один и тот же код проверки на обоих
потребителях: `skillery-cli` (запуск навыка, демон) и gateway (`load_capability`).

**Версия:** 0.1.0 · **Лицензия:** MIT · **Зависимости:** `cryptography` (Ed25519) — и всё.

Реализует контракт лиза способности (внутренний документ
`capability-lease-contract.md`, 2026-08-06): **TTL 24 ч**, **`grace = 0`**, гейт включается
**точечно** (`requires_lease`), `NO_GRANT`/`GRANT_REVOKED` наружу не различаются.

## Зачем отдельный пакет

`skillkit` — библиотека CLI, `adapterkit` — библиотека gateway; общей у них нет. Две
независимые реализации проверки подписи и срока разъедутся — вопрос не «если», а
«когда». И разъедутся они **в сторону «пускаем»**: лишний отказ замечают в тот же
день, лишний пропуск не замечает никто. Поэтому реализация одна, и она здесь.

## Быстрый старт

```python
from pathlib import Path
from leasekit import lease_gate, LeaseDenied

try:
    lease = lease_gate(
        "grok_transcriber",                    # имя = ключ entry-point skillery.plugins
        store=Path.home() / ".skillery",       # каталог состояния
        subject=current_user_subject,          # "user:1042" — текущая учётка
        device_id=client_device_id,            # справочно: расхождение = WARN
        hub_offset=hub_time_offset,            # из заголовка Date последнего ответа хаба
    )
except LeaseDenied as denied:
    log.warning("lease denied: %s", denied.verdict.value)
    raise SystemExit(denied.message)           # текст уже содержит следующее действие

# сюда попадаем ТОЛЬКО с проверенным лизом
run_capability(lease.claims.cap)
```

Где ставить гейт (§9.2 контракта):

* **CLI** — `commands/run.py::cmd_run`, **перед** `_spawn(argv)`;
* **gateway** — `gateway/plugins.py::load_capability`, **перед** `ep.load()`.

Перечисление способностей гейтом не закрывается: список того, что установлено на
машине, секретом не является.

## Почему проверку нельзя обойти по невнимательности

Форма публичного API — не вкусовщина, а способ убрать целые классы ошибок:

| Решение | Что оно предотвращает |
|---|---|
| `verify_lease` / `lease_gate` **поднимают `LeaseDenied`**, а не возвращают вердикт | проигнорированный return-код молча пускает; проигнорированное исключение останавливает процесс |
| «Проверить» и «получить claims» — **одно действие**: `LeaseClaims` достаётся только из `VerifiedLease` | нельзя разобрать payload, «забыв» проверить его; функции «просто распарсить» в публичном API нет |
| `subject` — **обязательный именованный аргумент** без значения по умолчанию | единственную привязку, по которой отказывают (§2.4), нельзя пропустить |
| Время берётся из **`LeaseClock`**, а не из `now: int` | применение монотонного пола — обязанность кита, а не вызывающего |
| `DEVICE_MISMATCH` — **не вердикт, а `LeaseWarning`** | естественный код `if verdict is not VALID: deny` не начнёт отказывать там, где спека велит пропускать |
| Диагностический путь называется **`inspect_lease`** и возвращает другой тип (`LeaseCheck`) | «взять не ту функцию» не выглядит как рабочий код: `LeaseCheck` возвращается и при отказе |
| Любая неоднозначность → отказ (`MALFORMED`) | битый JSON, обрезанный файл, чужой алгоритм, отсутствующий claim не превращаются в молчаливый пропуск |

## Монотонный пол времени

Лиз проверяется по локальным часам — других на офлайн-машине нет. Три правила §5,
и без всех трёх контур дырявый:

1. **допуск ±300 с** на `nbf`/`exp` — обычный перекос часов не даёт отказа на границе;
2. **смещение от часов хаба** — `offset = hub_now − local_now` из заголовка `Date`
   последнего успешного ответа; спасает машины с систематически сбитыми часами
   (VM без NTP, севшая CMOS) без ослабления допуска;
3. **монотонный пол** `hub_time_floor` — максимальное время, когда-либо
   подтверждённое хабом.

```python
effective_now = max(local_now + offset, hub_time_floor)   # LeaseClock.effective_now
```

Пол только растёт (`LeaseStore.raise_floor` никогда не уменьшает) и живёт в
`leases.json` рядом с лизами. Без него весь контур снимается одной командой
«перевести часы назад»: истёкший лиз оживает, и срок доступа перестаёт что-либо
значить.

Две ситуации разделены намеренно, хотя обе — отказ:

* часы **ниже пола** сверх допуска → `CLOCK_SUSPECT` («похоже, часы идут неверно»);
* лиз истёк, а часы **правдоподобны** → `EXPIRED` («срок истёк, подключитесь к сети»).

Отказ с неверной причиной отправляет пользователя искать несуществующий отзыв доступа.

## Вердикты

| Вердикт | Поведение |
|---|---|
| `VALID` | пропустить, в сеть не ходить |
| `MISSING` | лиза нет в хранилище |
| `EXPIRED` | `exp` (+`grace`) позади |
| `NOT_YET_VALID` | `nbf` впереди сверх допуска |
| `SUBJECT_MISMATCH` | лиз выдан другому пользователю |
| `CAPABILITY_MISMATCH` | лиз выписан на другую способность |
| `ISSUER_MISMATCH` | другой хаб (проверяется, только если `issuer` задан) |
| `SIGNATURE_INVALID` | подпись не сходится или `alg` ≠ `EdDSA` (включая `none`) |
| `UNKNOWN_KID` | ключ неизвестен; с `refresh_jwks` — ровно один повтор, иначе отказ |
| `UNSUPPORTED_VER` | `ver` лиза новее клиента |
| `CLOCK_SUSPECT` | часы ниже пола или неправдоподобно впереди |
| `MALFORMED` | битый токен/JSON/нет обязательного claim |

Предупреждения (`LeaseWarning`, не влияют на решение): `DEVICE_MISMATCH`, `NO_TIME_FLOOR`.

## Что кит НЕ делает — и почему

* **Не ходит в сеть.** Ни за лизом, ни за JWKS. У CLI и gateway разные транспорты,
  таймауты и политика ретраев; тащить сюда сетевой слой значило бы тащить и его
  зависимости обоим потребителям.
* **Не выдаёт лизы.** Единственная точка решения «право действует» — хаб
  (`is_grant_effective`). Модуль `leasekit.testing` подписывает лизы **только для
  тестов** — вынесен в отдельный модуль, чтобы не попадаться в подсказках IDE в проде.
* **Не решает, когда удалять лиз.** Различение «хаб ответил нет» (удалить немедленно)
  и «хаб недоступен» (жить по имеющемуся лизу до `exp`) требует знания об ответе сети —
  это знает потребитель. Кит лишь выполняет `drop()` корректно и атомарно.
* **Не хранит гранты и не знает про биллинг.** Лиз — булево «можно», а не счётчик и не
  квота: счётчик требует онлайна на каждый вызов, то есть ровно того, от чего отказались.
* **Не зависит от `skillkit`/`adapterkit`.** Он должен ставиться обоим потребителям
  независимо; общая зависимость снизу — ровно то, ради чего кит существует.
* **Не защищает от владельца машины.** Файлы способности лежат на диске, интерпретатор
  открыт, проверку можно вырезать из своей копии клиента. Лиз делает честного клиента
  честным и ставит праву конечный срок — не более.
* **Не гейтит перечисление** установленного и **не отзывается точечно** (CRL нет, §6.2).

## Расхождения со спекой

Спека писалась до кода; ниже — где реализация от неё отходит и почему.

1. **Сигнатуры публичных функций.** Спека §3.1 обещает
   `verify_lease(...) -> LeaseVerdict` и `lease_gate(...) -> None`. Реализовано:
   обе поднимают `LeaseDenied` и возвращают `VerifiedLease`. Возврат вердикта-энума —
   это return-код, который можно не проверить; в контуре, где «лишний пропуск никто не
   заметит», такой API работает против собственной цели. Диагностический возврат
   вердикта сохранён под другим именем — `inspect_lease() -> LeaseCheck`.
2. **`now: int` → `clock: LeaseClock`.** Голое `now` перекладывает применение
   монотонного пола на вызывающего, то есть на два места, которые разъедутся.
3. **`DEVICE_MISMATCH` — не вердикт, а предупреждение** (обоснование выше).
4. **Добавлены вердикты** `MALFORMED`, `CAPABILITY_MISMATCH`, `NOT_YET_VALID`,
   `ISSUER_MISMATCH`. Спека §9.2 их не перечисляет, но требует fail-closed на любую
   нераспознанную ситуацию; отдельные коды нужны, чтобы отказ объяснял причину.
5. **`lease_gate(subject=...)` обязателен.** В спеке подпись гейта субъекта не
   содержит — но `SUBJECT_MISMATCH` без субъекта непроверяем, а «взять текущую учётку
   самому» кит не может: он не знает, где она лежит у каждого потребителя.
6. **Хранилище (`LeaseStore`) вошло в кит,** хотя §3.1 упоминает только две функции.
   Формат `leases.json` и дисциплина «пол только растёт» — ровно то, что разъедется
   в двух копиях. Политика удаления при этом осталась у потребителей.
7. **`refresh_jwks` как callback.** Спека требует «при живой сети обновить JWKS и
   повторить проверку ОДИН раз». Сеть — не дело кита, но правило «ровно один повтор»
   держится здесь, а не в двух копиях.

## Тесты

```bash
uv sync --extra dev
uv run pytest -q
```

Ключевые случаи — не формальность, а то, ради чего контур существует:
перевод часов назад не оживляет истёкший лиз; расхождение `client_device_id` даёт
предупреждение, а не отказ; подделанная подпись, `alg: none` и неизвестный `kid`
отвергаются.
