# ADR-0006: DTI HMAC request signature болон nonce replay protection

## Status

Accepted

## Context

DTI client болон server хооронд request-ийн эх үүсвэр, action-level business data, method, path/query-г shared secret ашиглан баталгаажуулах шаардлагатай.

Raw request body-г бүхэлд нь sign хийхэд JSON field order, whitespace, form serialization, text encoding, body parser болон raw body capture зэрэг transport-level complexity үүснэ.

DTI action contract дээр `signature(param)` callback байгаа тул action бүр хамгаалах business field-үүдээ deterministic string болгон тодорхойлж болно.

Timestamp дангаараа replay attack-аас бүрэн хамгаалахгүй. Tolerance window дотор ижил valid request-ийг дахин илгээхээс хамгаалахын тулд atomic nonce store шаардлагатай.

## Decision

### Signing scope

DTI request signing нь `HMAC-SHA256` болон shared secret ашиглана.

`DTIAction.signature(param)` нь cryptographic signature биш. Энэ callback нь action-ийн хамгаалах business data-г deterministic string болгон буцаах үүрэгтэй.

```ts
const paymentCreate = createAction("paymentCreate", {
    params: z.object({
        tenantId: z.string(),
    }),
    body: z.object({
        invoiceId: z.string(),
        amount: z.number(),
    }),
}, {
    path: "/tenants/:tenantId/payments",
    method: "POST",
    signature: ({ params, body }) => [
        params.tenantId,
        body.invoiceId,
        body.amount,
    ].join(":"),
});
```

`signature(param)` дараах дүрэмтэй:

- synchronous `string` буцаана;
- client болон server дээр ижил parsed param-аас ижил утга гаргана;
- random value, current time, locale-dependent format ашиглахгүй;
- secret агуулахгүй;
- хамгаалах шаардлагатай business field бүрийг өөрөө оруулна.

Request body-г автоматаар бүхэлд нь sign хийхгүй. `signature(param)`-д оруулаагүй body/query/path field-ийн business integrity-г DTI signing баталгаажуулахгүй.

### Enablement

Client signing-ийг global эсвэл per-call `sign` option-оор идэвхжүүлнэ.

```ts
const client = new DTIClient("/api", {
    sign: {
        keyId: "client-a",
        secret: "shared-secret",
    },
});
```

`secret` нь static string эсвэл `{ action, param }` авдаг sync/async resolver байж болно.

Per-call `sign` нь tri-state дүрэмтэй:

| Per-call `sign` | Behavior |
| --- | --- |
| `undefined` эсвэл field байхгүй | Global `sign`-ийг inherit хийнэ |
| `false` | Тухайн call дээр client signing disable хийнэ |
| `DTIClientSign` object | Global `sign`-ийг бүхэлд нь override хийнэ |

```ts
await client.call(publicAction, param, {
    sign: false,
});
```

`sign: false` үед:

- client library signing header үүсгэхгүй;
- action contract дээр `signature` callback шаардахгүй;
- global/per-call custom `headers` дотор app өөрөө өгсөн `x-dti-*` header-ийг library автоматаар устгахгүй;
- server router-ийн signing policy өөрчлөгдөхгүй.

Server router signing enabled бол `sign: false` request signing header-гүй очиж `401 / DTI_SIGNATURE_REQUIRED` авна. Иймээс opt-out нь unsigned server route дуудах үед хэрэглэгдэнэ.

Library `NODE_ENV`, development эсвэл debug mode-оор signing-г автоматаар disable хийхгүй. Environment policy-г application config шийднэ.

Server signing нь router-level option байна.

```ts
const dti = createDTIExpressRouter({
    sign: {
        toleranceMs: 5 * 60 * 1000,
        nonceStore,
        getSecret: async ({ keyId }) => {
            return resolveSecret(keyId);
        },
    },
});
```

Router дээр `sign` тохируулсан бол тухайн router-ийн бүх action signing шаардана. Action дээр `signature` callback байхгүй эсвэл request signing header дутуу бол request reject хийнэ. Нэг router дотор action-level sign opt-out байхгүй.

`getSecret` нь `{ keyId, req, res }` авч shared secret буцаана. Unknown эсвэл revoked `keyId`-г reject хийх үүрэг server application-д байна.

### Canonical payload

Client болон server дараах canonical payload-ийг яг ижил утгаар үүсгэнэ:

```text
{method}
{pathWithQuery}
{timestamp}
{nonce}
{actionSignature}
```

Дүрэм:

- separator нь newline буюу `\n`;
- `method` нь uppercase HTTP method;
- `pathWithQuery` нь resolved, encoded path болон query string;
- origin, protocol, host, port canonical payload-д орохгүй;
- `timestamp` нь request header дээр дамжих string;
- `nonce` нь request header дээр дамжих string;
- `actionSignature` нь `DTIAction.signature(validParam)` result.

Path params client дээр resolve болон URL encode хийгдсэний дараах path canonical payload-д орно. Server `req.url` ашиглана. Proxy эсвэл middleware path/query encoding, order, value-г өөрчилбөл signature таарахгүй байж болно.

Жишээ:

```text
POST
/tenants/acme/payments?dryRun=false
2026-06-19T02:10:00.000Z
nonce-123
acme:invoice-42:150000
```

Canonical payload-ийг shared secret ашиглан `HMAC-SHA256` тооцно. Result нь padding-гүй Base64URL string байна.

HMAC implementation нь Web Crypto `crypto.subtle` шаарддаг. Signature comparison нь timing-safe string comparison ашиглана.

### Signing headers

Request дээр library-owned дараах default header names ашиглана:

- `x-dti-key-id`
- `x-dti-timestamp`
- `x-dti-nonce`
- `x-dti-signature`

Эдгээр нь fixed protocol constant биш, config өгөөгүй үеийн default mapping байна. Client болон server signing config дээр header names-ийг өөрчлөх шийдвэр, validation болон migration policy-г `ADR-0008-configurable-dti-sign-header-names.md` тодорхойлно.

Client header merge дараалал:

1. global `headers`
2. global `auth`
3. per-call `headers`
4. per-call `auth`
5. DTI signing headers
6. `Content-Type` байхгүй бол library fallback

Signing enabled үед app-level custom header DTI signing header-үүдийг override хийхгүй. Library өөрийн тооцсон утгаар сольж тавина.

### Timestamp

Client default timestamp нь `new Date().toISOString()` байна.

Custom `timestamp()` callback нь `Date`, strict UTC ISO timestamp string, эсвэл тэдгээрийн `Promise` буцааж болно. `Date` утгыг client `toISOString()` ашиглан string болгоно.

String timestamp дараах exact format-тай байна:

```text
YYYY-MM-DDTHH:mm:ss.sssZ
```

Client custom string-ийг request илгээхээс өмнө, server header value-г tolerance шалгахаас өмнө format болон calendar validity-аар validate хийнэ. Offset бүхий timestamp, timezone-гүй string, space separator болон fractional second-гүй утгыг зөвшөөрөхгүй.

Server strict format validation амжилттай болсны дараа timestamp-г parse хийнэ. Request time нь current server time-ээс өнгөрсөн эсвэл ирээдүй аль ч чиглэлд `toleranceMs`-ээс их зөрвөл reject хийнэ.

Default `toleranceMs`:

```text
5 minutes = 300000 milliseconds
```

`toleranceMs` нь server-side signing config байна. Client дээр tolerance config байхгүй; client зөвхөн timestamp үүсгэж header-ээр дамжуулна.

```ts
const toleranceMs = Number(
    process.env.DTI_SIGN_TOLERANCE_MS ?? 5 * 60 * 1000,
);

const dti = createDTIExpressRouter({
    sign: {
        getSecret,
        nonceStore,
        toleranceMs,
    },
});
```

Library environment variable-ийг автоматаар уншихгүй. Env/config source-ийн string утгыг number болгох үүрэг application-д байна. Resolved `toleranceMs`-ийн эцсийн validation-ийг library router үүсэх үед хийнэ.

Resolved value дараах дүрмийг хангана:

```ts
Number.isSafeInteger(toleranceMs) && toleranceMs > 0
```

Invalid бол router үүсэх үед request хүлээж авахаас өмнө дараах алдаа гарна:

```text
DTI_SIGN_CONFIG_ERROR
```

`toleranceMs` нь зөвхөн request timestamp-ийн зөвшөөрөгдөх өнгөрсөн/ирээдүйн зөрүүг тодорхойлно. Config өгөөгүй үед `300000ms` default ашиглана.

### Nonce replay protection

Client default nonce үүсгэх дараалал:

1. `crypto.randomUUID()`
2. `crypto.getRandomValues()` ашигласан 16-byte random value
3. runtime fallback value

Production runtime Web Crypto дэмжих шаардлагатай.

Server nonce store contract:

```ts
export interface INonceStore {
    consume(nonce: string, ttl: number): Promise<boolean>;
}
```

Server nonce store-д raw nonce биш дараах normalized key дамжуулна:

```text
{keyId}:{nonce}
```

`consume` нь atomic check-and-store operation байна:

- `true`: key өмнө ашиглагдаагүй, одоо TTL-тай хадгалагдсан;
- `false`: key өмнө ашиглагдсан, request replay гэж reject хийнэ.

Nonce TTL нь тусдаа public config биш. Library request-ийн signed timestamp хүчинтэй байх үлдсэн хугацаанаас автоматаар тооцно:

```ts
const validUntil = timestampMs + toleranceMs;
const nonceTtlMs = Math.max(1, validUntil - Date.now());
```

Ингэснээр nonce request дахин хүчинтэй байж болох бүх хугацаанд store-д үлдэнэ. Future timestamp tolerance-ийн дээд хязгаарт байвал nonce TTL хамгийн ихдээ ойролцоогоор `2 * toleranceMs` байна.

`nonceStore.consume(...)`-д дамжих `ttl` нь `nonceTtlMs` millisecond утга байна. Storage өөр unit ашигладаг бол implementation expiry-г богиносгохгүйгээр хөрвүүлнэ. Жишээ нь second precision ашиглавал дээш нь тоймлоно.

Production distributed deployment дээр Redis `SET key value NX PX ttl` зэрэг нэг atomic operation ашиглана. Тусдаа `has` болон `add` operation ашиглахгүй.

Nonce-г signature valid болсны дараа consume хийнэ. Invalid signature nonce store-ийн key space-г дүүргэхгүй.

### Request flow

```mermaid
sequenceDiagram
    participant C as DTI Client
    participant S as DTI Server
    participant K as Secret Provider
    participant N as Nonce Store
    participant H as Action Handler

    C->>C: Parse params, query, body
    C->>C: Run validateParam
    C->>C: Resolve path and query
    C->>C: Build actionSignature
    C->>C: Build canonical payload
    C->>C: HMAC-SHA256
    C->>S: HTTP request + signing headers
    S->>S: Parse params, query, body
    S->>S: Require action.signature and headers
    S->>S: Validate timestamp tolerance
    S->>S: Build canonical payload
    S->>K: getSecret(keyId)
    K-->>S: shared secret
    S->>S: Verify HMAC-SHA256
    S->>S: Derive nonce TTL from validUntil
    S->>N: consume(keyId:nonce, nonceTtlMs)
    N-->>S: true / false
    S->>S: Resolve auth and meta
    S->>S: Run validateParam
    S->>H: Invoke typed handler
```

Server flow-ийн чухал дараалал:

1. Path params, query, body contract schema-аар parse хийгдэнэ.
2. Action signature callback болон required header шалгагдана.
3. Timestamp tolerance шалгагдана.
4. Canonical payload болон shared secret-ээр HMAC verify хийгдэнэ.
5. Valid signature дээр request-ийн хүчинтэй үлдсэн хугацаанаас nonce TTL тооцно.
6. Nonce atomic consume хийгдэнэ.
7. Auth, meta, `validateParam`, action handler үргэлжилнэ.

Schema validation fail бол signing verification-ээс өмнө `400` алдаа гарч болно. Signature, timestamp, replay validation нь auth болон action handler-аас өмнө ажиллана.

### Error contract

Invalid `toleranceMs` config дээр router үүсэх үед `DTI_SIGN_CONFIG_ERROR` гарна. Энэ нь HTTP response биш startup/configuration error байна.

| Code | HTTP status | Нөхцөл |
| --- | --- | --- |
| `DTI_SIGNATURE_REQUIRED` | `401` | Router signing enabled боловч action callback эсвэл required header байхгүй |
| `DTI_SIGNATURE_TIMESTAMP_INVALID` | `401` | Timestamp parse болохгүй эсвэл tolerance-оос гарсан |
| `DTI_SIGNATURE_INVALID` | `401` | HMAC signature таарахгүй |
| `DTI_REPLAY_DETECTED` | `401` | Nonce key өмнө consume хийгдсэн |

Secret, canonical payload, expected signature-г error response эсвэл log-д задлахгүй.

### Signed болон unsigned data

Canonical payload-д шууд орно:

- HTTP method
- exact resolved path/query
- timestamp
- nonce
- action-selected business material

Шууд орохгүй:

- origin, host, port
- raw body bytes
- JSON whitespace болон field order
- arbitrary request headers
- auth token
- `Content-Type`
- `signature(param)`-д сонгоогүй business field

HTTPS шаардлага хэвээр байна. HMAC signing нь transport encryption, server identity verification болон secret storage-ийг орлохгүй.

## Consequences

- JSON, form, text serialization-ийн raw byte ялгаа signature-д нөлөөлөхгүй.
- Action бүр хамгаалах business field-үүдээ contract-level дээр ил тод сонгоно.
- `signature(param)` deterministic биш бол client/server signature зөрнө.
- Field орхигдвол тухайн field-ийн business integrity хамгаалагдахгүй.
- Exact path/query canonical payload-д ордог тул proxy rewrite signature эвдэж болно.
- Key rotation болон multi-client secret resolution-ийг `keyId` болон `getSecret`-ээр application шийднэ.
- Replay protection nonce store-ийн atomic ажиллагаанаас хамаарна.
- Distributed production орчин shared nonce store шаарддаг.
- Nonce TTL нь timestamp validity-ээс derived тул тусдаа буруу TTL config replay window нээхгүй.
- Signing router-level тул public болон signed action-ийг тусдаа router-аар салгана.
- Global client sign default-аар inherit хийгдэх бөгөөд зөвхөн explicit `sign: false` тухайн call-ийг unsigned болгоно.
- Signing header names нь default mapping-тэй бөгөөд client/server config-оор coordinated байдлаар өөрчилж болно.
- `action.name` canonical payload-д орохгүй тул contract identifier rename хийх нь HTTP route болон request signature-г өөрчлөхгүй.
- Canonical payload-д protocol version/domain marker байхгүй. Ирээдүйд payload format эсвэл signing algorithm өөрчлөх бол client/server coordinated breaking change шаардлагатай.

## Alternatives Considered

### `action.name`-ийг canonical payload-д оруулах

Нэг HTTP route дээрх contract identifier-ийг signature-д bind хийх боломжтой. Гэхдээ `action.name` нь internal contract identifier бөгөөд REST client HTTP request-ээс шууд тодорхойлох боломжгүй. Method болон exact resolved path/query нь endpoint identity-г аль хэдийн canonical payload-д оруулж байгаа тул `action.name`-ийг хасна.

### Protocol version marker canonical payload-д оруулах

Protocol domain separation болон ирээдүйн version negotiation хийх суурь болно. Гэхдээ одоогийн signing contract олон version зэрэг дэмжих negotiation mechanism-гүй бөгөөд marker нь raw HTTP client-д DTI-specific нэмэлт утга шаардана. Canonical payload-ийг marker-гүй байлгаж, ирээдүйн format эсвэл algorithm өөрчлөлтийг coordinated breaking change гэж үзнэ.

### Raw request body-г бүхэлд нь hash/sign хийх

Transport payload integrity илүү өргөн хамгаална. Гэхдээ raw body capture, serialization canonicalization, content type бүрийн encoding contract шаарддаг тул одоогийн DTI scope-д оруулаагүй.

### Бүх parsed param-ийг library автоматаар canonical JSON болгох

Action author field сонгох шаардлагагүй болно. Гэхдээ object key order, transform, unsupported value type болон backward compatibility-д тусдаа canonical JSON standard шаардлагатай.

### Зөвхөн timestamp ашиглах

Implementation энгийн боловч tolerance window дотор replay attack хийх боломж үлдэнэ.

### Nonce store дээр has болон add хоёр operation ашиглах

Concurrent request хоёулаа valid болох race condition үүснэ. Иймээс atomic `consume` contract ашиглана.

### Nonce TTL-ийг тусдаа configurable option болгох

Storage retention-ийг application шууд удирдах боломжтой болно. Гэхдээ timestamp validity window-ээс богино TTL тохируулбал nonce store-оос key арилсны дараа request дахин хүчинтэй хэвээр байж replay хамгаалалт нээгдэнэ. Иймээс nonce TTL-ийг signed timestamp-ийн хүчинтэй үлдсэн хугацаанаас library derive хийнэ.

### Database nonce store заавал ашиглах

Durability нэмэгдэх боловч latency, cleanup complexity өснө. Library storage implementation тулгахгүй, зөвхөн atomic contract шаарддаг.
