openapi: 3.0.3
info:
  title: storage-p API
  version: "1.0"
  description: |
    Zero-knowledge менеджер секретов. Всё содержимое записей шифруется в браузере
    до отправки — сервер хранит только шифротекст (`*_enc_b64` = base64 от nonce(24)‖ciphertext)
    и непрозрачные обёрнутые ключи. Расшифровать сервер ничего не может.

    ## Аутентификация
    - **Пользователь:** `Authorization: Bearer <access_token>` (короткоживущий JWT, 15 мин; обновляется refresh-токеном).
    - **Интеграция:** `Authorization: Bearer spkt_<...>` — scoped API-токен.

    ## Доступ интеграции — нужны ДВА секрета
    При создании scoped-токена с доступом к проекту выдаются **оба, по одному разу**:
    1. **token** (`spkt_...`) — для заголовка `Authorization`. Даёт доступ к API в рамках scope.
    2. **tokenKey** (base64) — для разворачивания Project Key и расшифровки/шифрования
       содержимого проекта НА СТОРОНЕ ДЕРЖАТЕЛЯ ТОКЕНА (zero-knowledge). Сервер его не хранит.

    Только token → держатель получит шифротекст, но не расшифрует. Только tokenKey → бесполезен.
    Чтение записи может требовать подтверждения владельцем (через приложение или Telegram).

    Типичный цикл интеграции:
    1. `GET /api/grants` → получить свои гранты; развернуть Project Key: `PK = unwrap(tokenKey, wrapped_key_b64)`.
    2. `GET /api/vault/{id}` → если требуется подтверждение, вернётся `confirmation_id` (опрашивать с `?confirmation_id=`);
       при `status=ok` — расшифровать: `CEK = unwrap(PK, cek_wrap_project_b64)`, затем meta/body под CEK.
    3. `POST /api/vault` → положить новую креду в проект (нужен write-грант): зашифровать под новый CEK, завернуть CEK под PK.

    ## Криптография — точный контракт (без него не расшифровать)
    Все `*_b64` — **стандартный base64** (RFC 4648, с паддингом). Все ключи — **32 байта**.

    **Симметричный примитив:** XChaCha20-Poly1305-IETF (libsodium `crypto_aead_xchacha20poly1305_ietf_*`).
    Формат КАЖДОГО зашифрованного блоба (контент и обёртки ключей):
    `blob = nonce(24 байта) ‖ ciphertext_with_tag`. При расшифровке: `nonce = blob[:24]`, `ct = blob[24:]`.

    - **Контент записи** (`meta_enc_b64`, `body_enc_b64`): ключ = CEK, **AAD = ASCII-строка** `"meta"` или `"body"` соответственно. Открытый текст — JSON.
    - **Обёртки ключей** (`cek_wrap_owner_b64`, `cek_wrap_project_b64`, `pk_wrap_owner_b64`, а также `wrapped_key_b64` гранта для **token**): тот же формат, **AAD пустой (нет)**. `unwrap(k, blob) = decrypt(k, blob[24:], nonce=blob[:24])`.
    - **Гранты пользователю** (`wrapped_key_b64` при `principal_type=user`, в т.ч. `/grants/incoming`): **sealed-box** — libsodium `crypto_box_seal` на X25519-pubkey получателя; открыть `crypto_box_seal_open(pubkey, privkey)`. (Гранты токену — симметричная обёртка под `tokenKey`.)
    - **vault-ключ владельца** (интеграциям НЕ нужен): Argon2id (`crypto_pwhash`, `ALG_ARGON2ID13`) от master_password с `kdf_salt`/`kdf_memory_kib`/`kdf_iters`/`kdf_parallelism` → 32 байта.

    **Чтение интеграцией (по project-гранту):**
    ```
    PK  = unwrap(tokenKey, b64d(grant.wrapped_key_b64))        # симметрично, без AAD
    CEK = unwrap(PK,       b64d(item.cek_wrap_project_b64))    # симметрично, без AAD
    meta = JSON(decrypt(CEK, b64d(item.meta_enc_b64)[24:], nonce=...[:24], aad="meta"))
    body = JSON(decrypt(CEK, b64d(item.body_enc_b64)[24:], nonce=...[:24], aad="body"))
    ```
    **Запись интеграцией:** сгенерировать случайный CEK(32) → `meta_enc=enc(CEK,json(meta),aad="meta")`,
    `body_enc=enc(CEK,json(body),aad="body")`, `cek_wrap_project=wrap(PK,CEK)` → `POST /api/vault`
    с `{ meta_enc_b64, body_enc_b64, project_id, cek_wrap_project_b64 }`.

    Совместимая реализация: любая библиотека с XChaCha20-Poly1305-IETF, X25519 sealed-box и Argon2id
    (libsodium / PyNaCl / tweetnacl / и т.п.). Меньше всего возни — libsodium.
servers:
  - url: https://storage-p.com/api/v1
components:
  securitySchemes:
    userJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Пользовательский access-token (JWT).
    scopedToken:
      type: http
      scheme: bearer
      description: Scoped API-токен (spkt_...).
  schemas:
    VaultItem:
      type: object
      description: Запись хранилища. Контент зашифрован клиентом.
      properties:
        id: { type: string }
        meta_enc_b64: { type: string, description: "nonce(24)‖ct заголовка (название/тип/папка)" }
        body_enc_b64: { type: string, description: "nonce(24)‖ct тела (секрет/логин/поля)" }
        blind_index_b64: { type: string, nullable: true }
        version: { type: integer }
        created_at: { type: integer }
        updated_at: { type: integer }
        enc_scheme: { type: integer, description: "0 = под vault-ключом (legacy), 1 = envelope (CEK)" }
        project_id: { type: string, nullable: true }
        cek_wrap_owner_b64: { type: string, nullable: true, description: "CEK, завёрнутый под vault-ключ владельца" }
        cek_wrap_project_b64: { type: string, nullable: true, description: "CEK, завёрнутый под Project Key" }
    Grant:
      type: object
      properties:
        id: { type: string }
        scope: { type: string, enum: [project, item] }
        target_id: { type: string }
        principal_type: { type: string, enum: [user, token] }
        principal_ref: { type: string }
        actions: { type: array, items: { type: string, enum: [read, write] } }
        require_confirmation: { type: boolean }
        created_at: { type: integer }
        expires_at: { type: integer, nullable: true }
    Error:
      type: object
      properties:
        error: { type: string }
        message: { type: string }
security:
  - userJWT: []
paths:
  /health:
    get:
      summary: Проверка живости
      security: []
      responses: { "200": { description: ok } }

  /auth/register:
    post:
      summary: Регистрация
      security: []
      description: Клиент сам выбирает параметры KDF и загружает уже зашифрованный приватный ключ.
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: {
          email: {type: string}, master_password: {type: string},
          kdf_salt_b64: {type: string}, kdf_memory_kib: {type: integer}, kdf_iters: {type: integer}, kdf_parallelism: {type: integer},
          pubkey_b64: {type: string}, privkey_enc_b64: {type: string} } } } }
      responses: { "200": { description: "{ access_token, refresh_token, user_id, kdf_* }" } }
  /auth/login:
    post:
      summary: Вход
      security: []
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: {
          email: {type: string}, master_password: {type: string}, totp_code: {type: string, nullable: true} } } } }
      responses: { "200": { description: "{ access_token?, refresh_token?, user_id?, kdf_*, totp_required }" } }
  /auth/refresh:
    post:
      summary: Обновить access-токен
      security: []
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { refresh_token: {type: string} } } } } }
      responses: { "200": { description: "{ access_token, refresh_token }" } }
  /auth/logout:
    post:
      summary: Выйти (отозвать refresh)
      responses: { "200": { description: "{ ok }" } }
  /auth/totp/setup:
    post:
      summary: Включить TOTP 2FA
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { secret_b32: {type: string}, current_code: {type: string} } } } } }
      responses: { "200": { description: "{ ok }" } }
  /me:
    get:
      summary: Профиль
      responses: { "200": { description: "{ user_id, email, totp_enabled, pubkey_b64, privkey_enc_b64 }" } }

  /vault:
    get:
      summary: Список записей
      responses: { "200": { description: список, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/VaultItem" } } } } } }
    post:
      summary: Создать запись
      description: meta/body уже зашифрованы клиентом. Для записи в проект — project_id + cek_wrap_project_b64.
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: {
        meta_enc_b64: {type: string}, body_enc_b64: {type: string}, blind_index_b64: {type: string, nullable: true},
        enc_scheme: {type: integer}, project_id: {type: string, nullable: true},
        cek_wrap_owner_b64: {type: string, nullable: true}, cek_wrap_project_b64: {type: string, nullable: true} } } } } }
      responses: { "200": { description: запись, content: { application/json: { schema: { $ref: "#/components/schemas/VaultItem" } } } } }
  /vault/trash:
    get: { summary: Корзина, responses: { "200": { description: список удалённых } } }
  /vault/{id}:
    get: { summary: Получить запись, parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: запись } } }
    put:
      summary: Обновить запись (оптимистичная блокировка по версии)
      parameters: [{name: id, in: path, required: true, schema: {type: string}}]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: {
        meta_enc_b64: {type: string, nullable: true}, body_enc_b64: {type: string, nullable: true},
        expected_version: {type: integer}, enc_scheme: {type: integer, nullable: true}, project_id: {type: string, nullable: true},
        cek_wrap_owner_b64: {type: string, nullable: true}, cek_wrap_project_b64: {type: string, nullable: true} } } } } }
      responses: { "200": { description: запись }, "409": { description: version mismatch } }
    delete: { summary: "Удалить в корзину (soft-delete)", parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: "{ ok }" } } }
  /vault/{id}/restore:
    post: { summary: Восстановить из корзины, parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: "{ ok }" } } }
  /vault/{id}/purge:
    delete: { summary: Удалить навсегда, parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: "{ ok }" } } }

  /projects:
    get: { summary: "Проекты (папки с ключом)", responses: { "200": { description: "[ { id, name_enc_b64, pk_wrap_owner_b64, created_at, updated_at } ]" } } }
    post:
      summary: Создать проект
      description: Project Key генерится на клиенте и заворачивается под vault-ключ владельца.
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: {
        id: {type: string, nullable: true}, name_enc_b64: {type: string, nullable: true}, pk_wrap_owner_b64: {type: string} } } } } }
      responses: { "200": { description: проект } }
  /projects/{id}:
    delete: { summary: Удалить проект (отзывает его гранты), parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: "{ ok }" } } }

  /grants:
    get: { summary: Выданные мной гранты, responses: { "200": { description: список, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Grant" } } } } } } }
    post:
      summary: Выдать доступ
      description: |
        wrapped_key_b64 — PK (scope=project) или CEK (scope=item), завёрнутый ПОД принципала:
        для user — sealed-box на его pubkey, для token — wrap(tokenKey, ...).
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: {
        scope: {type: string, enum: [project, item]}, target_id: {type: string},
        principal_type: {type: string, enum: [user, token]}, principal_ref: {type: string},
        actions: {type: array, items: {type: string, enum: [read, write]}},
        wrapped_key_b64: {type: string}, require_confirmation: {type: boolean}, expires_at: {type: integer, nullable: true} } } } } }
      responses: { "200": { description: грант, content: { application/json: { schema: { $ref: "#/components/schemas/Grant" } } } } }
  /grants/incoming:
    get:
      summary: Гранты, выданные МНЕ (пользователю)
      description: Возвращает wrapped_key_b64 (sealed-box на мой pubkey) — развернуть приватным ключом.
      responses: { "200": { description: "[ { id, scope, target_id, actions, require_confirmation, wrapped_key_b64, expires_at } ]" } }
  /grants/{id}:
    delete: { summary: Отозвать грант, parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: "{ ok }" } } }
  /shared/items:
    get:
      summary: Записи, к которым у меня есть грант
      description: Шифротекст записей выданных проектов и отдельных записей. PK/CEK берутся из /grants/incoming.
      responses: { "200": { description: список, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/VaultItem" } } } } } }

  /share:
    post:
      summary: Создать одноразовую ссылку
      description: Ключ дешифровки в URL-фрагменте, на сервер не уходит. Burn-after-read.
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: {
        payload_b64: {type: string}, nonce_b64: {type: string}, ttl_secs: {type: integer}, max_views: {type: integer} } } } } }
      responses: { "200": { description: "{ id, expires_at }" } }
  /share/{id}:
    get: { summary: "Открыть ссылку (public, burn-after-read)", security: [], parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: "{ payload_b64, nonce_b64 }" } } }
  /users/lookup:
    get:
      summary: Найти pubkey пользователя по email
      parameters: [{name: email, in: query, required: true, schema: {type: string}}]
      responses: { "200": { description: "{ user_id, pubkey_b64 }" } }
  /shares/user:
    post:
      summary: Поделиться записью с пользователем (sealed-box)
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { to_email: {type: string}, payload_b64: {type: string} } } } } }
      responses: { "200": { description: "{ id, ok }" } }
  /shares/incoming:
    get: { summary: Входящие шары, responses: { "200": { description: список } } }
  /shares/{id}:
    delete: { summary: Удалить шару, parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: "{ ok }" } } }

  /tokens:
    get: { summary: Список API-токенов, responses: { "200": { description: список } } }
    post:
      summary: Создать scoped-токен
      description: Возвращает token (один раз). Для доступа к проектам грант выдаётся отдельно через /grants.
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: {
        name: {type: string}, item_ids: {type: array, items: {type: string}}, require_confirmation: {type: boolean},
        rate_limit_per_hour: {type: integer}, ttl_secs: {type: integer} } } } } }
      responses: { "200": { description: "{ id, token, expires_at }" } }
  /tokens/{id}:
    delete: { summary: Отозвать токен, parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: { "200": { description: "{ ok }" } } }
  /confirmations/pending:
    get: { summary: Запросы, ждущие подтверждения, responses: { "200": { description: список } } }
  /confirmations/{id}/resolve:
    post:
      summary: Подтвердить/отклонить запрос
      parameters: [{name: id, in: path, required: true, schema: {type: string}}]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { decision: {type: string, enum: [approve, deny]} } } } } }
      responses: { "200": { description: "{ ok, status }" } }

  /api/grants:
    get:
      summary: "[ТОКЕН] Гранты этого токена"
      security: [{ scopedToken: [] }]
      description: wrapped_key_b64 = PK/CEK, завёрнутый под tokenKey. Развернуть им и расшифровать содержимое.
      responses: { "200": { description: "[ { id, scope, target_id, actions, require_confirmation, wrapped_key_b64, expires_at } ]" } }
  /api/vault/{id}:
    get:
      summary: "[ТОКЕН] Прочитать запись по гранту/scope"
      security: [{ scopedToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: confirmation_id, in: query, required: false, schema: { type: string }, description: "для опроса статуса подтверждения" }
      responses:
        "200": { description: "{ status: ok, item } ИЛИ { status: pending_confirmation, confirmation_id, poll_after_secs, expires_at }" }
        "403": { description: нет доступа / отклонено }
  /api/vault:
    get:
      summary: "[ТОКЕН] Список доступных записей (шифротекст)"
      security: [{ scopedToken: [] }]
      description: |
        Записи, которые токен может читать (по project/item read-грантам и legacy item_ids).
        Шифротекст; расшифровка — через PK/CEK на стороне держателя. Чтобы id не нужно было знать заранее.
      responses: { "200": { description: список, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/VaultItem" } } } } } }
    post:
      summary: "[ТОКЕН] Записать новую креду в проект"
      security: [{ scopedToken: [] }]
      description: Нужен write-грант на проект. Контент шифруется держателем токена под новый CEK, CEK заворачивается под Project Key.
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: {
        meta_enc_b64: {type: string}, body_enc_b64: {type: string}, blind_index_b64: {type: string, nullable: true},
        project_id: {type: string}, cek_wrap_project_b64: {type: string} } } } } }
      responses: { "200": { description: запись }, "403": { description: нет write-гранта } }
