openapi: 3.1.0

# ---------------------------------------------------------------------------
# Tricryption Key Service (kS) Gateway — REST API
#
# Hand-authored source-of-truth contract for the 7 endpoints exposed by the
# demo gateway (a plain Node http server that fronts the Tricryption kS). This
# is the REST front door used to integrate agentic pipelines, as opposed to the
# native C/C++ SDK used by classic enterprise applications.
#
# This file is BOTH the render source for the API Reference tier AND an
# independently useful artifact: feed it to your own OpenAPI tooling. It is
# served verbatim at /docs/api/openapi.yaml.
#
# Accuracy note: shapes and status codes match the verified gateway contract.
# The `algo`/`keyLen` fields on /keys/export describe the *delivered* data key
# (the key being unwrapped), not the wrap envelope — the envelope itself is an
# ECDH-P256 → HKDF-SHA256 → AES-GCM wrap (gatewayPubKey/salt/iv/ciphertext/
# authTag). See the "Integrating via the API" guide for the security model.
# ---------------------------------------------------------------------------

info:
  title: Tricryption Key Service Gateway REST API
  version: '8.1'
  summary: REST front door for the Tricryption Key Service, for agentic integrations.
  description: |
    The gateway exposes the Tricryption Key Service (kS) over a small REST
    surface so that agentic pipelines can generate, export, grant, and revoke keys
    without linking the native C/C++ SDK.

    ### Authentication

    Authentication is a **token-broker** model, not bearer auth:

    - `POST /login` performs a real SRP login as the user against the kS, issues
      a per-user Token-Encryption (TE) token, captures the principal identity,
      then closes the login connection and discards the password.
    - The TE token is held **server-side** in an in-memory session store keyed
      by an opaque random session id. The client receives that session id as
      `sessionId` and echoes it back in the `X-Session` header on every
      authenticated request. **The TE token itself never leaves the gateway.**
    - On each authenticated request the gateway opens a fresh validated-TLS
      connection, authenticates with the stored token, runs the operation, and
      closes the connection. No connection is held at rest — only the token.

    ### Token expiry is a contract, not a TTL

    Tokens **may** expire. This spec does not assert a concrete TTL: the demo kS
    may never expire tokens, and a production deployment may set its own policy.
    The contract you integrate against is: **on `401` with body
    `{ "reauth": true }`, re-run `POST /login` and retry the request.** A `401`
    *without* `reauth` is an ordinary missing/unknown-session error.

    ### Error contract (integration-critical)

    The gateway deliberately distinguishes session-expiry from access-denial:

    - `401 { reauth: true }` — the server-held token expired/invalid →
      re-login and retry.
    - `403` — a real kS RBAC/ACL authorization denial → surface it; retrying
      will not help until access is granted.
    - `500` — some other kS operation error.

    Treating these the same is the most common integration bug; handle them
    distinctly.
  contact:
    name: Pi Soft — Tricryption Engine Documentation
  license:
    name: Proprietary — Pi Soft

servers:
  - url: /api
    description: >-
      Same-origin reverse proxy used by the demo front-end: it strips the
      `/api` prefix and forwards to the gateway. Calls in the browser demo use
      this base.
  - url: http://localhost:8090
    description: Gateway HTTP listen address (direct, no proxy).

tags:
  - name: Session
    description: Sign in / sign out. `/login` issues the server-held token.
  - name: Keys
    description: Generate, export (wrapped), grant, and revoke keys.
  - name: System
    description: Unauthenticated health / TLS-fingerprint probe.

# ---------------------------------------------------------------------------
# Error contract — consumed by the docs generator to render the integration
# error table, and useful to any tooling reading this spec.
# ---------------------------------------------------------------------------
x-error-contract:
  - status: '400'
    condition: 'Validation: missing fields, invalid JSON, or body larger than 1 MB.'
    body: '{ "error": "<detail>" }'
    action: 'Fix the request; do not retry unchanged.'
  - status: '401'
    condition: 'Missing or unknown/expired `X-Session` (session layer). No `reauth` flag.'
    body: '{ "error": "<detail>" }'
    action: 'Re-login to obtain a new session id, then retry.'
  - status: '401 (reauth)'
    condition: 'The server-held TE token expired or is invalid (token layer).'
    body: '{ "error": "session expired", "reauth": true, "detail": "<detail>" }'
    action: 'Re-run `POST /login` and retry the request.'
  - status: '403'
    condition: 'Real kS RBAC/ACL denial — the caller is not authorized.'
    body: '{ "error": "<op> denied or failed", "detail": "...Authorization Failed..." }'
    action: 'Surface the denial. Retrying will not help until access is granted.'
  - status: '404'
    condition: 'No such route.'
    body: '{ "error": "no route <METHOD> <path>" }'
    action: 'Check method and path.'
  - status: '500'
    condition: 'Other kS operation error.'
    body: '{ "error": "<detail>" }'
    action: 'Inspect `detail`; treat as an operation failure, not an auth problem.'
  - status: '502'
    condition: '`/login` failed for a non-credential reason (kS unreachable).'
    body: '{ "error": "<detail>" }'
    action: 'Transient/infrastructure — retry with backoff.'

components:
  securitySchemes:
    SessionId:
      type: apiKey
      in: header
      name: X-Session
      description: |
        Opaque session id returned by `POST /login` as `sessionId`. It is a
        handle to a server-held per-user TE token — **not** a bearer token and
        **not** the token itself. Send it as `X-Session: <sessionId>` on every
        authenticated request. (This is why the scheme is a custom header and
        not `Authorization: Bearer`: the token never leaves the gateway.)

  schemas:
    LoginRequest:
      type: object
      required: [user, password]
      properties:
        user:
          type: string
          description: kS username.
          example: alice
        password:
          type: string
          description: kS password (used only to SRP-login; discarded after the token is issued).
          format: password
          example: '********'

    LoginResponse:
      type: object
      required: [sessionId, user, systemId, principalId, hasToken]
      properties:
        sessionId:
          type: string
          description: Opaque session id. Echo it back in `X-Session` on authenticated calls.
          example: 9f3c1ab7d2e4...48hex
        user:
          type: string
          example: alice
        systemId:
          type: integer
          description: >-
            kS system identifier for the principal. (Judgment call: documented
            as a numeric engine system id; serialized verbatim by the gateway.)
          example: 1001
        principalId:
          type: string
          description: Principal identifier, hex-encoded (16 hex chars).
          example: a1b2c3d4e5f60718
        hasToken:
          type: boolean
          description: True when a TE token was issued and stored server-side for this session.
          example: true

    MintKeyResponse:
      type: object
      required: [ttag]
      properties:
        ttag:
          type: string
          format: byte
          description: >-
            Base64 of the key's "hidden link" buffer — the opaque handle for the
            newly generated key. The caller becomes the key OWNER. Pass this `ttag`
            to export/grant/revoke.
          example: dGFnLWJhc2U2NC1leGFtcGxl

    ExportRequest:
      type: object
      required: [ttag, clientPubKey]
      properties:
        ttag:
          type: string
          format: byte
          description: Base64 key handle returned by `POST /keys`.
          example: dGFnLWJhc2U2NC1leGFtcGxl
        clientPubKey:
          type: string
          format: byte
          description: >-
            Base64 of the client's **ephemeral** raw uncompressed P-256 point
            (65 bytes, `0x04 || X || Y`). The key is wrapped to this public key;
            generate a fresh keypair per export and keep the private key
            non-extractable.
          example: BFq2...base64-65-byte-point

    WrappedKey:
      type: object
      description: >-
        ECDH-P256 → HKDF-SHA256 → AES-GCM wrap envelope. The client derives the
        same shared secret from `gatewayPubKey` + its own ephemeral private key,
        then AES-GCM-decrypts `ciphertext` (with `iv`/`authTag`) to unwrap the
        data key. The raw key never crosses the wire in clear.
      required: [gatewayPubKey, salt, iv, ciphertext, authTag]
      properties:
        gatewayPubKey:
          type: string
          format: byte
          description: Base64 of the gateway's ephemeral P-256 public point.
        salt:
          type: string
          format: byte
          description: Base64 HKDF salt (16 bytes).
        iv:
          type: string
          format: byte
          description: Base64 AES-GCM initialization vector.
        ciphertext:
          type: string
          format: byte
          description: Base64 AES-GCM ciphertext of the wrapped data key.
        authTag:
          type: string
          format: byte
          description: Base64 AES-GCM authentication tag.

    ExportResponse:
      type: object
      required: [wrapped, algo, keyLen]
      properties:
        wrapped:
          $ref: '#/components/schemas/WrappedKey'
        algo:
          type: string
          description: >-
            Cipher label for the delivered data key (not the wrap envelope).
            NOTE: the gateway currently **hardcodes** this to `aes-128-cbc`; it
            is NOT derived from the key and does NOT track `keyLen`. The demo
            only creates AES-128 keys (NID 419 → 16 bytes), so here `algo` and
            `keyLen` happen to coincide. For an AES-192/256 key, `keyLen` would
            be 24/32 while `algo` would still report `aes-128-cbc`. Treat
            `keyLen` as authoritative for the delivered key length.
          example: aes-128-cbc
        keyLen:
          type: integer
          description: >-
            Length in bytes of the delivered data key, derived from the export
            blob NID (419 → 16, 423 → 24, 427 → 32). Authoritative for the key
            length (see the caveat on `algo`).
          enum: [16, 24, 32]
          example: 16

    GrantRequest:
      type: object
      required: [ttag, grantee]
      properties:
        ttag:
          type: string
          format: byte
          description: Base64 key handle of the key to grant access to.
          example: dGFnLWJhc2U2NC1leGFtcGxl
        grantee:
          type: string
          description: Username to grant access to.
          example: bob

    GrantResponse:
      type: object
      required: [ok, grantee]
      properties:
        ok:
          type: boolean
          example: true
        grantee:
          type: object
          required: [user, systemId, principalId]
          properties:
            user:
              type: string
              example: bob
            systemId:
              type: integer
              example: 1002
            principalId:
              type: string
              description: Hex-encoded principal id (16 hex chars).
              example: 1122334455667788

    RevokeRequest:
      type: object
      required: [ttag, grantee]
      properties:
        ttag:
          type: string
          format: byte
          description: Base64 key handle of the key to revoke access to.
          example: dGFnLWJhc2U2NC1leGFtcGxl
        grantee:
          type: string
          description: Username to revoke access from.
          example: bob

    OkResponse:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
          example: true

    HealthResponse:
      type: object
      required: [ok, ksFingerprint, sessions]
      properties:
        ok:
          type: boolean
          example: true
        ksFingerprint:
          type: string
          description: TLS certificate fingerprint of the kS the gateway is bound to.
          example: 'AB:CD:EF:...'
        sessions:
          type: integer
          description: Count of live server-held sessions.
          example: 2

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error summary.
        detail:
          type: string
          description: Optional underlying detail (e.g. the kS error text).

    ReauthError:
      type: object
      description: >-
        Token-layer 401. Distinguished from the session-layer 401 by the
        `reauth: true` flag. Re-run `POST /login` and retry.
      required: [error, reauth]
      properties:
        error:
          type: string
          example: session expired
        reauth:
          type: boolean
          const: true
          example: true
        detail:
          type: string

  responses:
    BadRequest:
      description: 'Validation error: missing fields, invalid JSON, or body > 1 MB.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: >-
        Authentication required. Two shapes share this status: a session-layer
        `401` (a plain `Error`, **no** `reauth` flag) means a missing or
        unknown/expired `X-Session` — re-login for a new session id; a
        token-layer `401` (`ReauthError` with `reauth: true`) means the
        server-held token expired — re-run `POST /login` and retry.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/Error'
              - $ref: '#/components/schemas/ReauthError'
    Forbidden:
      description: >-
        kS RBAC/ACL denial — a real authorization refusal. Surface it; retrying
        will not help until access is granted.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: No such route.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: 'no route GET /foo'
    ServerError:
      description: Other kS operation error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

paths:
  /login:
    post:
      tags: [Session]
      operationId: login
      summary: Sign in and issue a server-held token
      description: |
        Performs a real SRP login as the user against the kS, issues a per-user
        TE token stored server-side, and returns an opaque `sessionId`. The
        password is used only for the login and is then discarded; the token
        never leaves the gateway.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Logged in; session created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: >-
            Wrong credential (`WRONG_CREDENTIAL` / `credential` / `empty data`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: Login failed for a non-credential reason (kS unreachable).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /logout:
    post:
      tags: [Session]
      operationId: logout
      summary: Sign out
      description: Clears the server-held token and drops the session.
      security:
        - SessionId: []
      responses:
        '200':
          description: Logged out.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /keys:
    post:
      tags: [Keys]
      operationId: mintKey
      summary: Generate a new key
      description: >-
        Generates a new key and returns its `ttag` (base64 hidden-link handle). The
        caller becomes the key OWNER. The request body is empty (`{}`).
      security:
        - SessionId: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              description: Empty object.
      responses:
        '200':
          description: Key generated; caller is owner.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MintKeyResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'

  /keys/export:
    post:
      tags: [Keys]
      operationId: exportKey
      summary: Export a key wrapped to the client's ephemeral public key
      description: |
        Wraps the key identified by `ttag` to the client's ephemeral P-256
        public key (ECDH → HKDF → AES-GCM) and returns the wrap envelope. The
        caller must be the owner or have been granted access, otherwise the kS
        denies the export with `403`.
      security:
        - SessionId: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportRequest'
      responses:
        '200':
          description: Wrapped key returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'

  /keys/grant:
    post:
      tags: [Keys]
      operationId: grantKey
      summary: Grant a user access to a key
      description: >-
        Adds `grantee` to the key's ACL. The caller must be the owner. Returns
        the resolved grantee identity.
      security:
        - SessionId: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GrantRequest'
      responses:
        '200':
          description: Access granted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GrantResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'

  /keys/revoke:
    post:
      tags: [Keys]
      operationId: revokeKey
      summary: Revoke a user's access to a key
      description: Removes `grantee` from the key's ACL. The caller must be the owner.
      security:
        - SessionId: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RevokeRequest'
      responses:
        '200':
          description: Access revoked.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'

  /health:
    get:
      tags: [System]
      operationId: health
      summary: Health and TLS fingerprint
      description: >-
        Unauthenticated liveness probe. Returns the kS TLS fingerprint (pin it
        in your UI) and the live session count.
      security: []
      responses:
        '200':
          description: Gateway is healthy.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
