Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5714e3090b | |||
| 2a167ee458 | |||
| da08e22686 | |||
| a8882bdc2f | |||
| 4a51f69a2f | |||
| 179c9eb803 | |||
| 903926e34c | |||
| 2575e9545d | |||
| d1d7410731 | |||
| 0ba0d4c382 | |||
| c5085b2707 | |||
| ade9fe20b2 | |||
| fed20d5fba | |||
| 975cc509ae | |||
| d59dc7b796 | |||
| 4221ca707e | |||
| f240778162 | |||
| 412a6d268a | |||
| e02dae4ec7 | |||
| ff50881fd0 | |||
| 6c82544b80 | |||
| eaf4125f21 | |||
| 2e16e7d852 | |||
| 0c68759455 | |||
| 4fc6eaf986 | |||
| 6415db7085 | |||
| 1a97e1208c | |||
| a7f1398529 | |||
| f5bcaa3a69 | |||
| d4a34bb067 | |||
| 844725d660 | |||
| 8d63baa53a | |||
| 1498be5213 | |||
| 6fbce27242 | |||
| 28c4164a61 | |||
| 666672d43e | |||
| 865b5eb550 | |||
| 50ac2bffc3 | |||
| e14e34a36e | |||
| 913153285b | |||
| 95ec869f79 | |||
| c0b7da9f40 | |||
| 0fd5d098be | |||
| 323e1e94a9 | |||
| bd4839b67f | |||
| 5eb7db9bf3 | |||
| 35bfe887bb | |||
| d7ccbda283 | |||
| 3d98728694 | |||
| 6e66c5c086 | |||
| d6f12ed7e9 | |||
| 1a4876a0cd | |||
| 5ed8a63839 | |||
| bfa5c7e00b | |||
| fad6aadf20 | |||
| 662ee9e64f | |||
| 7c703abe24 | |||
| a3fcc4b063 | |||
| f991ba21d8 | |||
| e2608c9e2a | |||
| bf48d7a8bd | |||
| 2c74db214b | |||
| 372d30e430 | |||
| e6522e65c6 | |||
| 51c8f2abf1 | |||
| 364215bcf1 | |||
| 0cef265a2c | |||
| 37127cc8ec | |||
| 7ccb2e844f | |||
| 1effd2f898 | |||
| 4ed652ef3e | |||
| edfde6fa1e | |||
| a288e8888b | |||
| f4701d07c7 | |||
| d31aa50768 | |||
| 9ece08eed9 | |||
| 0a6ede48ef | |||
| 2f15111f99 | |||
| c6ca8a2fa5 | |||
| 3ed1c5ee95 | |||
| d63ace86e4 | |||
| e67001a82a | |||
| c4febaa749 | |||
| f573d0e5a3 | |||
| fafb991561 | |||
| 49ecba1149 | |||
| aa482316ee | |||
| 33548c2200 | |||
| 7b6fdd823e | |||
| 0065314b95 | |||
| f8e835b4a8 | |||
| 4ac9e17296 | |||
| 75f5a4bfd5 | |||
| 86dede3d35 |
@@ -39,3 +39,14 @@ MINIO_ROOT_PASSWORD=minioadmin
|
||||
CHAIN_URL=http://127.0.0.1:8888
|
||||
API_URL=http://127.0.0.1:2998/v1/graphql
|
||||
MONGODB_URL=mongodb://127.0.0.1:27017
|
||||
|
||||
# --- CoopID (overlays docker-compose.authentik.yml + docker-compose.edge.yml) ---
|
||||
# Поднимаются только явной цепочкой -f (см. infra/coopid/README.md);
|
||||
# перед первым запуском: bash scripts/coopid-gen-secrets.sh
|
||||
# К портам применяется то же правило offset = (INSTANCE_INDEX - 1) * 10.
|
||||
COOP_PG_HOST_PORT=5632
|
||||
AUTHENTIK_HOST_PORT=9008
|
||||
CADDY_HTTP_PORT=8088
|
||||
CADDY_HTTPS_PORT=8443
|
||||
# Домен кооператива для caddy: localhost = dev self-signed; реальный домен = Let's Encrypt.
|
||||
COOPID_DOMAIN=localhost
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: SDK cross-runtime
|
||||
|
||||
# Story 9.13 (CoopID, Эпик 9): smoke-тесты @coopenomics/auth на трёх рантаймах —
|
||||
# Node (vitest), браузер (vitest browser mode + chromium), desktop-runtime
|
||||
# (electron, main-process против собранного dist). SDK уходит клиентам в три
|
||||
# разных рантайма, и регрессия вида «crypto.subtle недоступен / bundle не
|
||||
# грузится в браузере» не ловится Node-юнитами вовсе.
|
||||
#
|
||||
# Триггер — только изменения самого пакета: прогон тяжёлый (браузер + electron
|
||||
# + xvfb), на каждый PR в dev его гонять незачем.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- 'components/auth/**'
|
||||
- '.github/workflows/sdk-cross-runtime.yaml'
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: sdk-cross-runtime-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cross-runtime:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install workspace deps
|
||||
run: pnpm install --frozen-lockfile --filter @coopenomics/auth...
|
||||
|
||||
# xvfb — electron на Linux требует X-сервер; браузеры playwright ставим
|
||||
# вместе с системными библиотеками chromium (--with-deps).
|
||||
- name: Install runtime prerequisites
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb
|
||||
# test:electron поднимает реальный electron — ему нужны GTK/ATK/ALSA-библиотеки,
|
||||
# которых нет в наборе `playwright --with-deps chromium` (chromium на другом тулките).
|
||||
# Симптом без них: electron «error while loading shared libraries: libgtk-3.so.0».
|
||||
# Ubuntu 24.04 (noble) — пакеты с суффиксом t64; ставим по одному, чтобы расхождение
|
||||
# имени пакета не валило весь шаг.
|
||||
for pkg in libgtk-3-0t64 libnotify4 libnss3 libxss1 libxtst6 libatspi2.0-0t64 libasound2t64; do
|
||||
sudo apt-get install -y "$pkg" || true
|
||||
done
|
||||
cd components/auth && pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run cross-runtime smoke
|
||||
run: pnpm test:cross-runtime
|
||||
@@ -25,3 +25,6 @@ components/contracts/cpp/ledger2/scripts/out/
|
||||
.pnpm-store
|
||||
schema.gql
|
||||
.claude
|
||||
|
||||
# CoopID: file-based Docker Secrets (генерируются scripts/coopid-gen-secrets.sh)
|
||||
infra/coopid/secrets/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineBuildConfig } from 'unbuild'
|
||||
|
||||
// Один dual-выход (ESM .mjs + CJS .cjs + .d.ts) обслуживает все три target'а:
|
||||
// браузер и desktop-runtime берут import→.mjs, Node — require→.cjs (exports-map).
|
||||
export default defineBuildConfig({
|
||||
entries: ['src/index'],
|
||||
declaration: true,
|
||||
clean: true,
|
||||
rollup: {
|
||||
emitCJS: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// @ts-check
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
type: 'lib',
|
||||
rules: {
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
'no-console': 'off',
|
||||
'ts/explicit-function-return-type': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@coopenomics/auth",
|
||||
"type": "module",
|
||||
"version": "2026.6.9-18",
|
||||
"private": false,
|
||||
"packageManager": "pnpm@9.9.0",
|
||||
"description": "SDK аутентификации CoopID: OIDC, vault, кошелёк и подпись для браузера, Node и desktop",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"keywords": [],
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"./dist/*",
|
||||
"./dist/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "pnpm run typecheck",
|
||||
"build": "unbuild",
|
||||
"dev": "unbuild --stub",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:node": "vitest run test/cross-runtime",
|
||||
"test:browser": "vitest run --config vitest.config.browser.ts",
|
||||
"test:electron": "node test/cross-runtime/run-electron.mjs",
|
||||
"test:cross-runtime": "pnpm run build && pnpm run test:node && pnpm run test:browser && pnpm run test:electron",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@wharfkit/antelope": "^1.1.1",
|
||||
"@wharfkit/session": "^1.4.0",
|
||||
"cooptypes": "workspace:*",
|
||||
"jose": "^6.2.2",
|
||||
"oidc-client-ts": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.3.2",
|
||||
"@types/node": "^22.5.4",
|
||||
"@vitest/browser": "2.1.9",
|
||||
"electron": "36.9.5",
|
||||
"eslint": "^9.9.1",
|
||||
"playwright": "1.59.1",
|
||||
"typescript": "^5.5.4",
|
||||
"unbuild": "^2.0.0",
|
||||
"vite": "^5.4.3",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* participant_certificate: чтение claims на клиенте (ЛК, Story 1.9) и offline-
|
||||
* верификация (Story 4.4). Здесь — только ДЕКОДИРОВАНИЕ payload и производные
|
||||
* (статус, человекочитаемые типы верификации). Проверка подписи против trust
|
||||
* anchor — отдельно (verifyOffline, chain/), для отображения в ЛК не требуется.
|
||||
*/
|
||||
import { decodeJwt } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
export * from './schema-policy'
|
||||
|
||||
export interface CoopChainLink {
|
||||
account: string
|
||||
public_key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждённый тип верификации в удостоверении (Story 4.3). Структурная форма
|
||||
* для RP: что подтверждено (`type`), когда (`verified_at`, ISO-8601 UTC) и на каком
|
||||
* основании (`source`).
|
||||
*/
|
||||
export interface VerificationTypeClaim {
|
||||
type: string
|
||||
verified_at: string
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Claims participant_certificate (зеркало payload контроллера, Story 1.8). */
|
||||
export interface ParticipantCertificateClaims {
|
||||
iss: string
|
||||
/** UUID пайщика */
|
||||
sub: string
|
||||
/** серийный номер удостоверения */
|
||||
jti: string
|
||||
iat: number
|
||||
exp: number
|
||||
coopname: string
|
||||
coop_chain: CoopChainLink[]
|
||||
verification_types: VerificationTypeClaim[]
|
||||
identification: Record<string, unknown> | null
|
||||
claim_schema_version: string
|
||||
/** 152-ФЗ-обязательство RP удалить данные пайщика (Story 4.8), напр. `erase_on_exclusion`. */
|
||||
data_retention_contract: string
|
||||
/** Дедлайн удаления данных RP, unix-секунды (`iat + 30 дней`, Story 4.8). */
|
||||
retention_deadline_ts: number
|
||||
}
|
||||
|
||||
/** Нормализовать сырой claim verification_types в структурную форму (Story 4.3). */
|
||||
function normalizeVerificationTypes(raw: unknown): VerificationTypeClaim[] {
|
||||
if (!Array.isArray(raw))
|
||||
return []
|
||||
return raw
|
||||
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null && typeof (e as Record<string, unknown>).type === 'string')
|
||||
.map(e => ({
|
||||
type: String(e.type),
|
||||
verified_at: String(e.verified_at ?? ''),
|
||||
source: String(e.source ?? ''),
|
||||
}))
|
||||
}
|
||||
|
||||
export type CertificateStatus = 'active' | 'expiring' | 'expired'
|
||||
|
||||
/** Окно «истекает» до exp (24ч по умолчанию у сертификата; здесь — последний час). */
|
||||
export const CERTIFICATE_EXPIRING_WINDOW_MS = 60 * 60 * 1000
|
||||
|
||||
/** Человекочитаемые описания типов верификации (claim `verification_types`). */
|
||||
export const VERIFICATION_TYPE_LABELS: Record<string, string> = {
|
||||
coop_baseline: 'Базовое подтверждение кооперативом',
|
||||
}
|
||||
|
||||
/** Описание типа верификации; неизвестный — отдаём как есть (forward-compat). */
|
||||
export function verificationTypeLabel(type: string): string {
|
||||
return VERIFICATION_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
/**
|
||||
* Декодировать compact JWS в claims БЕЗ проверки подписи (для отображения в ЛК).
|
||||
* Бросает AuthV2Error при структурно некорректном сертификате.
|
||||
*/
|
||||
export function decodeParticipantCertificate(jws: string): ParticipantCertificateClaims {
|
||||
let raw: Record<string, unknown>
|
||||
try {
|
||||
raw = decodeJwt(jws) as Record<string, unknown>
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Некорректный participant_certificate: не удалось прочитать claims')
|
||||
}
|
||||
if (typeof raw.jti !== 'string' || typeof raw.exp !== 'number' || typeof raw.sub !== 'string')
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'participant_certificate без обязательных claims (jti/exp/sub)')
|
||||
|
||||
return {
|
||||
iss: String(raw.iss ?? ''),
|
||||
sub: raw.sub,
|
||||
jti: raw.jti,
|
||||
iat: Number(raw.iat ?? 0),
|
||||
exp: raw.exp,
|
||||
coopname: String(raw.coopname ?? ''),
|
||||
coop_chain: Array.isArray(raw.coop_chain) ? (raw.coop_chain as CoopChainLink[]) : [],
|
||||
verification_types: normalizeVerificationTypes(raw.verification_types),
|
||||
identification: (raw.identification as Record<string, unknown> | null) ?? null,
|
||||
claim_schema_version: String(raw.claim_schema_version ?? ''),
|
||||
data_retention_contract: String(raw.data_retention_contract ?? ''),
|
||||
retention_deadline_ts: Number(raw.retention_deadline_ts ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** Статус по сроку действия: expired / expiring (близко к exp) / active. */
|
||||
export function certificateStatus(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): CertificateStatus {
|
||||
const expMs = claims.exp * 1000
|
||||
if (nowMs >= expMs)
|
||||
return 'expired'
|
||||
if (expMs - nowMs <= CERTIFICATE_EXPIRING_WINDOW_MS)
|
||||
return 'expiring'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
/**
|
||||
* Окно упреждающего перезапроса сертификата (Story 4.6): за 5 минут до `exp` SDK
|
||||
* молча обновляет удостоверение, чтобы у короткоживущего cert (дефолт 1ч) не было
|
||||
* разрыва доступа на стыке.
|
||||
*/
|
||||
export const CERTIFICATE_RENEWAL_LEAD_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Сколько миллисекунд ждать до момента перезапроса (`exp − lead`). Никогда не
|
||||
* отрицательно: если до `exp` осталось ≤ lead (или cert уже истёк) — 0 (перезапрос сразу).
|
||||
*/
|
||||
export function computeRenewalDelayMs(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): number {
|
||||
const renewAtMs = claims.exp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS
|
||||
return Math.max(0, renewAtMs - nowMs)
|
||||
}
|
||||
|
||||
/** Управление запланированным авто-обновлением сертификата. */
|
||||
export interface CertificateRenewalHandle {
|
||||
/** Отменить запланированный перезапрос (например, при logout). */
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/** Параметры планировщика — для подмены времени/таймеров в тестах. */
|
||||
export interface ScheduleCertificateRenewalOptions {
|
||||
now?: () => number
|
||||
setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>
|
||||
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Запланировать silent renewal сертификата за 5 минут до `exp` (Story 4.6). По
|
||||
* срабатыванию таймера вызывает `renew()` — хост-колбэк, который обновляет access
|
||||
* через `refresh_token` и тянет свежий cert (`getParticipantCertificate`), — затем
|
||||
* перепланирует от `exp` нового удостоверения. Кросс-рантайм: SDK владеет ЛОГИКОЙ
|
||||
* планирования, а транспорт/refresh — на стороне приложения (как и весь fetch в SDK).
|
||||
* Бесконечный цикл сам себя продлевает, пока не вызван `cancel()`. Ошибка `renew`
|
||||
* не роняет процесс (логируется), но и НЕ перепланирует — повторную попытку инициирует хост.
|
||||
*/
|
||||
export function scheduleCertificateRenewal(
|
||||
initialJws: string,
|
||||
renew: () => Promise<string>,
|
||||
options: ScheduleCertificateRenewalOptions = {},
|
||||
): CertificateRenewalHandle {
|
||||
const now = options.now ?? (() => Date.now())
|
||||
const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms))
|
||||
const clearTimer = options.clearTimer ?? (h => clearTimeout(h))
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let cancelled = false
|
||||
|
||||
const scheduleFrom = (jws: string): void => {
|
||||
if (cancelled)
|
||||
return
|
||||
const claims = decodeParticipantCertificate(jws)
|
||||
const delay = computeRenewalDelayMs(claims, now())
|
||||
timer = setTimer(() => {
|
||||
void renew()
|
||||
.then((nextJws) => {
|
||||
if (!cancelled)
|
||||
scheduleFrom(nextJws)
|
||||
})
|
||||
.catch(() => {
|
||||
// best-effort: сбой обновления (нет сети/refresh истёк) не роняет цикл;
|
||||
// повторную попытку инициирует приложение (например, при следующем действии).
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
scheduleFrom(initialJws)
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true
|
||||
if (timer !== null)
|
||||
clearTimer(timer)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Политика версий схемы claims удостоверения на стороне SDK (Story 4.10).
|
||||
* Зеркало публичной `/.well-known/coopid-schema-policy.json` контроллера: типы,
|
||||
* сравнение версий и кэш с TTL 24ч. `verifyOffline` сверяет `claim_schema_version`
|
||||
* проверяемого сертификата с минимально поддерживаемой версией из этого кэша.
|
||||
*
|
||||
* Кросс-рантайм: SDK владеет ЛОГИКОЙ кеша/сравнения; сам HTTP-fetch политики
|
||||
* инжектируется хостом (как и весь fetch в SDK — chain/ остаётся чисто офлайновым).
|
||||
*/
|
||||
|
||||
/** Путь публичной политики версий схемы у контроллера кооператива. */
|
||||
export const SCHEMA_POLICY_WELL_KNOWN_PATH = '/.well-known/coopid-schema-policy.json'
|
||||
|
||||
/** TTL кэша политики: офлайн принимает закэшированную min-версию до 24 часов (FR72). */
|
||||
export const SCHEMA_POLICY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export interface CoopIdSchemaPolicy {
|
||||
current_version: string
|
||||
min_supported_version: string
|
||||
deprecation: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Сравнить две версии схемы. Версии — целочисленные строки (`'0'`, `'1'`, …):
|
||||
* сравниваем численно, при нечисловой версии — лексикографический фолбэк.
|
||||
* `< 0` если a старее b, `0` если равны, `> 0` если a новее.
|
||||
*/
|
||||
export function compareSchemaVersions(a: string, b: string): number {
|
||||
const na = Number(a)
|
||||
const nb = Number(b)
|
||||
if (Number.isFinite(na) && Number.isFinite(nb))
|
||||
return na - nb
|
||||
if (a === b)
|
||||
return 0
|
||||
return a < b ? -1 : 1
|
||||
}
|
||||
|
||||
/** Поддерживается ли версия схемы (не старее минимально поддерживаемой). */
|
||||
export function isSchemaVersionSupported(version: string, minSupported: string): boolean {
|
||||
return compareSchemaVersions(version, minSupported) >= 0
|
||||
}
|
||||
|
||||
export interface SchemaPolicyCacheOptions {
|
||||
/** Хост-колбэк, тянущий политику с `SCHEMA_POLICY_WELL_KNOWN_PATH` (fetch — у приложения). */
|
||||
fetchPolicy: () => Promise<CoopIdSchemaPolicy>
|
||||
/** «Сейчас» в мс (инъекция для детерминизма/тестов). */
|
||||
now?: () => number
|
||||
/** TTL кэша в мс (по умолчанию 24ч). */
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export interface SchemaPolicyCache {
|
||||
/** Политика из кэша (свежий fetch при истёкшем TTL; stale-кэш при офлайне). */
|
||||
getPolicy: () => Promise<CoopIdSchemaPolicy>
|
||||
/** Минимально поддерживаемая версия — для передачи в `verifyOffline`. */
|
||||
getMinSupportedVersion: () => Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Кэш политики версий схемы с TTL 24ч (Story 4.10, FR72). В пределах TTL отдаёт
|
||||
* закэшированную политику без сети. По истечении TTL пробует обновить; если fetch
|
||||
* упал (офлайн/недоступность) — отдаёт последнюю валидную политику (недоступность
|
||||
* сети НЕ роняет офлайн-проверку; короткий cert TTL ограничивает риск устаревания).
|
||||
* Нет кэша и fetch упал → пробрасывает ошибку (политику взять неоткуда).
|
||||
*/
|
||||
export function createSchemaPolicyCache(options: SchemaPolicyCacheOptions): SchemaPolicyCache {
|
||||
const now = options.now ?? (() => Date.now())
|
||||
const ttlMs = options.ttlMs ?? SCHEMA_POLICY_CACHE_TTL_MS
|
||||
let cached: { policy: CoopIdSchemaPolicy, fetchedAt: number } | null = null
|
||||
|
||||
const getPolicy = async (): Promise<CoopIdSchemaPolicy> => {
|
||||
if (cached && now() - cached.fetchedAt < ttlMs)
|
||||
return cached.policy
|
||||
try {
|
||||
const policy = await options.fetchPolicy()
|
||||
cached = { policy, fetchedAt: now() }
|
||||
return policy
|
||||
}
|
||||
catch (error) {
|
||||
if (cached)
|
||||
return cached.policy
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getPolicy,
|
||||
getMinSupportedVersion: async () => (await getPolicy()).min_supported_version,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { CoopChainLink } from '../certificate'
|
||||
/**
|
||||
* Цепочка доверия: офлайн-проверка participant_certificate walk'ом
|
||||
* `ano → voskhod → vostok → participant` от embedded trust anchor.
|
||||
* Реализация verify — Story 4.4; чтение ключей `cert` из COOPOS — здесь.
|
||||
*/
|
||||
import { APIClient } from '@wharfkit/antelope'
|
||||
import { base64url } from 'jose'
|
||||
import { compareSchemaVersions } from '../certificate/schema-policy'
|
||||
import { TRUST_ANCHOR_ANO_CERT_PUBKEY } from '../config/trust-anchor'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
/**
|
||||
* Публичный ключ permission `cert` аккаунта в COOPOS (Story 1.3).
|
||||
* Возвращает строку в формате Antelope (`PUB_K1_...`).
|
||||
*/
|
||||
export async function readCertPublicKey(rpcUrl: string, account: string): Promise<string> {
|
||||
const client = new APIClient({ url: rpcUrl })
|
||||
const acc = await client.v1.chain.get_account(account).catch((e: unknown) => {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.NetworkError,
|
||||
`COOPOS недоступен или аккаунт ${account} не найден: ${e instanceof Error ? e.message : String(e)}`,
|
||||
)
|
||||
})
|
||||
const cert = acc.permissions.find(p => String(p.perm_name) === 'cert')
|
||||
const key = cert?.required_auth.keys[0]?.key
|
||||
if (!cert || !key) {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.ChainVerificationFailed,
|
||||
`На аккаунте ${account} нет permission cert с ключом — цепочка доверия не настроена (Story 1.3).`,
|
||||
)
|
||||
}
|
||||
// MVP-инвариант: cert — строго single-key (multi-sig для ano — Growth).
|
||||
// Молча взять keys[0] у multi-sig значило бы проверять подпись против
|
||||
// одного из N ключей — это дыра в цепочке доверия.
|
||||
const auth = cert.required_auth
|
||||
if (Number(auth.threshold) !== 1 || auth.keys.length !== 1 || auth.accounts.length > 0 || auth.waits.length > 0) {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.ChainVerificationFailed,
|
||||
`Permission cert на ${account} не является single-key (threshold=${auth.threshold}, keys=${auth.keys.length}) — не поддерживается MVP-верификацией.`,
|
||||
)
|
||||
}
|
||||
return key.toString()
|
||||
}
|
||||
|
||||
export type VerifyOfflineReason =
|
||||
| 'malformed_certificate' // не compact JWS / нет обязательных claims (coop_chain, exp)
|
||||
| 'unsupported_alg' // alg ≠ ES256K
|
||||
| 'expired' // exp в прошлом относительно now
|
||||
| 'untrusted_anchor' // coop_chain не укоренён в известном trust-anchor `ano`
|
||||
| 'untrusted_issuer' // звено цепи (в т.ч. издатель) не совпало с доверенным кэшем ключей
|
||||
| 'signature_mismatch' // подпись не сходится с ключом издателя
|
||||
| 'unsupported_schema_version' // claim_schema_version старее min_supported_version политики
|
||||
|
||||
export interface VerifyOfflineResult {
|
||||
valid: boolean
|
||||
/** Причина отказа, если valid=false. Офлайн-отзыв (revoked) — вне MVP (Story 4.7). */
|
||||
reason?: VerifyOfflineReason
|
||||
/** Аккаунт-издатель (последнее звено coop_chain), под чьим ключом сошлась подпись. */
|
||||
issuer?: string
|
||||
}
|
||||
|
||||
export interface VerifyOfflineOptions {
|
||||
/**
|
||||
* Офлайн-снимок доверенных cert-ключей известных кооперативов
|
||||
* (`chain_manifests_cache`): `account → Antelope public_key`. Источник доверия —
|
||||
* каждое звено `coop_chain` сертификата сверяется с этим набором (закрывает
|
||||
* подделку «свой leaf-ключ + настоящий ano в root»). Без него издатель не
|
||||
* подтверждается → `untrusted_issuer` (fail-closed). Наполнение кэша
|
||||
* (manifest-sync) — отдельная задача.
|
||||
*/
|
||||
trustedKeys?: Record<string, string>
|
||||
/**
|
||||
* Доверенный якорь `ano.cert` (Antelope `PUB_K1_…`). По умолчанию —
|
||||
* `trustedKeys['ano']`, затем вшитый release-pinned `TRUST_ANCHOR_ANO_CERT_PUBKEY`.
|
||||
*/
|
||||
trustAnchor?: string
|
||||
/** «Сейчас» в мс для проверки exp (инъекция для детерминизма/тестов). */
|
||||
now?: number
|
||||
/**
|
||||
* Минимально поддерживаемая версия схемы claims (Story 4.10). Резолвится хостом
|
||||
* из кэша политики (`createSchemaPolicyCache().getMinSupportedVersion`, TTL 24ч).
|
||||
* Если задана и `cert.claim_schema_version` старее неё → `unsupported_schema_version`.
|
||||
* Не задана → ось версии схемы не гейтит (крипто/exp/цепь остаются fail-closed).
|
||||
*/
|
||||
minSchemaVersion?: string
|
||||
}
|
||||
|
||||
/** Порядок группы secp256k1 (n) и его половина — для low-S нормализации подписи. */
|
||||
const SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
|
||||
const SECP256K1_HALF_N = SECP256K1_N >> 1n
|
||||
|
||||
/**
|
||||
* Нормализовать S подписи R||S (64 байта) к нижней половине порядка кривой.
|
||||
* cert подписывает controller через jose+Node KeyObject, а Node-ECDSA может выдать
|
||||
* high-S; wharfkit/noble verify по умолчанию отвергает high-S (`lowS:true`).
|
||||
* Верификация secp256k1 инвариантна к `S ↔ n−S`, поэтому приведение к low-S делает
|
||||
* подпись принимаемой независимо от каноничности подписанта.
|
||||
*/
|
||||
function normalizeLowS(rs: Uint8Array): Uint8Array {
|
||||
let s = 0n
|
||||
for (const byte of rs.slice(32, 64))
|
||||
s = (s << 8n) | BigInt(byte)
|
||||
if (s <= SECP256K1_HALF_N)
|
||||
return rs
|
||||
let low = SECP256K1_N - s
|
||||
const out = new Uint8Array(rs) // копия R||S
|
||||
for (let i = 63; i >= 32; i--) {
|
||||
out[i] = Number(low & 0xFFn)
|
||||
low >>= 8n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface CertHead { alg?: string }
|
||||
interface CertPayload { coop_chain?: CoopChainLink[], exp?: number, claim_schema_version?: string }
|
||||
|
||||
/**
|
||||
* Офлайн-валидация удостоверения `participant_certificate` без обращения к сети
|
||||
* (Story 4.4; Vision: NFC-карты, бумажный QR). Проверяет структуру/alg/exp,
|
||||
* укоренение `coop_chain` в trust-anchor `ano`, принадлежность звеньев доверенному
|
||||
* кэшу ключей и подпись против ключа издателя (последнее звено `coop_chain`).
|
||||
*
|
||||
* Полностью офлайн и fail-closed: без доверенного якоря/кэша вердикт всегда
|
||||
* `valid:false`. Отзыв ключа офлайн не проверяется (вне MVP — Story 4.7, Growth
|
||||
* FR65); компенсируется коротким TTL (Story 4.6).
|
||||
*/
|
||||
export async function verifyOffline(certificate: string, options: VerifyOfflineOptions = {}): Promise<VerifyOfflineResult> {
|
||||
const parts = certificate.split('.')
|
||||
if (parts.length !== 3)
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
const [h, p, s] = parts
|
||||
|
||||
let head: CertHead
|
||||
let payload: CertPayload
|
||||
try {
|
||||
head = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
|
||||
payload = JSON.parse(new TextDecoder().decode(base64url.decode(p)))
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
}
|
||||
if (head.alg !== 'ES256K')
|
||||
return { valid: false, reason: 'unsupported_alg' }
|
||||
|
||||
const chain = payload.coop_chain
|
||||
if (!Array.isArray(chain) || chain.length === 0 || typeof payload.exp !== 'number')
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
|
||||
const now = options.now ?? Date.now()
|
||||
if (now >= payload.exp * 1000)
|
||||
return { valid: false, reason: 'expired' }
|
||||
|
||||
// Версия схемы (Story 4.10): если хост передал минимально поддерживаемую версию
|
||||
// (из кэша политики, TTL 24ч), отвергаем сертификаты старее неё — устаревшая схема
|
||||
// claims не должна проходить как валидная. Без minSchemaVersion ось не гейтит.
|
||||
if (options.minSchemaVersion !== undefined
|
||||
&& compareSchemaVersions(payload.claim_schema_version ?? '0', options.minSchemaVersion) < 0) {
|
||||
return { valid: false, reason: 'unsupported_schema_version' }
|
||||
}
|
||||
|
||||
// Якорь: цепь обязана начинаться с известного `ano`.
|
||||
const root = chain[0]
|
||||
const anchor = options.trustAnchor ?? options.trustedKeys?.ano ?? TRUST_ANCHOR_ANO_CERT_PUBKEY
|
||||
if (!anchor || root.account !== 'ano' || root.public_key !== anchor)
|
||||
return { valid: false, reason: 'untrusted_anchor' }
|
||||
|
||||
// Звенья: при наличии кэша каждое звено должно совпасть с доверенным ключом.
|
||||
if (options.trustedKeys) {
|
||||
for (const link of chain) {
|
||||
if (options.trustedKeys[link.account] !== link.public_key)
|
||||
return { valid: false, reason: 'untrusted_issuer' }
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Без кэша подтверждён только якорь; издателя доверять нельзя — fail-closed.
|
||||
return { valid: false, reason: 'untrusted_issuer' }
|
||||
}
|
||||
|
||||
// Подпись: против ключа издателя (последнее звено coop_chain, оно же `kid`).
|
||||
const issuer = chain[chain.length - 1]
|
||||
let rs: Uint8Array
|
||||
try {
|
||||
rs = base64url.decode(s)
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
}
|
||||
if (rs.length !== 64)
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
|
||||
const { PublicKey, Signature } = await import('@wharfkit/antelope')
|
||||
const normalized = normalizeLowS(rs)
|
||||
const signingInput = new TextEncoder().encode(`${h}.${p}`)
|
||||
let ok = false
|
||||
try {
|
||||
const sig = Signature.from({ type: 'K1', r: normalized.slice(0, 32), s: normalized.slice(32, 64), recid: 0 })
|
||||
ok = sig.verifyMessage(signingInput, PublicKey.from(issuer.public_key))
|
||||
}
|
||||
catch {
|
||||
ok = false // некорректный ключ/подпись в сертификате — не валим исключением
|
||||
}
|
||||
if (!ok)
|
||||
return { valid: false, reason: 'signature_mismatch' }
|
||||
|
||||
return { valid: true, issuer: issuer.account }
|
||||
}
|
||||
|
||||
/**
|
||||
* Окно активности одного публичного ключа подписанта в истории его аккаунта.
|
||||
* Блоки, не время: смена authority в COOPOS привязана к блоку.
|
||||
*/
|
||||
export interface KeyValidityWindow {
|
||||
/** публичный ключ в формате Antelope (`PUB_K1_...`) */
|
||||
public_key: string
|
||||
/** блок, с которого ключ стал активным (включительно) */
|
||||
valid_from_block: number
|
||||
/** блок, по который ключ был активным (включительно); null = активен сейчас */
|
||||
valid_to_block: number | null
|
||||
}
|
||||
|
||||
export type VerifyDocumentReason =
|
||||
| 'malformed_jws'
|
||||
| 'unsupported_alg'
|
||||
| 'signature_mismatch' // ни один исторический ключ не даёт валидную подпись (подделка/чужой)
|
||||
| 'key_not_active_at_signing' // подпись валидна под ключом, но он НЕ был активен на signedAtBlock
|
||||
|
||||
export interface VerifyDocumentParams {
|
||||
/** compact JWS из `signDocument` (Story 2.3) */
|
||||
jws: string
|
||||
/**
|
||||
* Блок, на который проверяется активность ключа — `last_irreversible_block_num`
|
||||
* на момент `iat` подписи. Известен держателю документа (метаданные подписи).
|
||||
*/
|
||||
signedAtBlock: number
|
||||
/**
|
||||
* Историческая хронология ключей подписанта (offline-снимок из
|
||||
* `chain_manifests_cache`). Наполнение кэша (ротация ключей / manifest-sync) —
|
||||
* отдельная задача; здесь — чистая проверка по уже полученной хронологии.
|
||||
*/
|
||||
authority: KeyValidityWindow[]
|
||||
}
|
||||
|
||||
export interface VerifyDocumentResult {
|
||||
valid: boolean
|
||||
reason?: VerifyDocumentReason
|
||||
/** ключ, под которым подпись подтвердилась (если valid) */
|
||||
matched_key?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-валидность подписи документа через chain-walk (Story 2.5). Подпись,
|
||||
* сделанная ключом пайщика ДО ротации, остаётся валидной: проверяем, что ключ был
|
||||
* активен в момент подписи (`signedAtBlock` ∈ окно ключа), а не «активен сейчас».
|
||||
* Так ротация ключа не аннулирует юридически значимые документы.
|
||||
*
|
||||
* Полностью офлайн: работает по переданной хронологии `authority` (кэш манифеста),
|
||||
* без сети. Верификация — `verifyMessage` против ключей-кандидатов (а НЕ recover:
|
||||
* `signDocument` отбрасывает recovery-байт, оставляя JWS-формат R||S).
|
||||
*/
|
||||
export async function verifyDocumentOffline(params: VerifyDocumentParams): Promise<VerifyDocumentResult> {
|
||||
const parts = params.jws.split('.')
|
||||
if (parts.length !== 3)
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
const [h, p, s] = parts
|
||||
|
||||
let header: { alg?: string }
|
||||
try {
|
||||
header = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
}
|
||||
if (header.alg !== 'ES256K')
|
||||
return { valid: false, reason: 'unsupported_alg' }
|
||||
|
||||
let rs: Uint8Array
|
||||
try {
|
||||
rs = base64url.decode(s)
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
}
|
||||
if (rs.length !== 64)
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
|
||||
const { PublicKey, Signature } = await import('@wharfkit/antelope')
|
||||
const signingInput = new TextEncoder().encode(`${h}.${p}`)
|
||||
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
|
||||
|
||||
let signedUnderRotatedKey = false
|
||||
for (const w of params.authority) {
|
||||
let ok = false
|
||||
try {
|
||||
ok = sig.verifyMessage(signingInput, PublicKey.from(w.public_key))
|
||||
}
|
||||
catch {
|
||||
ok = false // некорректный ключ в хронологии — пропускаем, не валим всю проверку
|
||||
}
|
||||
if (!ok)
|
||||
continue
|
||||
const active = params.signedAtBlock >= w.valid_from_block
|
||||
&& (w.valid_to_block === null || params.signedAtBlock <= w.valid_to_block)
|
||||
if (active)
|
||||
return { valid: true, matched_key: w.public_key }
|
||||
// подпись валидна, но этот ключ не был активен в момент подписи — продолжаем
|
||||
// искать (вдруг другой ключ окна подходит); запоминаем для точной причины.
|
||||
signedUnderRotatedKey = true
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
reason: signedUnderRotatedKey ? 'key_not_active_at_signing' : 'signature_mismatch',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Публичные RPC-ноды COOPOS — fallback для чтения ключей/manifest'ов,
|
||||
* когда нода кооператива недоступна (используется начиная со Story 9.5).
|
||||
*
|
||||
* TODO(Story 9.5): наполнить актуальным списком публичных нод сети.
|
||||
*/
|
||||
export const COOPOS_PUBLIC_NODES: readonly string[] = []
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Embedded trust anchor цепочки доверия: публичный ключ `ano.cert` в
|
||||
* Antelope-формате (`PUB_K1_…`, кривая secp256k1). Якорь вшивается в пакет
|
||||
* (release-pinned), чтобы `verifyOffline()` укоренял `coop_chain` без сети.
|
||||
*
|
||||
* Формат — Antelope-строка, а не JWK: вся цепь (`coop_chain`, `readCertPublicKey`,
|
||||
* wharfkit-верификация) оперирует `PUB_K1_…`; JWK был несравним со звеньями
|
||||
* (реконсиляция скелета 1.2 ↔ реальности, Story 4.4).
|
||||
*
|
||||
* TODO(release): подставить реальный ключ после создания permission `cert` на
|
||||
* аккаунте `ano` в COOPOS (hash-pinned bundle в consuming-приложениях, NFR).
|
||||
*/
|
||||
export const TRUST_ANCHOR_ANO_CERT_PUBKEY: string | null = null
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Зеркало enum'а ошибок auth-v2 контроллера (источник истины — controller,
|
||||
* появится в Story 1.11). Расширяется синхронно с серверной стороной.
|
||||
*/
|
||||
export enum AuthV2ErrorCode {
|
||||
NotImplemented = 'not_implemented',
|
||||
InvalidCredentials = 'invalid_credentials',
|
||||
WeakPassword = 'weak_password',
|
||||
SessionBindingReused = 'session_binding_reused',
|
||||
SessionBindingExpired = 'session_binding_expired',
|
||||
TimestampTooOld = 'timestamp_too_old',
|
||||
VaultDecryptionFailed = 'vault_decryption_failed',
|
||||
CertificateExpired = 'certificate_expired',
|
||||
CertificateRevoked = 'certificate_revoked',
|
||||
ChainVerificationFailed = 'chain_verification_failed',
|
||||
CooposDegraded = 'coopos_degraded',
|
||||
TooManyAttempts = 'too_many_attempts',
|
||||
TooManyRecoveryAttempts = 'too_many_recovery_attempts',
|
||||
InvalidTwoFactorCode = 'invalid_2fa_code',
|
||||
TwoFactorNotEnrolled = 'two_factor_not_enrolled',
|
||||
InvalidRecoveryToken = 'invalid_recovery_token',
|
||||
InvalidOfflineCode = 'invalid_offline_code',
|
||||
InsufficientVerification = 'insufficient_verification',
|
||||
NetworkError = 'network_error',
|
||||
WalletLocked = 'wallet_locked',
|
||||
ClientWalletMismatch = 'client_wallet_mismatch',
|
||||
/** Клиентский код: high-stakes-действие (экспорт удостоверения с PII) не подтверждено. */
|
||||
ConsentRequired = 'consent_required',
|
||||
}
|
||||
|
||||
/**
|
||||
* Ошибки SDK в формате OAuth 2.0 ({ error, error_description }): код — машинный,
|
||||
* description — человеко-читаемое сообщение для UI.
|
||||
*/
|
||||
export class AuthV2Error extends Error {
|
||||
readonly code: AuthV2ErrorCode
|
||||
|
||||
constructor(code: AuthV2ErrorCode, description: string) {
|
||||
super(description)
|
||||
this.name = 'AuthV2Error'
|
||||
this.code = code
|
||||
}
|
||||
|
||||
toJSON(): { error: AuthV2ErrorCode, error_description: string } {
|
||||
return { error: this.code, error_description: this.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Внутренний помощник для каркаса: единообразный отказ нереализованных методов. */
|
||||
export function notImplemented(method: string): never {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.NotImplemented,
|
||||
`Метод ${method}() ещё не реализован: скелет SDK (Story 1.2), реализация приходит историями Эпиков 1–2.`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекомендованное действие для UI при ошибке — определяет, какую кнопку/подсказку
|
||||
* показать пайщику (Story 1.11).
|
||||
*/
|
||||
export type AuthV2ErrorAction =
|
||||
| 'retry' // повторить ввод/вход
|
||||
| 'recover' // предложить восстановление доступа («Восстановить доступ»)
|
||||
| 'check_connection' // проверить интернет
|
||||
| 'contact_support' // обратиться в кооператив
|
||||
| 'none'
|
||||
|
||||
/** Описание ошибки для отображения: сообщение + действие + судьба сессии. */
|
||||
export interface AuthV2ErrorView {
|
||||
/** машинный код (null — неожиданная, не-типизированная ошибка) */
|
||||
code: AuthV2ErrorCode | null
|
||||
/** человеко-читаемое сообщение для UI */
|
||||
message: string
|
||||
/** что предложить пайщику */
|
||||
action: AuthV2ErrorAction
|
||||
/**
|
||||
* Сохранять ли текущую сессию. `true` — НЕ разлогинивать (сбой временный/внешний:
|
||||
* нет связи, кооператив недоступен, кошелёк заперт) — NFR20/FR50: оффлайн не
|
||||
* выкидывает пайщика. `false` — сбой требует повторного входа.
|
||||
*/
|
||||
keepSession: boolean
|
||||
}
|
||||
|
||||
type AuthV2ErrorViewBody = Omit<AuthV2ErrorView, 'code'>
|
||||
|
||||
/**
|
||||
* Каталог человеко-читаемых сообщений по кодам ошибок (Story 1.11). Источник
|
||||
* UI-текстов — здесь; контроллер отдаёт только машинный `error`, клиент по нему
|
||||
* выбирает сообщение и actionable-подсказку. Тексты — по-русски, без раскрытия
|
||||
* технических деталей (например, какое именно поле логина неверно — security).
|
||||
*/
|
||||
export const AUTH_V2_ERROR_VIEWS: Record<AuthV2ErrorCode, AuthV2ErrorViewBody> = {
|
||||
[AuthV2ErrorCode.NotImplemented]: {
|
||||
message: 'Функция пока недоступна.',
|
||||
action: 'none',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidCredentials]: {
|
||||
message: 'Неверный email или пароль.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.WeakPassword]: {
|
||||
message: 'Пароль слишком простой. Используйте не менее 12 символов и не повторяйте типичные сочетания.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.SessionBindingReused]: {
|
||||
message: 'Сессия входа уже использована. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.SessionBindingExpired]: {
|
||||
message: 'Время на подтверждение входа истекло. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.TimestampTooOld]: {
|
||||
message: 'Истекло время на подтверждение входа. Повторите попытку.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.VaultDecryptionFailed]: {
|
||||
message: 'Не удалось расшифровать кошелёк. Попробуйте восстановить доступ.',
|
||||
action: 'recover',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CertificateExpired]: {
|
||||
message: 'Срок действия удостоверения истёк. Войдите заново, чтобы обновить его.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CertificateRevoked]: {
|
||||
message: 'Удостоверение отозвано. Обратитесь в кооператив.',
|
||||
action: 'contact_support',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.ChainVerificationFailed]: {
|
||||
message: 'Не удалось подтвердить подпись. Обратитесь в поддержку кооператива.',
|
||||
action: 'contact_support',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CooposDegraded]: {
|
||||
message: 'Кооператив временно недоступен. Повторите попытку позже.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.TooManyAttempts]: {
|
||||
message: 'Слишком много попыток. Подождите немного и попробуйте снова.',
|
||||
action: 'retry',
|
||||
// временный троттлинг — не разлогиниваем пайщика, просто просим подождать.
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.TooManyRecoveryAttempts]: {
|
||||
message: 'Слишком много запросов на восстановление. Подождите и попробуйте позже.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidTwoFactorCode]: {
|
||||
message: 'Неверный код из приложения-аутентификатора. Проверьте код и попробуйте снова.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.TwoFactorNotEnrolled]: {
|
||||
message: 'Второй фактор не подключён.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidRecoveryToken]: {
|
||||
message: 'Ссылка восстановления недействительна или истекла. Запросите восстановление заново.',
|
||||
action: 'recover',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidOfflineCode]: {
|
||||
message: 'Код восстановления неверен или уже использован.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.InsufficientVerification]: {
|
||||
message: 'Недостаточный уровень верификации для этого действия. Обратитесь в кооператив.',
|
||||
action: 'contact_support',
|
||||
// авторизационное ограничение по уровню доверия — сессия валидна, не разлогиниваем.
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.NetworkError]: {
|
||||
message: 'Нет связи с кооперативом. Проверьте интернет.',
|
||||
action: 'check_connection',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.WalletLocked]: {
|
||||
message: 'Кошелёк заблокирован. Введите пароль для доступа к ключу.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.ClientWalletMismatch]: {
|
||||
message: 'Ключ в этом браузере не соответствует аккаунту. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.ConsentRequired]: {
|
||||
message: 'Для экспорта удостоверения нужно подтверждение.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** Безопасный фолбэк для неожиданной (не-AuthV2) ошибки — без утечки технических деталей. */
|
||||
const GENERIC_ERROR_VIEW: AuthV2ErrorViewBody = {
|
||||
message: 'Не удалось выполнить операцию. Попробуйте ещё раз.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Превратить любую брошенную SDK ошибку в готовое к показу описание (Story 1.11):
|
||||
* человеко-читаемое сообщение, рекомендованное действие и флаг сохранения сессии.
|
||||
* Не-типизированные ошибки сводятся к безопасному фолбэку (не показываем `e.message`).
|
||||
*/
|
||||
export function describeAuthV2Error(error: unknown): AuthV2ErrorView {
|
||||
if (error instanceof AuthV2Error)
|
||||
return { code: error.code, ...AUTH_V2_ERROR_VIEWS[error.code] }
|
||||
return { code: null, ...GENERIC_ERROR_VIEW }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Экспорт удостоверения наружу (QR для Vision/предъявления). В MVP существует
|
||||
* только ПОЛНАЯ форма (`full_certificate`) — она несёт PII пайщика, поэтому
|
||||
* огорожена двумя барьерами (разблокированный vault + явное согласие).
|
||||
*
|
||||
* ROADMAP (Growth): анонимная форма `proof_of_membership` — доказательство членства
|
||||
* БЕЗ PII, с коротким сроком (`exp ≤ 24ч`). Она появится отдельным методом
|
||||
* `exportProofQR()`. В MVP его НЕТ намеренно: пока anonymous-формат не существует,
|
||||
* вызвать `exportProofQR()` нельзя (ошибка компиляции) — чтобы по ошибке не выпустить
|
||||
* PII-полный сертификат под видом anonymous.
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { isWalletUnlocked } from '../wallet'
|
||||
|
||||
export interface ExportFullQROptions {
|
||||
/**
|
||||
* Колбэк high-stakes-consent диалога: хост показывает пайщику явное подтверждение
|
||||
* («вы экспортируете удостоверение с персональными данными») и резолвит `true`,
|
||||
* только если пайщик согласился. `false`/reject → экспорт отклоняется.
|
||||
*/
|
||||
confirm: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспорт ПОЛНОГО participant_certificate (с PII) для предъявления/QR. Доступен
|
||||
* только когда (1) vault разблокирован — экспортирует владелец активной сессии, не
|
||||
* случайный держатель запертого устройства; (2) пайщик явно подтвердил high-stakes
|
||||
* consent. Возвращает payload-байты удостоверения; кодирование в визуальный QR —
|
||||
* на стороне хоста/Vision (SDK кросс-рантайм, QR-рендерер не тянет).
|
||||
*/
|
||||
export async function exportFullQR(certificate: string, options: ExportFullQROptions): Promise<Uint8Array> {
|
||||
if (!isWalletUnlocked())
|
||||
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Экспорт удостоверения возможен только при разблокированном кошельке')
|
||||
|
||||
const consented = await options.confirm().catch(() => false)
|
||||
if (!consented)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ConsentRequired, 'Экспорт удостоверения с персональными данными требует явного подтверждения')
|
||||
|
||||
return new TextEncoder().encode(certificate)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type { CertificateRenewalHandle, CertificateStatus, CoopChainLink, CoopIdSchemaPolicy, ParticipantCertificateClaims, ScheduleCertificateRenewalOptions, SchemaPolicyCache, SchemaPolicyCacheOptions, VerificationTypeClaim } from './certificate'
|
||||
export {
|
||||
CERTIFICATE_EXPIRING_WINDOW_MS,
|
||||
CERTIFICATE_RENEWAL_LEAD_MS,
|
||||
certificateStatus,
|
||||
compareSchemaVersions,
|
||||
computeRenewalDelayMs,
|
||||
createSchemaPolicyCache,
|
||||
decodeParticipantCertificate,
|
||||
isSchemaVersionSupported,
|
||||
scheduleCertificateRenewal,
|
||||
SCHEMA_POLICY_CACHE_TTL_MS,
|
||||
SCHEMA_POLICY_WELL_KNOWN_PATH,
|
||||
VERIFICATION_TYPE_LABELS,
|
||||
verificationTypeLabel,
|
||||
} from './certificate'
|
||||
|
||||
export type { KeyValidityWindow, VerifyDocumentParams, VerifyDocumentReason, VerifyDocumentResult, VerifyOfflineOptions, VerifyOfflineReason, VerifyOfflineResult } from './chain'
|
||||
|
||||
export { readCertPublicKey, verifyDocumentOffline, verifyOffline } from './chain'
|
||||
/**
|
||||
* @coopenomics/auth — SDK аутентификации CoopID.
|
||||
*
|
||||
* Публичная поверхность зафиксирована скелетом (Story 1.2): все методы
|
||||
* типизированы и бросают AuthV2Error(not_implemented) до своей реализации.
|
||||
*/
|
||||
export { COOPOS_PUBLIC_NODES } from './config/coopos-public-nodes'
|
||||
|
||||
export { TRUST_ANCHOR_ANO_CERT_PUBKEY } from './config/trust-anchor'
|
||||
export { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from './errors'
|
||||
export type { AuthV2ErrorAction, AuthV2ErrorView } from './errors'
|
||||
|
||||
export type { ExportFullQROptions } from './export'
|
||||
export { exportFullQR } from './export'
|
||||
export type { MigrateParams } from './migration'
|
||||
|
||||
export { canonicalMigrationMessage, migrate } from './migration'
|
||||
export type { FlowExecutorParams, HandshakeResult, LoginParams, LoginResult, LogoutParams, SessionTokens } from './oidc'
|
||||
|
||||
export { authenticateWithFlowExecutor, configureCoopId, configureOidc, configureTokenStorage, currentTokens, DEFAULT_AUTHENTICATION_FLOW, getAccessToken, getParticipantCertificate, login, loginWithMagicLink, logout, performTimestampHandshake, recover, restoreSession } from './oidc'
|
||||
export type { SignDocumentParams, TimestampSignature } from './signing'
|
||||
|
||||
export { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from './signing'
|
||||
export type { EncryptedVaultBlob, VaultSubject } from './vault'
|
||||
|
||||
export { ARGON2ID_PARAMS, decryptPrivateKey, deriveKey, encryptPrivateKey } from './vault'
|
||||
export type { StorageAdapter } from './wallet'
|
||||
|
||||
export { clearLocalVault, clearPinCache, DEFAULT_PIN, fetchVaultBlob, getWallet, hasPinCache, isWalletUnlocked, loadLocalVault, lockWallet, persistPinCache, rotateKey, saveLocalVault, saveToVault, storeVaultBlob, unlockWallet, unlockWithPin, Wallet } from './wallet'
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Миграция действующего пайщика «ключ → пароль» (Story 11.4), клиентская сторона.
|
||||
*
|
||||
* Пайщик сейчас владеет только WIF (легаси, без пароля). `migrate` доказывает
|
||||
* владение ключом подписью метки времени (как легаси `login`), сервер проверяет
|
||||
* её против он-чейн active-permission и ставит пароль в authentik (Story 11.1).
|
||||
* Затем клиент шифрует ТОТ ЖЕ WIF новым паролём в vault (Story 11.3) — приватный
|
||||
* ключ на сервер не уходит. Идемпотентно (повтор с тем же ключом/паролём безопасен).
|
||||
*
|
||||
* Подпись биндится к новому паролю через `pw_hash` в каноническом сообщении:
|
||||
* перехваченный proof нельзя переиспользовать с другим паролём.
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { coopIdApiUrl } from '../oidc/client'
|
||||
import { saveToVault, type StorageAdapter } from '../wallet'
|
||||
|
||||
export interface MigrateParams {
|
||||
email: string
|
||||
/** WIF, которым пайщик владеет сейчас (легаси-ключ из поля входа). */
|
||||
privateKey: string
|
||||
newPassword: string
|
||||
/** Если задано — сохранить локальную копию зашифрованного vault'а на устройстве. */
|
||||
storage?: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Каноническое сообщение proof'а миграции — **зеркало** серверного
|
||||
* `canonicalMigrationMessage` (controller). Ключи в фиксированном алфавитном
|
||||
* порядке: клиент и сервер собирают строку байт-в-байт, иначе `recoverPublicKey`
|
||||
* на сервере восстановит чужой ключ. `pw_hash` — sha256-hex нового пароля.
|
||||
*/
|
||||
export function canonicalMigrationMessage(payload: { ts: string, pw_hash: string }): string {
|
||||
return JSON.stringify({ pw_hash: payload.pw_hash, purpose: 'coopid-key-migration', ts: payload.ts })
|
||||
}
|
||||
|
||||
/** sha256-hex (lowercase) — байт-в-байт совпадает с серверным createHash('sha256').digest('hex'). */
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
|
||||
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Разобрать OAuth2-ошибку контроллера ({ error, error_description }) в AuthV2Error. */
|
||||
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
|
||||
return new AuthV2Error((body?.error as AuthV2ErrorCode) ?? fallback, body?.error_description ?? fallbackMsg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполнить миграцию «ключ → пароль». Возвращает `{ username }` (резолвится
|
||||
* сервером по email; нужен как subject vault'а). Бросает `AuthV2Error`:
|
||||
* InvalidCredentials (неверный ключ/email/подпись), WeakPassword, TimestampTooOld,
|
||||
* CooposDegraded, NetworkError.
|
||||
*/
|
||||
export async function migrate(params: MigrateParams): Promise<{ username: string }> {
|
||||
const apiUrl = coopIdApiUrl()
|
||||
const ts = new Date().toISOString()
|
||||
const message = canonicalMigrationMessage({ ts, pw_hash: await sha256Hex(params.newPassword) })
|
||||
|
||||
// Подпись текущим ключом (recoverable SIG_K1_). Невалидный WIF → понятная ошибка.
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
let signature: string
|
||||
try {
|
||||
signature = PrivateKey.from(params.privateKey).signMessage(new TextEncoder().encode(message)).toString()
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Некорректный приватный ключ')
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl}/coop/migration`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: params.email, timestamp: ts, signature, new_password: params.newPassword }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при миграции: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw await authErrorFromResponse(res, AuthV2ErrorCode.InvalidCredentials, `Миграция отклонена (HTTP ${res.status})`)
|
||||
|
||||
const body = (await res.json()) as { username?: string }
|
||||
if (!body?.username)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Миграция не вернула username')
|
||||
|
||||
// Зашифровать текущий WIF новым паролём → server vault (обязательно) + локальная копия.
|
||||
await saveToVault({ apiUrl, account: body.username, privateKey: params.privateKey, password: params.newPassword, storage: params.storage })
|
||||
return { username: body.username }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Первый этап входа CoopID (Story 11.2) — password через authentik:
|
||||
* (1) встроенная форма гонит email+password в flow-executor authentik и
|
||||
* устанавливает сессию (`flow-executor.ts`); (2) `oidc-client-ts` выполняет
|
||||
* `authorization_code`+PKCE МОЛЧА (`signinSilent`, prompt=none) — сессия уже есть,
|
||||
* второго ввода пароля нет. Так клиент видит пароль (нужно для vault, Story 11.3),
|
||||
* не нарушая FR29: запрещённый `grant_type=password` не используется, грант —
|
||||
* стандартный authorization_code+PKCE (Implicit и ROPC запрещены, RFC 9700).
|
||||
*
|
||||
* Конфигурация (client_id/redirect_uri/scope, база controller'а) задаётся
|
||||
* приложением на старте через `configureOidc`/`configureCoopId` — это среда-зависимые
|
||||
* параметры (frontend кооператива), не зашитые в SDK.
|
||||
*/
|
||||
import { type User, UserManager, type UserManagerSettings } from 'oidc-client-ts'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { authenticateWithFlowExecutor } from './flow-executor'
|
||||
|
||||
interface OidcClientConfig {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
scope: string
|
||||
postLogoutRedirectUri?: string
|
||||
/** redirect_uri скрытого silent-iframe для prompt=none authorize (Story 11.2). */
|
||||
silentRedirectUri?: string
|
||||
}
|
||||
|
||||
let oidcConfig: OidcClientConfig | null = null
|
||||
let coopApiUrl: string | null = null
|
||||
|
||||
/** Конфигурация OIDC-клиента authentik (вызывается приложением на старте). */
|
||||
export function configureOidc(config: { clientId: string, redirectUri: string, scope?: string, postLogoutRedirectUri?: string, silentRedirectUri?: string }): void {
|
||||
oidcConfig = {
|
||||
clientId: config.clientId,
|
||||
redirectUri: config.redirectUri,
|
||||
scope: config.scope ?? 'openid profile',
|
||||
postLogoutRedirectUri: config.postLogoutRedirectUri,
|
||||
silentRedirectUri: config.silentRedirectUri,
|
||||
}
|
||||
}
|
||||
|
||||
/** База URL controller'а кооператива (для второго этапа: bind/verify/refresh). */
|
||||
export function configureCoopId(config: { apiUrl: string }): void {
|
||||
coopApiUrl = config.apiUrl.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/** База controller'а или явная ошибка конфигурации. */
|
||||
export function coopIdApiUrl(): string {
|
||||
if (!coopApiUrl)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, 'CoopID не сконфигурирован: вызовите configureCoopId({ apiUrl }) на старте приложения')
|
||||
return coopApiUrl
|
||||
}
|
||||
|
||||
function userManager(issuer: string): UserManager {
|
||||
if (!oidcConfig)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'OIDC не сконфигурирован: вызовите configureOidc({ clientId, redirectUri }) на старте приложения')
|
||||
const settings: UserManagerSettings = {
|
||||
authority: issuer.replace(/\/$/, ''),
|
||||
client_id: oidcConfig.clientId,
|
||||
redirect_uri: oidcConfig.redirectUri,
|
||||
silent_redirect_uri: oidcConfig.silentRedirectUri ?? oidcConfig.redirectUri,
|
||||
scope: oidcConfig.scope,
|
||||
post_logout_redirect_uri: oidcConfig.postLogoutRedirectUri,
|
||||
response_type: 'code', // Authorization Code + PKCE; oidc-client-ts включает PKCE по умолчанию.
|
||||
}
|
||||
return new UserManager(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проводит первый этап входа и возвращает OIDC-User (id_token/access_token +
|
||||
* установленная сессионная cookie authentik, нужная для bind). Story 11.2:
|
||||
* (1) встроенная форма → flow-executor authentik устанавливает сессию (фактор 1);
|
||||
* (2) `signinSilent` выполняет authorization_code+PKCE молча (сессия уже есть,
|
||||
* prompt=none через скрытый iframe `silent_redirect_uri`) — без попапа и без
|
||||
* повторного ввода пароля.
|
||||
*/
|
||||
export async function authenticateWithAuthentik(params: { issuer: string, email: string, password: string, flowSlug?: string }): Promise<User> {
|
||||
// Фактор-1: учётные данные уходят в authentik (а не в наш backend) — ROPC не используется.
|
||||
await authenticateWithFlowExecutor({ issuer: params.issuer, email: params.email, password: params.password, flowSlug: params.flowSlug })
|
||||
// Грант FR29: code+PKCE поверх уже установленной сессии, без интерактивного UI.
|
||||
const um = userManager(params.issuer)
|
||||
let user: User | null
|
||||
try {
|
||||
user = await um.signinSilent()
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, `Не удалось завершить вход через authentik: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!user)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'authentik не вернул сессию после ввода пароля')
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Story 11.2 — встроенный фактор-1 входа CoopID через flow-executor authentik.
|
||||
*
|
||||
* Зачем НЕ popup/redirect: desktop рисует СВОЮ форму email+password и гонит
|
||||
* учётные данные в интерактивный flow-API authentik. Так клиент пайщика ВИДИТ
|
||||
* пароль — это нужно, чтобы шифровать им password-vault (Story 11.3); требование
|
||||
* вынуждено zero-knowledge-инвариантом vault'а (сервер хранит шифр и расшифровать
|
||||
* не может → ключ шифрования обязан быть клиентским секретом, единственный такой
|
||||
* секрет — пароль). FR29 при этом соблюдается: запрещённый `grant_type=password`
|
||||
* НЕ используется — грант остаётся `authorization_code`+PKCE и выполняется в
|
||||
* `client.ts` уже ПОСЛЕ установки сессии (см. `authenticateWithAuthentik`).
|
||||
*
|
||||
* Топология (Эпик 5, Caddyfile): flow-API доступен same-origin по
|
||||
* `{issuer.origin}/api/v3/flows/executor/<slug>/` — Caddy catch-all проксирует всё
|
||||
* на authentik под доменом кооператива. `credentials:'include'` доставляет и
|
||||
* устанавливает сессионную cookie authentik; CSRF-cookie (`authentik_csrf`) эхо-
|
||||
* отправляется в заголовке `X-authentik-CSRF` (authentik требует его на unsafe-
|
||||
* методах API). Same-origin критичен: кросс-домен CSRF-cookie не прочитать, а
|
||||
* сессионная cookie не доедет до последующего authorize.
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
/** Slug стандартного flow аутентификации authentik (identification → password). */
|
||||
export const DEFAULT_AUTHENTICATION_FLOW = 'default-authentication-flow'
|
||||
|
||||
/** Предохранитель от зацикливания на неожиданной последовательности стадий. */
|
||||
const MAX_FLOW_STEPS = 8
|
||||
|
||||
export interface FlowExecutorParams {
|
||||
/** Issuer кооператива (`https://coop.example/application/o/coopid/`); origin — база flow-API. */
|
||||
issuer: string
|
||||
email: string
|
||||
password: string
|
||||
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
|
||||
flowSlug?: string
|
||||
}
|
||||
|
||||
/** Снимок challenge'а flow-executor (берём только нужные поля; authentik отдаёт больше). */
|
||||
interface FlowChallenge {
|
||||
/** `native` | `redirect` | `shell` — `redirect` означает завершение flow (сессия установлена). */
|
||||
type?: string
|
||||
/** Web-компонент текущей стадии: `ak-stage-identification`, `ak-stage-password`, … */
|
||||
component?: string
|
||||
/** Пер-полевые ошибки валидации (неверный пароль → перерисованная стадия с этим полем). */
|
||||
response_errors?: Record<string, Array<{ string?: string, code?: string }>>
|
||||
}
|
||||
|
||||
/** Текущий CSRF-токен authentik из cookie (same-origin). В Node/тестах — пусто. */
|
||||
function csrfHeader(): Record<string, string> {
|
||||
if (typeof document === 'undefined' || !document.cookie)
|
||||
return {}
|
||||
const match = document.cookie.match(/(?:^|;\s*)authentik_csrf=([^;]+)/)
|
||||
return match ? { 'X-authentik-CSRF': decodeURIComponent(match[1]) } : {}
|
||||
}
|
||||
|
||||
/** URL шага flow-executor. `query` пуст: flow запускается напрямую (authorize делает client.ts). */
|
||||
function flowUrl(base: string, slug: string): string {
|
||||
return `${base}/api/v3/flows/executor/${encodeURIComponent(slug)}/?query=${encodeURIComponent('')}`
|
||||
}
|
||||
|
||||
/** Завершение flow: сессия authentik установлена (redirect-терминал или стадия user-login). */
|
||||
function isSuccess(c: FlowChallenge): boolean {
|
||||
return c.type === 'redirect' || c.component === 'xak-flow-redirect' || c.component === 'ak-stage-user-login'
|
||||
}
|
||||
|
||||
/** Доступ отклонён политикой flow (например, аккаунт неактивен). */
|
||||
function isAccessDenied(c: FlowChallenge): boolean {
|
||||
return c.component === 'ak-stage-access-denied'
|
||||
}
|
||||
|
||||
/** Первое человеко-читаемое сообщение об ошибке валидации из challenge'а (если есть). */
|
||||
function firstResponseError(c: FlowChallenge): string | null {
|
||||
const groups = c.response_errors
|
||||
if (!groups)
|
||||
return null
|
||||
for (const key of Object.keys(groups)) {
|
||||
const msg = groups[key]?.find(e => e.string)?.string
|
||||
if (msg)
|
||||
return msg
|
||||
}
|
||||
return Object.keys(groups).length > 0 ? '' : null
|
||||
}
|
||||
|
||||
async function getChallenge(url: string): Promise<FlowChallenge> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, { method: 'GET', credentials: 'include', headers: { accept: 'application/json', ...csrfHeader() } })
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на старте входа: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `flow-executor (старт) вернул HTTP ${res.status}`)
|
||||
return (await res.json()) as FlowChallenge
|
||||
}
|
||||
|
||||
async function postChallenge(url: string, body: Record<string, unknown>): Promise<FlowChallenge> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json', ...csrfHeader() },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при отправке формы входа: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
// 400 — authentik перерисовывает ту же стадию с `response_errors` (неверный пароль);
|
||||
// это валидное тело challenge'а, читаем его. Остальные не-ok — сетевая/конфиг ошибка.
|
||||
if (!res.ok && res.status !== 400)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `flow-executor вернул HTTP ${res.status}`)
|
||||
return (await res.json()) as FlowChallenge
|
||||
}
|
||||
|
||||
/**
|
||||
* Проводит фактор-1 (email+password) через flow-executor authentik и устанавливает
|
||||
* сессионную cookie. Возвращает `void` при успехе (сессия — побочный эффект cookie),
|
||||
* бросает `AuthV2Error(InvalidCredentials)` при неверных учётных данных / отказе и
|
||||
* `AuthV2Error(NetworkError)` при сетевом сбое.
|
||||
*
|
||||
* Поддерживаются стандартные стадии `ak-stage-identification` и `ak-stage-password`
|
||||
* (в т.ч. совмещённая identification с `password_fields`). Интерактивные стадии
|
||||
* вне scope встроенной формы (MFA-валидация, consent) → InvalidCredentials с
|
||||
* пояснением: их обрабатывает отдельный эпик динамического 2FA.
|
||||
*/
|
||||
export async function authenticateWithFlowExecutor(params: FlowExecutorParams): Promise<void> {
|
||||
const base = new URL(params.issuer).origin
|
||||
const url = flowUrl(base, params.flowSlug ?? DEFAULT_AUTHENTICATION_FLOW)
|
||||
|
||||
let challenge = await getChallenge(url)
|
||||
for (let step = 0; step < MAX_FLOW_STEPS; step++) {
|
||||
if (isSuccess(challenge))
|
||||
return
|
||||
|
||||
if (isAccessDenied(challenge))
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Доступ запрещён: проверьте email и пароль')
|
||||
|
||||
const err = firstResponseError(challenge)
|
||||
if (err !== null)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, err || 'Неверный email или пароль')
|
||||
|
||||
switch (challenge.component) {
|
||||
case 'ak-stage-identification':
|
||||
// Совмещённая identification может ждать и пароль (`password_fields`) — отдаём оба.
|
||||
challenge = await postChallenge(url, { uid_field: params.email, password: params.password })
|
||||
break
|
||||
case 'ak-stage-password':
|
||||
challenge = await postChallenge(url, { password: params.password })
|
||||
break
|
||||
default:
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.InvalidCredentials,
|
||||
`Стадия входа «${challenge.component ?? challenge.type ?? 'неизвестно'}» не поддерживается встроенной формой`,
|
||||
)
|
||||
}
|
||||
}
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Вход не завершён: превышено число шагов flow authentik')
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Второй этап входа CoopID — timestamp-signature handshake (Story 1.7), браузер-
|
||||
* агностичная часть фасада логина. Предполагает уже установленную сессию authentik
|
||||
* (cookie доезжает на bind через `credentials: 'include'`); первый этап (password,
|
||||
* Authorization Code + PKCE через oidc-client-ts, FR29) делает `client.ts`.
|
||||
*
|
||||
* Поток: `/coop/session/bind` → `signTimestamp` (ключ берётся из keystore, наружу
|
||||
* не выходит) → `/coop/verify/timestamp`. Полученные токены кладутся в сессию
|
||||
* (`tokens.ts`) и возвращаются вызывающему.
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { signTimestamp } from '../signing'
|
||||
import { setSession } from './tokens'
|
||||
|
||||
export interface HandshakeResult {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
/** compact JWS ES256K, выпускается controller'ом (Story 1.8); может отсутствовать. */
|
||||
participantCertificate?: string
|
||||
/** true → вход выдан в degraded-режиме (узел недоступен / ключ не финализирован, Story 9.6). */
|
||||
degraded: boolean
|
||||
degradedReason?: string
|
||||
}
|
||||
|
||||
interface BindResponse {
|
||||
binding_token: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
interface VerifyResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
participant_certificate?: string
|
||||
degraded?: boolean
|
||||
degraded_reason?: string
|
||||
}
|
||||
|
||||
/** Извлечь AuthV2Error из тела ответа контроллера (OAuth2-формат { error, error_description }). */
|
||||
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
|
||||
const code = (body?.error as AuthV2ErrorCode) ?? fallback
|
||||
return new AuthV2Error(code, body?.error_description ?? fallbackMsg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет handshake второго этапа и сохраняет сессию. `apiUrl` — база controller'а
|
||||
* кооператива (например `https://coop.example`).
|
||||
*/
|
||||
export async function performTimestampHandshake(apiUrl: string): Promise<HandshakeResult> {
|
||||
const base = apiUrl.replace(/\/$/, '')
|
||||
|
||||
// 1. bind: session_binding_token из тела (Эпик 7, D2). credentials:'include' —
|
||||
// чтобы сессионная cookie authentik доехала до controller'а для резолва username.
|
||||
let bindRes: Response
|
||||
try {
|
||||
bindRes = await fetch(`${base}/coop/session/bind`, { method: 'POST', credentials: 'include' })
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на этапе bind: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (bindRes.status === 401 || bindRes.status === 403)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Сессия authentik не подтверждена: пройдите первый этап входа (password)')
|
||||
if (!bindRes.ok)
|
||||
throw await authErrorFromResponse(bindRes, AuthV2ErrorCode.NetworkError, `bind вернул HTTP ${bindRes.status}`)
|
||||
const bind = (await bindRes.json()) as BindResponse
|
||||
if (!bind?.binding_token)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'bind не вернул session_binding_token')
|
||||
|
||||
// 2. подпись канонической метки (jti/sub — из binding_token; ключ — из keystore,
|
||||
// бросит WalletLocked, если кошелёк заперт).
|
||||
const sig = await signTimestamp({ sessionBindingToken: bind.binding_token })
|
||||
|
||||
// 3. verify: доказательство владения ключом → платформенные токены + сертификат.
|
||||
let verifyRes: Response
|
||||
try {
|
||||
verifyRes = await fetch(`${base}/coop/verify/timestamp`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ signature: sig.signature, timestamp: sig.ts, binding_token: bind.binding_token }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на этапе verify: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!verifyRes.ok)
|
||||
throw await authErrorFromResponse(verifyRes, AuthV2ErrorCode.ChainVerificationFailed, `verify вернул HTTP ${verifyRes.status}`)
|
||||
|
||||
const v = (await verifyRes.json()) as VerifyResponse
|
||||
setSession(base, { accessToken: v.access_token, refreshToken: v.refresh_token })
|
||||
|
||||
return {
|
||||
accessToken: v.access_token,
|
||||
refreshToken: v.refresh_token,
|
||||
participantCertificate: v.participant_certificate,
|
||||
degraded: Boolean(v.degraded),
|
||||
degradedReason: v.degraded_reason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { VaultSubject } from '../vault/types'
|
||||
import type { StorageAdapter } from '../wallet'
|
||||
/**
|
||||
* OIDC-слой: вход через authentik (password + timestamp-signature),
|
||||
* magic-link, recovery и работа с токенами (oidc-client-ts).
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { encryptPrivateKey } from '../vault/encrypt'
|
||||
import { lockWallet, saveLocalVault, unlockWallet } from '../wallet'
|
||||
import { authenticateWithAuthentik, coopIdApiUrl } from './client'
|
||||
import { performTimestampHandshake } from './handshake'
|
||||
import { clearSession, getAccessToken as getStoredAccessToken } from './tokens'
|
||||
|
||||
export { authenticateWithAuthentik, configureCoopId, configureOidc } from './client'
|
||||
export { authenticateWithFlowExecutor, DEFAULT_AUTHENTICATION_FLOW } from './flow-executor'
|
||||
export type { FlowExecutorParams } from './flow-executor'
|
||||
export type { HandshakeResult } from './handshake'
|
||||
export { performTimestampHandshake } from './handshake'
|
||||
export type { SessionTokens } from './tokens'
|
||||
export { configureTokenStorage, currentTokens, restoreSession } from './tokens'
|
||||
|
||||
export interface LoginParams {
|
||||
/** Issuer кооператива, например `https://coop.example/application/o/coopid/` */
|
||||
issuer: string
|
||||
email: string
|
||||
/**
|
||||
* Пароль пайщика (Story 11.2). Уходит во встроенную форму → flow-executor
|
||||
* authentik (фактор 1), а не в наш backend — запрещённый FR29 `grant_type=password`
|
||||
* не используется. Тем же паролем клиент шифрует password-vault (Story 11.3).
|
||||
*/
|
||||
password: string
|
||||
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
|
||||
flowSlug?: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string
|
||||
idToken: string
|
||||
/** compact JWS ES256K, выпускается controller'ом */
|
||||
participantCertificate: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Двухэтапный вход (Story 1.7, обновлён Story 11.2): (1) password через authentik —
|
||||
* встроенная форма гонит email+password в flow-executor (сессия), затем
|
||||
* `authorization_code`+PKCE молча (`signinSilent`); (2) timestamp-signature handshake
|
||||
* против controller'а (bind → подпись ключом из keystore → verify). Перед вызовом
|
||||
* кошелёк должен быть разблокирован (`unlockWallet`), иначе handshake бросит WalletLocked.
|
||||
*
|
||||
* База controller'а берётся из `configureCoopId({ apiUrl })`, OIDC-клиент — из
|
||||
* `configureOidc({ clientId, redirectUri })` (вызываются приложением на старте).
|
||||
*/
|
||||
export async function login(params: LoginParams): Promise<LoginResult> {
|
||||
const apiUrl = coopIdApiUrl()
|
||||
// 1. password-этап: устанавливает сессию authentik (cookie) + отдаёт id_token.
|
||||
const user = await authenticateWithAuthentik({ issuer: params.issuer, email: params.email, password: params.password, flowSlug: params.flowSlug })
|
||||
// 2. timestamp-signature handshake: платформенные токены + удостоверение.
|
||||
const handshake = await performTimestampHandshake(apiUrl)
|
||||
return {
|
||||
accessToken: handshake.accessToken,
|
||||
idToken: user.id_token ?? '',
|
||||
participantCertificate: handshake.participantCertificate ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Разобрать OAuth2-ошибку контроллера ({ error, error_description }) в AuthV2Error. */
|
||||
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
|
||||
return new AuthV2Error((body?.error as AuthV2ErrorCode) ?? fallback, body?.error_description ?? fallbackMsg)
|
||||
}
|
||||
|
||||
export interface LoginWithMagicLinkParams {
|
||||
/** Issuer кооператива — для повторного входа через authentik уже новым паролём. */
|
||||
issuer: string
|
||||
/** Email пайщика — фактор-1 повторной аутентификации в authentik после смены ключа. */
|
||||
email: string
|
||||
/** Magic-link токен из ссылки восстановления (или `recovery_token` offline-канала, Story 3.4). */
|
||||
token: string
|
||||
/** TOTP-код из приложения-аутентификатора — второй фактор подтверждения (Story 3.2/3.6). */
|
||||
totp: string
|
||||
/** Новый пароль: им шифруется новый vault и он же ставится в authentik (Story 12.1). */
|
||||
newPassword: string
|
||||
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
|
||||
flowSlug?: string
|
||||
/** Если задано — сохранить локальную копию нового зашифрованного vault'а на устройстве. */
|
||||
storage?: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход по magic-link (восстановление доступа) — полный confirm-флоу + повторный вход
|
||||
* (Эпик 12, Story 12.2). Старый ключ пайщиком утрачен (на то и восстановление),
|
||||
* поэтому клиент генерит НОВУЮ пару: приватный шифруется новым паролём в vault и
|
||||
* наружу/на сервер не уходит, on-chain едет только публичный.
|
||||
*
|
||||
* Account на старте неизвестен — magic-link несёт только непрозрачный `token`. Но он и
|
||||
* не нужен заранее: AAD vault'а больше не зависит от account (см. `vault/encrypt.ts`),
|
||||
* а username для повторного входа отдаёт сам `confirm`, резолвнув его из токена. Так
|
||||
* обходимся без отдельного whoami-by-token эндпоинта.
|
||||
*
|
||||
* Шаги: (1) сгенерировать пару; (2) зашифровать новый ключ новым паролём (AAD=тип
|
||||
* субъекта, без account); (3) `POST /coop/recovery/confirm` {token, TOTP, public_key,
|
||||
* vault, password} — сервер (12.1) ставит пароль в authentik, сохраняет vault под нужным
|
||||
* account, ротирует active-ключ (`registrator::changekey`), отзывает старые сессии и
|
||||
* возвращает `{ username }`; (4) повторный вход новым контуром: authentik-сессия новым
|
||||
* паролём → `unlockWallet` по этому username (скачать только что записанный блоб →
|
||||
* расшифровать → keystore) → timestamp-handshake. Ключ к этому моменту уже ротирован
|
||||
* on-chain, поэтому verify увидит новый pubkey (при лаге узла handshake вернёт degraded,
|
||||
* Story 9.6).
|
||||
*/
|
||||
export async function loginWithMagicLink(params: LoginWithMagicLinkParams): Promise<LoginResult> {
|
||||
const apiUrl = coopIdApiUrl()
|
||||
|
||||
// 1. Новая пара ключей (старый утрачен). WIF в формате `5J…`/`5K…` — как везде в системе.
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const newKey = PrivateKey.generate('K1')
|
||||
const newPrivateKey = newKey.toWif()
|
||||
const newPublicKey = newKey.toPublic().toString()
|
||||
|
||||
// 2. Зашифровать новый ключ новым паролём. AAD = тип субъекта (`participant`), а не
|
||||
// account: id в AAD не участвует (см. vault/encrypt.ts), поэтому шифруем, не зная
|
||||
// username. Сервер сохранит блоб под нужным account сам (по recovery-токену).
|
||||
const subject: VaultSubject = { subject_type: 'participant', subject_id: '' }
|
||||
const vaultBlob = await encryptPrivateKey(newPrivateKey, params.newPassword, subject)
|
||||
|
||||
// 3. confirm: токен magic-link + TOTP + новый материал. Тело — контракт RecoveryConfirmBody.
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl}/coop/recovery/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token: params.token, code: params.totp, public_key: newPublicKey, vault: vaultBlob, password: params.newPassword }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при подтверждении восстановления: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (res.status === 429)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.TooManyRecoveryAttempts, 'Слишком много попыток подтверждения, попробуйте позже')
|
||||
if (!res.ok)
|
||||
throw await authErrorFromResponse(res, AuthV2ErrorCode.InvalidRecoveryToken, `Подтверждение восстановления отклонено (HTTP ${res.status})`)
|
||||
|
||||
// confirm вернул account пайщика (резолвнут из токена) — по нему скачаем и
|
||||
// расшифруем только что сохранённый сервером блоб при повторном входе.
|
||||
const confirmed = (await res.json().catch(() => null)) as { username?: string } | null
|
||||
if (!confirmed?.username)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidRecoveryToken, 'Подтверждение восстановления не вернуло аккаунт')
|
||||
const account = confirmed.username
|
||||
|
||||
// 4. Локальная копия нового блоба (best-effort на устройстве восстановления).
|
||||
if (params.storage)
|
||||
await saveLocalVault(params.storage, account, vaultBlob)
|
||||
|
||||
// 5. Повторный вход новым контуром. unlockWallet забирает только что сохранённый
|
||||
// серверный блоб и расшифровывает новым паролём — заодно round-trip-проверка vault'а.
|
||||
const user = await authenticateWithAuthentik({ issuer: params.issuer, email: params.email, password: params.newPassword, flowSlug: params.flowSlug })
|
||||
await unlockWallet({ apiUrl, account, password: params.newPassword })
|
||||
const handshake = await performTimestampHandshake(apiUrl)
|
||||
return {
|
||||
accessToken: handshake.accessToken,
|
||||
idToken: user.id_token ?? '',
|
||||
participantCertificate: handshake.participantCertificate ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запросить восстановление доступа (magic-link на email; по стратегии кооператива
|
||||
* — также offline-код). Эпик 3, `POST /coop/recovery/request`. Анти-enumeration:
|
||||
* сервер всегда отвечает 202 вне зависимости от существования аккаунта.
|
||||
*/
|
||||
export async function recover(email: string): Promise<void> {
|
||||
const base = coopIdApiUrl()
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${base}/coop/recovery/request`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе восстановления: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
// 202 — нормальный путь; иные коды (кроме rate-limit) — ошибка конфигурации/сети.
|
||||
if (res.status === 429)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.TooManyRecoveryAttempts, 'Слишком много попыток восстановления, попробуйте позже')
|
||||
if (!res.ok && res.status !== 202)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Запрос восстановления отклонён (HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
/** Текущий access_token (с автообновлением через refresh). Story 1.7. */
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
return getStoredAccessToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* Актуальное participant_certificate текущей сессии — compact JWS из
|
||||
* `GET /coop/certificate` (Story 1.8). `accessToken` — платформенный токен входа
|
||||
* (Bearer). Декодирование claims — `decodeParticipantCertificate` (certificate/).
|
||||
*/
|
||||
export async function getParticipantCertificate(apiUrl: string, accessToken: string): Promise<string> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/certificate`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе удостоверения: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить удостоверение (HTTP ${res.status})`)
|
||||
const body = (await res.json()) as { participant_certificate: string }
|
||||
return body.participant_certificate
|
||||
}
|
||||
|
||||
export interface LogoutParams {
|
||||
/** Базовый URL controller'а кооператива (например `https://coop.example`). */
|
||||
apiUrl: string
|
||||
/** refresh_token текущей сессии — отзывается на сервере. */
|
||||
refreshToken: string
|
||||
/** access_token (опционально) — тоже отзывается. */
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RP-initiated logout (Story 1.10): отзыв токенов на сервере + затирание локального
|
||||
* keystore. Серверный вызов — best-effort; локальное затирание ключа и сессии
|
||||
* выполняется ВСЕГДА (в `finally`), даже если сервер недоступен — безопасность важнее
|
||||
* «чистого» logout: расшифрованный ключ не должен остаться в памяти браузера при
|
||||
* сетевом сбое. Редирект на login — на стороне вызывающего. Стандартный OIDC
|
||||
* end-session — Story 5.1.
|
||||
*/
|
||||
export async function logout(params: LogoutParams): Promise<void> {
|
||||
try {
|
||||
await fetch(`${params.apiUrl.replace(/\/$/, '')}/coop/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: params.refreshToken, access_token: params.accessToken }),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// best-effort: недоступность сервера не должна блокировать локальное затирание ключа
|
||||
}
|
||||
finally {
|
||||
lockWallet()
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { StorageAdapter } from '../wallet/storage-adapter'
|
||||
/**
|
||||
* Lifecycle платформенных токенов сессии CoopID (Эпик 7). Источник истины токена —
|
||||
* этот модуль `@coopenomics/auth`; `@coopenomics/sdk` копирует access в свои
|
||||
* GraphQL-заголовки (D1: bearer не покидает слой SDK, приложение его не трогает).
|
||||
*
|
||||
* Хранение — in-memory (как keystore ключа): по умолчанию живёт только в RAM
|
||||
* вкладки. Персистентность между перезагрузками подключается приложением через
|
||||
* `StorageAdapter` (frontend — IndexedDB), чтобы крипто-/токен-логика не зависела
|
||||
* от среды и тестировалась без браузера.
|
||||
*/
|
||||
import { decodeJwt } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
export interface SessionTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
/** Запас до фактического exp, при котором access считаем «пора обновлять». */
|
||||
const REFRESH_SKEW_SEC = 30
|
||||
|
||||
let tokens: SessionTokens | null = null
|
||||
let apiBase: string | null = null
|
||||
|
||||
/**
|
||||
* Опциональная персистентность токенов между перезагрузками (паритет с легаси,
|
||||
* у которого токены лежат в IndexedDB). По умолчанию контур RAM-only; приложение
|
||||
* подключает `StorageAdapter` (frontend — IndexedDB) через `configureTokenStorage`.
|
||||
* Без этого CoopID-сессия не переживала бы F5 (ключ поднимается из PIN-кэша, а
|
||||
* токен терялся бы), т.е. была бы СЛАБЕЕ легаси.
|
||||
*/
|
||||
const TOKEN_STORAGE_KEY = 'coopid.session.tokens'
|
||||
let storage: StorageAdapter | null = null
|
||||
|
||||
interface PersistedSession {
|
||||
apiBase: string
|
||||
tokens: SessionTokens
|
||||
}
|
||||
|
||||
/** Подключает (или снимает — `null`) персистентность токенов сессии. */
|
||||
export function configureTokenStorage(adapter: StorageAdapter | null): void {
|
||||
storage = adapter
|
||||
}
|
||||
|
||||
/** Кладёт токены текущей сессии (вызывается из handshake/login/refresh). `apiUrl` — база controller'а для refresh. */
|
||||
export function setSession(apiUrl: string, next: SessionTokens): void {
|
||||
apiBase = apiUrl.replace(/\/$/, '')
|
||||
tokens = next
|
||||
// best-effort персист: сбой записи не должен ронять вход (токены уже в RAM).
|
||||
if (storage)
|
||||
void storage.set(TOKEN_STORAGE_KEY, JSON.stringify({ apiBase, tokens: next } satisfies PersistedSession)).catch(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает токены сессии из персистентного хранилища на старте приложения
|
||||
* (после reload). `true` — сессия поднята в RAM. Уже активная RAM-сессия не
|
||||
* перетирается. Без подключённого storage или записи — `false`.
|
||||
*/
|
||||
export async function restoreSession(): Promise<boolean> {
|
||||
if (tokens && apiBase)
|
||||
return true
|
||||
if (!storage)
|
||||
return false
|
||||
const raw = await storage.get(TOKEN_STORAGE_KEY)
|
||||
if (!raw)
|
||||
return false
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PersistedSession
|
||||
if (!parsed?.apiBase || !parsed?.tokens?.accessToken || !parsed?.tokens?.refreshToken)
|
||||
return false
|
||||
apiBase = parsed.apiBase
|
||||
tokens = parsed.tokens
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Затирает токены сессии (logout). Идемпотентно. Стирает и персистентную копию. */
|
||||
export function clearSession(): void {
|
||||
tokens = null
|
||||
apiBase = null
|
||||
if (storage)
|
||||
void storage.remove(TOKEN_STORAGE_KEY).catch(() => undefined)
|
||||
}
|
||||
|
||||
/** Снимок текущих токенов (или null, если сессии нет). */
|
||||
export function currentTokens(): SessionTokens | null {
|
||||
return tokens ? { ...tokens } : null
|
||||
}
|
||||
|
||||
/** Истёк ли (или вот-вот истечёт) access-токен. Невалидный/без exp → считаем истёкшим. */
|
||||
function accessExpired(token: string): boolean {
|
||||
try {
|
||||
const { exp } = decodeJwt(token)
|
||||
if (typeof exp !== 'number')
|
||||
return true
|
||||
return exp - REFRESH_SKEW_SEC <= Math.floor(Date.now() / 1000)
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет пару токенов через REST `/coop/refresh` (то же зеркало платформенной
|
||||
* токен-машинерии, что и legacy GraphQL-refresh). 401 → сессия истекла/отозвана.
|
||||
*/
|
||||
export async function refreshSession(base: string, refreshToken: string): Promise<SessionTokens> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${base}/coop/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при обновлении токена: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (res.status === 401 || res.status === 403)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Сессия истекла: требуется повторный вход')
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось обновить токен (HTTP ${res.status})`)
|
||||
|
||||
const body = (await res.json()) as { access_token: string, refresh_token: string }
|
||||
return { accessToken: body.access_token, refreshToken: body.refresh_token }
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущий access_token с авто-обновлением через refresh (Story 1.7). Бросает, если
|
||||
* активной сессии нет (нужно войти). Обновлённую пару кладёт обратно в сессию.
|
||||
*/
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
if (!tokens || !apiBase)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Нет активной сессии: сначала выполните вход')
|
||||
if (!accessExpired(tokens.accessToken))
|
||||
return tokens.accessToken
|
||||
|
||||
const refreshed = await refreshSession(apiBase, tokens.refreshToken)
|
||||
// setSession обновляет RAM И персистентную копию — иначе на диске остался бы
|
||||
// устаревший refresh-токен и следующий reload поднял бы протухшую сессию.
|
||||
setSession(apiBase, refreshed)
|
||||
return refreshed.accessToken
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Подпись: документы (Story 2.3) и timestamp-метка второго этапа аутентификации
|
||||
* (Story 2.4). Полностью локальные операции (без сети); подпись timestamp НЕ
|
||||
* создаёт audit_events.
|
||||
*/
|
||||
import { base64url, decodeJwt } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { currentView, readUnlockedKey } from '../wallet/storage'
|
||||
|
||||
export interface SignDocumentParams {
|
||||
/** Содержимое документа: байты или строка (кодируется UTF-8). */
|
||||
payload: Uint8Array | string
|
||||
/** Алгоритм подписи; поддерживается ES256K (COOPOS-кривая secp256k1). */
|
||||
alg?: 'ES256K'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись дайджеста транзакции COOPOS ключом пайщика из keystore (мост подписи
|
||||
* CoopID, Эпик 7). Приватный ключ НЕ покидает keystore — наружу уходит только
|
||||
* готовая подпись `SIG_K1_…`. Это та же операция, что внутри
|
||||
* `@wharfkit/wallet-plugin-privatekey`, но ключ берётся из RAM-keystore (2.2), а
|
||||
* не из аргумента: десктопный `WalletPluginCoopId` делегирует сюда `sign()`, и
|
||||
* wharfkit `Session.transact()` подписывает чужими руками, не зная ключа.
|
||||
*
|
||||
* Вход — hex-строка signing-дайджеста (`transaction.signingDigest(chainId)`),
|
||||
* чтобы не тащить wharfkit-типы через границу пакета (иначе ловушка двойного
|
||||
* `Checksum256` из разных копий antelope). Бросает `WalletLocked`, если заперт.
|
||||
*/
|
||||
export async function signChainDigest(digestHex: string): Promise<string> {
|
||||
currentView() // бросает WalletLocked, если keystore заперт
|
||||
const { PrivateKey, Checksum256 } = await import('@wharfkit/antelope')
|
||||
const signature = PrivateKey.from(readUnlockedKey()).signDigest(Checksum256.from(digestHex))
|
||||
return signature.toString()
|
||||
}
|
||||
|
||||
export interface TimestampSignature {
|
||||
ts: string
|
||||
binding_token_jti: string
|
||||
sub: string
|
||||
signature: string
|
||||
public_key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Каноническое сообщение, которое подписывается и которое сервер обязан собрать
|
||||
* байт-в-байт для `recoverMessage` (`/coop/verify/timestamp`, Story 1.7). Ключи в
|
||||
* фиксированном алфавитном порядке — детерминизм между клиентом и сервером.
|
||||
* Экспортируется, чтобы controller переиспользовал ту же канонизацию.
|
||||
*/
|
||||
export function canonicalTimestampMessage(payload: { ts: string, binding_token_jti: string, sub: string }): string {
|
||||
return JSON.stringify({ binding_token_jti: payload.binding_token_jti, sub: payload.sub, ts: payload.ts })
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная подпись документа ключом пайщика (Story 2.3). Возвращает compact JWS
|
||||
* `<header>.<payload>.<signature>` (alg=ES256K). Полностью офлайн: без сети и без
|
||||
* `audit_events` — сервер о подписи не знает (AC: подпись неоспорима и не зависит
|
||||
* от состояния сервера).
|
||||
*
|
||||
* Подпись делается тем же кросс-рантайм secp256k1-примитивом, что и `signTimestamp`
|
||||
* (`@wharfkit/antelope`): jose в браузере ES256K не умеет (WebCrypto без secp256k1),
|
||||
* поэтому compact JWS собирается вручную, а K1-подпись сериализуется в JWS-формат
|
||||
* R||S (recovery-байт отбрасывается — верификация по pubkey, не recover). `kid` в
|
||||
* заголовке = COOPOS-аккаунт подписанта (разрешение ключа при верификации, Story 2.5).
|
||||
*/
|
||||
export async function signDocument(params: SignDocumentParams): Promise<string> {
|
||||
const alg = params.alg ?? 'ES256K'
|
||||
if (alg !== 'ES256K')
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `Неподдерживаемый алгоритм подписи: ${alg}`)
|
||||
|
||||
const { account } = currentView() // бросает WalletLocked, если заперт
|
||||
const wif = readUnlockedKey()
|
||||
|
||||
const payloadBytes = typeof params.payload === 'string'
|
||||
? new TextEncoder().encode(params.payload)
|
||||
: params.payload
|
||||
|
||||
const header = base64url.encode(JSON.stringify({ alg, kid: account }))
|
||||
const payloadB64 = base64url.encode(payloadBytes)
|
||||
const signingInput = `${header}.${payloadB64}`
|
||||
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const sig = PrivateKey.from(wif).signMessage(new TextEncoder().encode(signingInput))
|
||||
// K1-подпись сериализуется как [recovery(1), r(32), s(32)] = 65б; JWS ES256K = R||S
|
||||
// (64б) — recovery-байт не нужен (верификация по pubkey, не recover).
|
||||
const rs = sig.data.array.slice(1)
|
||||
return `${signingInput}.${base64url.encode(rs)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись метки времени для второго этапа auth (Story 2.4). Берёт `sub`/`jti` из
|
||||
* активного `session_binding_token` (Эпик 1: читаем claims без верификации —
|
||||
* подпись делает локальный ключ, доверие даёт сервер при verify), подписывает
|
||||
* каноническое `{ ts, binding_token_jti, sub }` COOPOS-native recoverable
|
||||
* подписью (SIG_K1_..., секрет — из keystore 2.2). Схема самоописана префиксом
|
||||
* SIG_K1_ — alg не хардкодим. Результат уходит в `/coop/verify/timestamp`, где
|
||||
* сервер `recoverMessage` → сверяет pubkey с COOPOS `get_account`.
|
||||
*/
|
||||
export async function signTimestamp(params: { sessionBindingToken: string }): Promise<TimestampSignature> {
|
||||
let claims: { sub?: string, jti?: string }
|
||||
try {
|
||||
claims = decodeJwt(params.sessionBindingToken)
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Некорректный session_binding_token: не удалось прочитать claims')
|
||||
}
|
||||
const sub = claims.sub
|
||||
const jti = claims.jti
|
||||
if (!sub || !jti)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'session_binding_token без обязательных claims sub/jti')
|
||||
|
||||
// Кошелёк должен принадлежать тому же субъекту, что и токен (иначе подпишем
|
||||
// метку чужим ключом — сервер всё равно отвергнет, но ловим раньше и понятнее).
|
||||
const wallet = currentView() // бросает WalletLocked, если заперт
|
||||
if (wallet.account !== sub)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ClientWalletMismatch, `Разблокированный кошелёк (${wallet.account}) не совпадает с субъектом токена (${sub})`)
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const message = canonicalTimestampMessage({ ts, binding_token_jti: jti, sub })
|
||||
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const pk = PrivateKey.from(readUnlockedKey())
|
||||
const signature = pk.signMessage(new TextEncoder().encode(message))
|
||||
|
||||
return {
|
||||
ts,
|
||||
binding_token_jti: jti,
|
||||
sub,
|
||||
signature: signature.toString(),
|
||||
public_key: pk.toPublic().toString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { deriveKey } from './kdf'
|
||||
import type { EncryptedVaultBlob, VaultSubject } from './types'
|
||||
|
||||
const CIPHER_VERSION = 'aes-256-gcm-v1'
|
||||
const KDF_VERSION = 'argon2id-v1'
|
||||
const SALT_LEN = 16
|
||||
const NONCE_LEN = 12
|
||||
|
||||
function toB64Url(bytes: Uint8Array): string {
|
||||
let bin = ''
|
||||
for (const b of bytes) bin += String.fromCharCode(b)
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export function fromB64Url(s: string): Uint8Array {
|
||||
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const out = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* AAD GCM привязывает шифртекст к ТИПУ субъекта (`participant`/`coop`/…), но НЕ к
|
||||
* конкретному account-id. Раньше было `${type}|${id}` — это требовало знать
|
||||
* username ещё до confirm при восстановлении (Эпик 12), хотя сервер и так
|
||||
* резолвит account из recovery-токена; так родился лишний whoami-by-token.
|
||||
*
|
||||
* Account-id убран из AAD намеренно (решение владельца 2026-06-15): пер-юзер
|
||||
* привязку даёт пароль + случайная соль, лежащая в самом блобе; контролем доступа
|
||||
* AAD здесь не был (блоб и так публично читается по account через `GET /coop/vault`).
|
||||
* Поэтому теперь клиент шифрует новый ключ просто паролём, а account для
|
||||
* последующей выборки/расшифровки блоба берёт из ответа confirm.
|
||||
*/
|
||||
function aad(subject: VaultSubject): string {
|
||||
return subject.subject_type
|
||||
}
|
||||
|
||||
/** WebCrypto в strict-TS требует ArrayBuffer-backed view; нормализуем Uint8Array. */
|
||||
function buf(u: Uint8Array): ArrayBuffer {
|
||||
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject-агностичное ядро (Story 2.2): Argon2id(пароль, salt) → AES-256-GCM с
|
||||
* произвольной AAD-строкой. Поверх него работает vault приватного ключа
|
||||
* (AAD=субъект). PIN-слой (AAD=`pin|<account>`) снят в 11.8 (модель «без PIN»).
|
||||
*/
|
||||
export async function encryptWithPassword(
|
||||
plaintext: string,
|
||||
password: string,
|
||||
additionalData: string,
|
||||
): Promise<EncryptedVaultBlob> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN))
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN))
|
||||
const keyBytes = deriveKey(password, salt)
|
||||
|
||||
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['encrypt'])
|
||||
const sealed = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: buf(nonce), additionalData: buf(new TextEncoder().encode(additionalData)) },
|
||||
key,
|
||||
buf(new TextEncoder().encode(plaintext)),
|
||||
),
|
||||
)
|
||||
// WebCrypto склеивает ciphertext+tag; tag GCM — последние 16 байт.
|
||||
const tagLen = 16
|
||||
const ciphertext = sealed.slice(0, sealed.length - tagLen)
|
||||
const authTag = sealed.slice(sealed.length - tagLen)
|
||||
|
||||
return {
|
||||
cipher_version: CIPHER_VERSION,
|
||||
kdf_version: KDF_VERSION,
|
||||
salt: toB64Url(salt),
|
||||
nonce: toB64Url(nonce),
|
||||
ciphertext: toB64Url(ciphertext),
|
||||
auth_tag: toB64Url(authTag),
|
||||
}
|
||||
}
|
||||
|
||||
/** Парная расшифровка ядра; неверный пароль/AAD → `VaultDecryptionFailed`. */
|
||||
export async function decryptWithPassword(
|
||||
blob: EncryptedVaultBlob,
|
||||
password: string,
|
||||
additionalData: string,
|
||||
): Promise<string> {
|
||||
const keyBytes = deriveKey(password, fromB64Url(blob.salt))
|
||||
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['decrypt'])
|
||||
const sealed = new Uint8Array([...fromB64Url(blob.ciphertext), ...fromB64Url(blob.auth_tag)])
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: buf(fromB64Url(blob.nonce)), additionalData: buf(new TextEncoder().encode(additionalData)) },
|
||||
key,
|
||||
buf(sealed),
|
||||
)
|
||||
return new TextDecoder().decode(plain)
|
||||
} catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Не удалось расшифровать: неверный пароль или повреждённые данные')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентское шифрование приватного ключа пайщика (Story 2.1). Сервер получает
|
||||
* только результат — расшифровать может лишь владелец пароля (AAD=субъект).
|
||||
*/
|
||||
export async function encryptPrivateKey(
|
||||
privateKey: string,
|
||||
password: string,
|
||||
subject: VaultSubject,
|
||||
): Promise<EncryptedVaultBlob> {
|
||||
return encryptWithPassword(privateKey, password, aad(subject))
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная расшифровка (round-trip тесты и keystore 2.2). Серверу недоступна —
|
||||
* type-driven ban (см. controller VaultService).
|
||||
*/
|
||||
export async function decryptPrivateKey(
|
||||
blob: EncryptedVaultBlob,
|
||||
password: string,
|
||||
subject: VaultSubject,
|
||||
): Promise<string> {
|
||||
return decryptWithPassword(blob, password, aad(subject))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Vault-слой: client-side шифрование/расшифровка приватного ключа
|
||||
* (Argon2id + AES-256-GCM). Сервер хранит только зашифрованный блоб и не
|
||||
* может его расшифровать (type-driven ban в controller VaultService).
|
||||
*/
|
||||
export type { EncryptedVaultBlob, VaultSubject } from './types'
|
||||
export { ARGON2ID_PARAMS, deriveKey } from './kdf'
|
||||
export { decryptPrivateKey, encryptPrivateKey, fromB64Url } from './encrypt'
|
||||
@@ -0,0 +1,17 @@
|
||||
import { argon2id } from '@noble/hashes/argon2'
|
||||
|
||||
/** Параметры Argon2id KDF (CoopID): зафиксированы версией `argon2id-v1`. */
|
||||
export const ARGON2ID_PARAMS = {
|
||||
m: 65536, // 64 MiB
|
||||
t: 3,
|
||||
p: 4,
|
||||
dkLen: 32, // ключ AES-256
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Деривация 32-байтного ключа из пароля и соли (Argon2id).
|
||||
* Изоморфно: @noble/hashes работает в браузере, Node и desktop-runtime.
|
||||
*/
|
||||
export function deriveKey(password: string, salt: Uint8Array): Uint8Array {
|
||||
return argon2id(new TextEncoder().encode(password), salt, ARGON2ID_PARAMS)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Субъект, к которому привязан зашифрованный блоб (AAD). */
|
||||
export interface VaultSubject {
|
||||
subject_type: 'participant' | 'coop' | 'council_action'
|
||||
subject_id: string
|
||||
}
|
||||
|
||||
/** Зашифрованный блоб vault'а: всё, что хранит и отдаёт сервер. base64url-поля. */
|
||||
export interface EncryptedVaultBlob {
|
||||
cipher_version: string
|
||||
kdf_version: string
|
||||
salt: string
|
||||
nonce: string
|
||||
ciphertext: string
|
||||
auth_tag: string
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { EncryptedVaultBlob, VaultSubject } from '../vault/types'
|
||||
import type { StorageAdapter } from './storage-adapter'
|
||||
/**
|
||||
* Кошелёк (Story 2.2): разблокировка после логина, доступ к публичному «виду»
|
||||
* ключа в памяти, запирание на logout. Приватный ключ живёт ТОЛЬКО в keystore
|
||||
* (storage.ts) и наружу не выходит — `Wallet` его не содержит и не сериализует.
|
||||
* Desktop-кошелёк переезжает на этот модуль (миграция — Эпик 7).
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
|
||||
import { decryptPrivateKey, encryptPrivateKey } from '../vault/encrypt'
|
||||
import { saveLocalVault } from './local-vault'
|
||||
import { clearPinProtected, DEFAULT_PIN, hasPinProtected, loadPinProtected, savePinProtected } from './pin'
|
||||
import { currentView, isUnlocked, readUnlockedKey, storeUnlocked, wipeKeystore } from './storage'
|
||||
|
||||
/**
|
||||
* Несериализуемая обёртка кошелька: отдаёт только аккаунт и публичный ключ.
|
||||
* Приватного ключа в объекте НЕТ — `JSON.stringify(wallet)` физически не может
|
||||
* его раскрыть. Подпись (Stories 2.3/2.4) берёт ключ из keystore, не из Wallet.
|
||||
*/
|
||||
export class Wallet {
|
||||
readonly account: string
|
||||
readonly publicKey: string
|
||||
|
||||
constructor(view: { account: string, publicKey: string }) {
|
||||
this.account = view.account
|
||||
this.publicKey = view.publicKey
|
||||
}
|
||||
|
||||
toJSON(): { account: string, publicKey: string } {
|
||||
return { account: this.account, publicKey: this.publicKey }
|
||||
}
|
||||
}
|
||||
|
||||
/** Деривует публичный ключ из приватного (WIF K1) через WharfKit antelope. */
|
||||
async function derivePublicKey(privateKey: string): Promise<string> {
|
||||
// Ленивый импорт: тяжёлый antelope не тянем в bundle, пока кошелёк не разблокируют.
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
try {
|
||||
return PrivateKey.from(privateKey).toPublic().toString()
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Расшифрованное значение не является валидным приватным ключом')
|
||||
}
|
||||
}
|
||||
|
||||
/** GET зашифрованного blob'а пайщика с контроллера (blob-only, см. 2.2). */
|
||||
export async function fetchVaultBlob(apiUrl: string, subjectId: string): Promise<EncryptedVaultBlob> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault/participant/${encodeURIComponent(subjectId)}`)
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе vault: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить vault (HTTP ${res.status})`)
|
||||
return res.json() as Promise<EncryptedVaultBlob>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST зашифрованного blob'а на контроллер (Story 11.3, `POST /coop/vault`, 201).
|
||||
* Тело — blob + плоские `subject_type`/`subject_id` (контракт `StoreVaultDto`).
|
||||
* Сервер только сохраняет шифр и расшифровать его не может (type-ban в 2.1).
|
||||
*/
|
||||
export async function storeVaultBlob(apiUrl: string, subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...blob, subject_type: subject.subject_type, subject_id: subject.subject_id }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при сохранении vault: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось сохранить vault (HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
interface SaveToVaultParams {
|
||||
apiUrl: string
|
||||
account: string
|
||||
privateKey: string
|
||||
password: string
|
||||
/** Если задано — дополнительно сохранить локальную копию зашифрованного blob'а. */
|
||||
storage?: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Шифрует приватный ключ паролём (Argon2id+AES-256-GCM, AAD=субъект) и сохраняет
|
||||
* vault: на сервере (обязательно) и локально (если передан `storage`). Возвращает
|
||||
* зашифрованный blob. Приватный ключ наружу/на сервер не уходит — только шифр.
|
||||
* Используется миграцией «ключ→пароль» (Story 11.4) и сменой пароля.
|
||||
*/
|
||||
export async function saveToVault(params: SaveToVaultParams): Promise<EncryptedVaultBlob> {
|
||||
const subject: VaultSubject = { subject_type: 'participant', subject_id: params.account }
|
||||
const blob = await encryptPrivateKey(params.privateKey, params.password, subject)
|
||||
await storeVaultBlob(params.apiUrl, subject, blob)
|
||||
if (params.storage)
|
||||
await saveLocalVault(params.storage, params.account, blob)
|
||||
return blob
|
||||
}
|
||||
|
||||
interface UnlockParams {
|
||||
apiUrl: string
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная разблокировка: забрать blob → расшифровать паролём на клиенте →
|
||||
* положить ключ в keystore. При неверном пароле keystore остаётся пуст.
|
||||
*/
|
||||
export async function unlockWallet(params: UnlockParams): Promise<Wallet> {
|
||||
const blob = await fetchVaultBlob(params.apiUrl, params.account)
|
||||
const privateKey = await decryptPrivateKey(blob, params.password, {
|
||||
subject_type: 'participant',
|
||||
subject_id: params.account,
|
||||
})
|
||||
const publicKey = await derivePublicKey(privateKey)
|
||||
storeUnlocked({ account: params.account, publicKey, privateKey })
|
||||
return new Wallet({ account: params.account, publicKey })
|
||||
}
|
||||
|
||||
/** Текущий разблокированный кошелёк. Бросает `WalletLocked`, если заперт. */
|
||||
export async function getWallet(): Promise<Wallet> {
|
||||
return new Wallet(currentView())
|
||||
}
|
||||
|
||||
/** Заперт ли кошелёк (нет ключа в памяти). */
|
||||
export function isWalletUnlocked(): boolean {
|
||||
return isUnlocked()
|
||||
}
|
||||
|
||||
/** Затирает ключ из памяти (logout). Идемпотентно. */
|
||||
export function lockWallet(): void {
|
||||
wipeKeystore()
|
||||
}
|
||||
|
||||
/** Ротация ключа пайщика (updateauth + перешифровка vault). Story 3.3. */
|
||||
export async function rotateKey(): Promise<void> {
|
||||
notImplemented('rotateKey')
|
||||
}
|
||||
|
||||
interface PersistPinParams {
|
||||
/** ПИН для шифрования локального кэша; по умолчанию — `DEFAULT_PIN` ('000000', прозрачный). */
|
||||
pin?: string
|
||||
storage: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Перешифровывает текущий разблокированный ключ ПИНом и кладёт в локальный кэш
|
||||
* (уточнённая at-rest модель, см. `pin.ts`). Вызывается сразу после успешного
|
||||
* `unlockWallet`/`migrate`, чтобы последующие входы шли по ПИН, а не по паролю.
|
||||
* Бросает `WalletLocked`, если кошелёк заперт (нечего кэшировать). Ключ читается
|
||||
* пакет-внутренней `readUnlockedKey()` и наружу не выходит — в кэш ложится шифр.
|
||||
*/
|
||||
export async function persistPinCache(params: PersistPinParams): Promise<void> {
|
||||
const { account } = currentView() // бросает WalletLocked, если заперт
|
||||
await savePinProtected(readUnlockedKey(), params.pin ?? DEFAULT_PIN, account, params.storage)
|
||||
}
|
||||
|
||||
interface UnlockWithPinParams {
|
||||
/** ПИН; по умолчанию — `DEFAULT_PIN` ('000000', прозрачная авто-разблокировка). */
|
||||
pin?: string
|
||||
storage: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Разблокировка из локального PIN-кэша без round-trip к серверу и без пароля
|
||||
* (reload устройства, авто-лок по простою). `null` — кэша нет (нужен полный вход
|
||||
* `unlockWallet` паролём). Неверный ПИН → `VaultDecryptionFailed`. При успехе
|
||||
* кладёт ключ в keystore и возвращает `Wallet`.
|
||||
*/
|
||||
export async function unlockWithPin(params: UnlockWithPinParams): Promise<Wallet | null> {
|
||||
const loaded = await loadPinProtected(params.pin ?? DEFAULT_PIN, params.storage)
|
||||
if (!loaded)
|
||||
return null
|
||||
const publicKey = await derivePublicKey(loaded.privateKey)
|
||||
storeUnlocked({ account: loaded.account, publicKey, privateKey: loaded.privateKey })
|
||||
return new Wallet({ account: loaded.account, publicKey })
|
||||
}
|
||||
|
||||
/** Есть ли локальный PIN-кэш (выбор сценария разблокировки на загрузке). */
|
||||
export async function hasPinCache(storage: StorageAdapter): Promise<boolean> {
|
||||
return hasPinProtected(storage)
|
||||
}
|
||||
|
||||
/** Удаляет локальный PIN-кэш («забыть устройство» / смена аккаунта). */
|
||||
export async function clearPinCache(storage: StorageAdapter): Promise<void> {
|
||||
await clearPinProtected(storage)
|
||||
}
|
||||
|
||||
export { clearLocalVault, loadLocalVault, saveLocalVault } from './local-vault'
|
||||
export { DEFAULT_PIN } from './pin'
|
||||
export type { StorageAdapter } from './storage-adapter'
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Локальная копия зашифрованного vault-блоба (Story 11.3). Хранит то же, что и
|
||||
* сервер — Argon2id+AES-256-GCM ciphertext, бесполезный без пароля; расшифровать
|
||||
* нельзя без знания пароля пайщика (zero-knowledge инвариант сохраняется и локально).
|
||||
*
|
||||
* Зачем дублировать серверную копию на устройстве: вход и подпись возможны без
|
||||
* round-trip к контроллеру (офлайн/деградация узла), а также чтобы сразу после
|
||||
* миграции (Story 11.4) ключ был доступен локально. Сервер остаётся источником
|
||||
* истины и обязателен для входа с НОВОГО устройства (там локальной копии нет).
|
||||
*
|
||||
* Это НЕ расшифрованный ключ: расшифрованный WIF живёт только в RAM-keystore
|
||||
* (`storage.ts`) и стирается на логауте (Story 11.8). Локально лежит лишь шифр.
|
||||
*/
|
||||
import type { EncryptedVaultBlob } from '../vault/types'
|
||||
import type { StorageAdapter } from './storage-adapter'
|
||||
|
||||
const LOCAL_VAULT_KEY = 'coopid.wallet.vault'
|
||||
|
||||
interface LocalVaultRecord {
|
||||
account: string
|
||||
blob: EncryptedVaultBlob
|
||||
}
|
||||
|
||||
/** Сохраняет (перезаписывает) локальную копию зашифрованного blob'а пайщика. */
|
||||
export async function saveLocalVault(storage: StorageAdapter, account: string, blob: EncryptedVaultBlob): Promise<void> {
|
||||
const record: LocalVaultRecord = { account, blob }
|
||||
await storage.set(LOCAL_VAULT_KEY, JSON.stringify(record))
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает локальную копию blob'а для `account` (или `null`, если записи нет /
|
||||
* она от другого аккаунта). Подмена account → `null`: чужой blob не отдаём, даже
|
||||
* расшифровать его без чужого пароля всё равно нельзя.
|
||||
*/
|
||||
export async function loadLocalVault(storage: StorageAdapter, account: string): Promise<EncryptedVaultBlob | null> {
|
||||
const raw = await storage.get(LOCAL_VAULT_KEY)
|
||||
if (!raw)
|
||||
return null
|
||||
const record = JSON.parse(raw) as LocalVaultRecord
|
||||
return record.account === account ? record.blob : null
|
||||
}
|
||||
|
||||
/** Удаляет локальную копию vault'а (смена аккаунта / «забыть устройство»). */
|
||||
export async function clearLocalVault(storage: StorageAdapter): Promise<void> {
|
||||
await storage.remove(LOCAL_VAULT_KEY)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { EncryptedVaultBlob } from '../vault/types'
|
||||
import type { StorageAdapter } from './storage-adapter'
|
||||
/**
|
||||
* Локальный PIN-кэш приватного ключа (уточнённая модель CoopID, 2026-06-15 —
|
||||
* СУПЕРДИТ «без PIN» из Story 11.8). Двухуровневая защита ключа:
|
||||
*
|
||||
* 1) Серверный vault ← ПАРОЛЬ (сложный, записан отдельно). Расшифровывается
|
||||
* только при входе; повторно пароль не спрашиваем. Защита от кражи блоба.
|
||||
* 2) Локальный кэш ← ПИН (этот модуль). После входа расшифрованный WIF
|
||||
* перешифровывается ПИНом тем же Argon2id+AES-256-GCM и кладётся в
|
||||
* `StorageAdapter`. Дальнейшие разблокировки (reload, авто-лок по простою) —
|
||||
* ПИНом, НЕ паролём.
|
||||
*
|
||||
* Модель угроз: ПИН защищает не от похищения блоба (от него защищает пароль), а
|
||||
* «от дурака» — постороннего/ребёнка за разблокированным устройством. ПИН по
|
||||
* умолчанию `DEFAULT_PIN` ('000000') делает разблокировку прозрачной (ключ де-факто
|
||||
* лежит локально с тривиальной защитой — осознанная плата за «не вводить пароль
|
||||
* повторно»); кастомный ПИН поднимает планку до анти-«дурак» (6 цифр). AAD
|
||||
* `pin|<account>` привязывает обёртку к аккаунту; account лежит в самой записи,
|
||||
* поэтому знать его заранее не нужно (в отличие от серверного blob'а).
|
||||
*/
|
||||
import { decryptWithPassword, encryptWithPassword } from '../vault/encrypt'
|
||||
|
||||
export type { StorageAdapter } from './storage-adapter'
|
||||
|
||||
const STORAGE_KEY = 'coopid.wallet.pin-vault'
|
||||
|
||||
/** Стандартный «прозрачный» ПИН: разблокировка происходит автоматически и незаметно. */
|
||||
export const DEFAULT_PIN = '000000'
|
||||
|
||||
function aad(account: string): string {
|
||||
return `pin|${account}`
|
||||
}
|
||||
|
||||
interface PinRecord {
|
||||
account: string
|
||||
blob: EncryptedVaultBlob
|
||||
}
|
||||
|
||||
/** Сохраняет ключ под ПИН в storage (перезаписывает предыдущую запись). */
|
||||
export async function savePinProtected(
|
||||
privateKey: string,
|
||||
pin: string,
|
||||
account: string,
|
||||
storage: StorageAdapter,
|
||||
): Promise<void> {
|
||||
const blob = await encryptWithPassword(privateKey, pin, aad(account))
|
||||
const record: PinRecord = { account, blob }
|
||||
await storage.set(STORAGE_KEY, JSON.stringify(record))
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает ключ из storage по ПИН. `null` — записи нет. Неверный ПИН →
|
||||
* `VaultDecryptionFailed` (из ядра). Возвращает `{account, privateKey}` — account
|
||||
* берётся из самой записи, знать его до разблокировки не требуется.
|
||||
*/
|
||||
export async function loadPinProtected(
|
||||
pin: string,
|
||||
storage: StorageAdapter,
|
||||
): Promise<{ account: string, privateKey: string } | null> {
|
||||
const raw = await storage.get(STORAGE_KEY)
|
||||
if (!raw)
|
||||
return null
|
||||
const record = JSON.parse(raw) as PinRecord
|
||||
const privateKey = await decryptWithPassword(record.blob, pin, aad(record.account))
|
||||
return { account: record.account, privateKey }
|
||||
}
|
||||
|
||||
/** Есть ли локальный PIN-кэш (без расшифровки — для выбора сценария разблокировки). */
|
||||
export async function hasPinProtected(storage: StorageAdapter): Promise<boolean> {
|
||||
return (await storage.get(STORAGE_KEY)) !== null
|
||||
}
|
||||
|
||||
/** Удаляет PIN-запись (logout с «забыть устройство» / смена аккаунта). */
|
||||
export async function clearPinProtected(storage: StorageAdapter): Promise<void> {
|
||||
await storage.remove(STORAGE_KEY)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Подключаемый async key-value стор для персистентных артефактов кошелька
|
||||
* (локальная копия vault'а — Story 11.3). PIN-слой снят в 11.8 (модель «без PIN»).
|
||||
*
|
||||
* Абстракция нужна для кросс-рантайма (NFR26): в браузере — обёртка над
|
||||
* localStorage/IndexedDB, в desktop-runtime — своя реализация, в Node/тестах —
|
||||
* память. Крипто и логика хранения от среды не зависят.
|
||||
*/
|
||||
export interface StorageAdapter {
|
||||
get: (key: string) => Promise<string | null>
|
||||
set: (key: string, value: string) => Promise<void>
|
||||
remove: (key: string) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* In-memory keystore (Story 2.2). ЕДИНСТВЕННОЕ место, где живёт расшифрованный
|
||||
* приватный ключ пайщика — module-private переменная, только в RAM процесса/вкладки.
|
||||
*
|
||||
* Публичная поверхность SDK (`getWallet()`) приватный ключ НЕ отдаёт: signing-модули
|
||||
* (Stories 2.3/2.4) берут его через пакет-внутреннюю `readUnlockedKey()`. На logout —
|
||||
* `wipeKeystore()` затирает ключ (перезапись + сброс ссылки).
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
interface UnlockedEntry {
|
||||
account: string
|
||||
publicKey: string
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
/** Открытое состояние сессии. null = заперто. */
|
||||
let unlocked: UnlockedEntry | null = null
|
||||
|
||||
/** Кладёт расшифрованный ключ в keystore (вызывается из unlockWallet). */
|
||||
export function storeUnlocked(entry: UnlockedEntry): void {
|
||||
unlocked = entry
|
||||
}
|
||||
|
||||
/** Заперт ли кошелёк (нет расшифрованного ключа в памяти). */
|
||||
export function isUnlocked(): boolean {
|
||||
return unlocked !== null
|
||||
}
|
||||
|
||||
/** Публичный «вид» кошелька без приватного ключа. Бросает, если заперто. */
|
||||
export function currentView(): { account: string, publicKey: string } {
|
||||
if (!unlocked)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Кошелёк заперт: сначала вызовите unlockWallet()')
|
||||
return { account: unlocked.account, publicKey: unlocked.publicKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакет-внутренний доступ к приватному ключу для signing-модулей (2.3/2.4).
|
||||
* НЕ реэкспортируется из index.ts — наружу ключ не выходит.
|
||||
*/
|
||||
export function readUnlockedKey(): string {
|
||||
if (!unlocked)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Кошелёк заперт: подпись невозможна без unlockWallet()')
|
||||
return unlocked.privateKey
|
||||
}
|
||||
|
||||
/** Затирает ключ из памяти (logout / lockWallet). Идемпотентно. */
|
||||
export function wipeKeystore(): void {
|
||||
if (unlocked) {
|
||||
// Перезаписываем строку-ключ перед сбросом ссылки, чтобы уменьшить окно жизни
|
||||
// в куче (JS-строки иммутабельны — гарантий нет, но снимаем прямую ссылку).
|
||||
unlocked.privateKey = '\0'.repeat(unlocked.privateKey.length)
|
||||
unlocked = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CERTIFICATE_RENEWAL_LEAD_MS,
|
||||
computeRenewalDelayMs,
|
||||
scheduleCertificateRenewal,
|
||||
} from '../src/certificate'
|
||||
|
||||
/** Собрать декодируемый compact-JWS с нужными claims (подпись не проверяется при decode). */
|
||||
function fakeCert(claims: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'ES256K', typ: 'JWT' })).toString('base64url')
|
||||
const payload = Buffer.from(JSON.stringify({ jti: 'serial-1', sub: 'uuid-1', ...claims })).toString('base64url')
|
||||
return `${header}.${payload}.sig`
|
||||
}
|
||||
|
||||
describe('computeRenewalDelayMs', () => {
|
||||
it('обычный кейс: задержка до exp − lead', () => {
|
||||
const nowMs = 1_000_000
|
||||
const exp = Math.floor(nowMs / 1000) + 3600 // +1ч
|
||||
const delay = computeRenewalDelayMs({ exp }, nowMs)
|
||||
expect(delay).toBe(3600 * 1000 - CERTIFICATE_RENEWAL_LEAD_MS)
|
||||
})
|
||||
|
||||
it('до exp осталось ≤ lead → 0 (перезапрос немедленно)', () => {
|
||||
const nowMs = 1_000_000
|
||||
const exp = Math.floor(nowMs / 1000) + 120 // 2 мин < lead (5 мин)
|
||||
expect(computeRenewalDelayMs({ exp }, nowMs)).toBe(0)
|
||||
})
|
||||
|
||||
it('cert уже истёк → 0', () => {
|
||||
const nowMs = 1_000_000
|
||||
const exp = Math.floor(nowMs / 1000) - 10
|
||||
expect(computeRenewalDelayMs({ exp }, nowMs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scheduleCertificateRenewal', () => {
|
||||
it('срабатывает в exp − lead, зовёт renew и перепланирует от нового cert', async () => {
|
||||
let currentNow = 0
|
||||
const timers: Array<{ cb: () => void, delay: number }> = []
|
||||
const setTimer = vi.fn((cb: () => void, delay: number) => {
|
||||
timers.push({ cb, delay })
|
||||
return timers.length as any
|
||||
})
|
||||
const clearTimer = vi.fn()
|
||||
|
||||
const firstExp = 3600 // сек
|
||||
const secondExp = 7200
|
||||
const first = fakeCert({ exp: firstExp })
|
||||
const second = fakeCert({ exp: secondExp })
|
||||
const renew = vi.fn().mockResolvedValue(second)
|
||||
|
||||
scheduleCertificateRenewal(first, renew, { now: () => currentNow, setTimer, clearTimer })
|
||||
|
||||
// первый таймер запланирован на exp − lead
|
||||
expect(setTimer).toHaveBeenCalledTimes(1)
|
||||
expect(timers[0].delay).toBe(firstExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS)
|
||||
|
||||
// время дошло до срабатывания → дёргаем callback
|
||||
currentNow = firstExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS
|
||||
timers[0].cb()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(renew).toHaveBeenCalledTimes(1)
|
||||
// перепланирование от exp нового сертификата
|
||||
expect(setTimer).toHaveBeenCalledTimes(2)
|
||||
expect(timers[1].delay).toBe(secondExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS - currentNow)
|
||||
})
|
||||
|
||||
it('cancel() отменяет запланированный таймер и не перепланирует', async () => {
|
||||
const timers: Array<{ cb: () => void, delay: number }> = []
|
||||
const setTimer = vi.fn((cb: () => void, delay: number) => {
|
||||
timers.push({ cb, delay })
|
||||
return timers.length as any
|
||||
})
|
||||
const clearTimer = vi.fn()
|
||||
const renew = vi.fn().mockResolvedValue(fakeCert({ exp: 7200 }))
|
||||
|
||||
const handle = scheduleCertificateRenewal(fakeCert({ exp: 3600 }), renew, { now: () => 0, setTimer, clearTimer })
|
||||
handle.cancel()
|
||||
expect(clearTimer).toHaveBeenCalledTimes(1)
|
||||
|
||||
// даже если таймер «выстрелит» после cancel — renew не перепланирует
|
||||
timers[0].cb()
|
||||
await Promise.resolve()
|
||||
expect(setTimer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { SignJWT } from 'jose'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CERTIFICATE_EXPIRING_WINDOW_MS,
|
||||
certificateStatus,
|
||||
decodeParticipantCertificate,
|
||||
verificationTypeLabel,
|
||||
} from '../src/certificate'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
|
||||
async function makeCert(overrides: Record<string, unknown> = {}, opts: { jti?: string, exp?: string } = {}): Promise<string> {
|
||||
let b = new SignJWT({
|
||||
coopname: 'voskhod',
|
||||
coop_chain: [
|
||||
{ account: 'ano', public_key: 'PUB_K1_ano' },
|
||||
{ account: 'voskhod', public_key: 'PUB_K1_vos' },
|
||||
{ account: 'vostok', public_key: 'PUB_K1_vostok' },
|
||||
],
|
||||
verification_types: [{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' }],
|
||||
identification: { type: 'individual', username: 'ant', first_name: 'Иван' },
|
||||
claim_schema_version: '1',
|
||||
...overrides,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuer('https://voskhod.coop')
|
||||
.setSubject('uuid-1')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(opts.exp ?? '24h')
|
||||
if (opts.jti !== undefined || overrides.jti === undefined)
|
||||
b = b.setJti(opts.jti ?? 'serial-123')
|
||||
return b.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
|
||||
}
|
||||
|
||||
describe('decodeParticipantCertificate', () => {
|
||||
it('декодирует все claims в типизированный объект', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert())
|
||||
expect(claims.sub).toBe('uuid-1')
|
||||
expect(claims.jti).toBe('serial-123')
|
||||
expect(claims.coopname).toBe('voskhod')
|
||||
expect(claims.claim_schema_version).toBe('1')
|
||||
expect(claims.verification_types).toEqual([
|
||||
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
|
||||
])
|
||||
expect(claims.coop_chain.map(l => l.account)).toEqual(['ano', 'voskhod', 'vostok'])
|
||||
expect(claims.identification).toMatchObject({ type: 'individual', username: 'ant' })
|
||||
expect(claims.exp).toBeGreaterThan(claims.iat)
|
||||
})
|
||||
|
||||
it('identification может быть null', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert({ identification: null }))
|
||||
expect(claims.identification).toBeNull()
|
||||
})
|
||||
|
||||
it('retention-claims читаются (Story 4.8)', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert({
|
||||
data_retention_contract: 'erase_on_exclusion',
|
||||
retention_deadline_ts: 1800000000,
|
||||
}))
|
||||
expect(claims.data_retention_contract).toBe('erase_on_exclusion')
|
||||
expect(claims.retention_deadline_ts).toBe(1800000000)
|
||||
})
|
||||
|
||||
it('retention-claims отсутствуют → безопасные дефолты (Story 4.8)', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert())
|
||||
expect(claims.data_retention_contract).toBe('')
|
||||
expect(claims.retention_deadline_ts).toBe(0)
|
||||
})
|
||||
|
||||
it('verification_types: структурные записи сохраняются, мусор отбрасывается (Story 4.3)', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert({
|
||||
verification_types: [
|
||||
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
|
||||
'legacy_string',
|
||||
{ source: 'no_type' },
|
||||
],
|
||||
}))
|
||||
expect(claims.verification_types).toEqual([
|
||||
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
|
||||
])
|
||||
})
|
||||
|
||||
it('не-JWT строка → AuthV2Error(chain_verification_failed)', () => {
|
||||
let err: unknown
|
||||
try {
|
||||
decodeParticipantCertificate('garbage')
|
||||
}
|
||||
catch (e) {
|
||||
err = e
|
||||
}
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
|
||||
})
|
||||
|
||||
it('без обязательных claims (нет jti) → AuthV2Error', async () => {
|
||||
// токен без jti
|
||||
const noJti = await new SignJWT({ coopname: 'voskhod' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject('uuid-1')
|
||||
.setExpirationTime('24h')
|
||||
.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
|
||||
expect(() => decodeParticipantCertificate(noJti)).toThrow(AuthV2Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('certificateStatus', () => {
|
||||
const now = 1_900_000_000_000 // фиксированный «сейчас» (мс)
|
||||
it('exp в прошлом → expired', () => {
|
||||
expect(certificateStatus({ exp: now / 1000 - 10 }, now)).toBe('expired')
|
||||
})
|
||||
it('exp в окне «истекает» → expiring', () => {
|
||||
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS - 1000) / 1000
|
||||
expect(certificateStatus({ exp }, now)).toBe('expiring')
|
||||
})
|
||||
it('exp далеко → active', () => {
|
||||
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS + 10 * 60 * 1000) / 1000
|
||||
expect(certificateStatus({ exp }, now)).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verificationTypeLabel', () => {
|
||||
it('известный тип → человекочитаемое описание', () => {
|
||||
expect(verificationTypeLabel('coop_baseline')).toBe('Базовое подтверждение кооперативом')
|
||||
})
|
||||
it('неизвестный тип → возвращается как есть', () => {
|
||||
expect(verificationTypeLabel('future_kyc_x')).toBe('future_kyc_x')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Интеграционный тест Story 1.3: SDK читает publickey permission `cert` из COOPOS.
|
||||
* Требует живую дев-ноду: COOPID_TEST_RPC=http://127.0.0.1:8908 pnpm test
|
||||
* Без env — скипается (юнит-прогоны CI не зависят от цепи).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { readCertPublicKey } from '../src/index'
|
||||
|
||||
const RPC = process.env.COOPID_TEST_RPC
|
||||
|
||||
describe.skipIf(!RPC)('readCertPublicKey — против дев-сети COOPOS', () => {
|
||||
it.each(['vostok', 'voskhod', 'ano'])('читает ключ cert у %s', async (account) => {
|
||||
const key = await readCertPublicKey(RPC!, account)
|
||||
expect(key).toMatch(/^(PUB_K1_|EOS)/)
|
||||
})
|
||||
|
||||
it('аккаунт без cert → ChainVerificationFailed', async () => {
|
||||
const err = await readCertPublicKey(RPC!, 'eosio').then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
|
||||
})
|
||||
|
||||
it('несуществующий аккаунт → NetworkError', async () => {
|
||||
const err = await readCertPublicKey(RPC!, 'nonexistacct').then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Electron-таргет cross-runtime smoke (Story 9.13): те же проверки, что в
|
||||
* smoke.test.ts, но против СОБРАННОГО артефакта (dist/index.cjs — то, что
|
||||
* реально получают клиенты) в main-process electron. Запуск — через
|
||||
* run-electron.mjs (`pnpm test:electron`), не напрямую.
|
||||
*
|
||||
* Без vitest: минимальный assert + exit-код, чтобы не тащить тест-раннер в
|
||||
* electron. Сценарии держать синхронными со smoke.test.ts.
|
||||
*/
|
||||
const { app } = require('electron')
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
function assert(cond, name) {
|
||||
if (!cond)
|
||||
throw new Error(`smoke-проверка провалена: ${name}`)
|
||||
console.log(`[electron-smoke] OK ${name}`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const sdk = require('../../dist/index.cjs')
|
||||
|
||||
// vault round-trip + unlockWallet (WebCrypto AES-GCM + Argon2id в main-process)
|
||||
const blob = await sdk.encryptPrivateKey(KEY, PW, { subject_type: 'participant', subject_id: ACCOUNT })
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify(blob), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
const wallet = await sdk.unlockWallet({ apiUrl: 'https://coop.stub', account: ACCOUNT, password: PW })
|
||||
assert(wallet.account === ACCOUNT && wallet.publicKey.startsWith('PUB_K1_'), 'vault round-trip + unlockWallet')
|
||||
assert(sdk.isWalletUnlocked() === true, 'isWalletUnlocked после unlock')
|
||||
|
||||
// signDocument → verifyDocumentOffline
|
||||
const jws = await sdk.signDocument({ payload: 'кросс-рантайм smoke-документ' })
|
||||
assert(jws.split('.').length === 3, 'signDocument: compact JWS')
|
||||
const verdict = await sdk.verifyDocumentOffline({
|
||||
jws,
|
||||
signedAtBlock: 100,
|
||||
authority: [{ public_key: wallet.publicKey, valid_from_block: 1, valid_to_block: null }],
|
||||
})
|
||||
assert(verdict.valid === true, 'verifyDocumentOffline подтверждает подпись')
|
||||
|
||||
// запертый кошелёк → типизированная WalletLocked
|
||||
sdk.lockWallet()
|
||||
const lockedErr = await sdk.signDocument({ payload: 'x' }).then(() => null, e => e)
|
||||
assert(lockedErr instanceof sdk.AuthV2Error && lockedErr.code === sdk.AuthV2ErrorCode.WalletLocked, 'WalletLocked при запертом кошельке')
|
||||
|
||||
// login/getAccessToken реализованы (вход 1.7 + flow-executor Story 11.2): в stub-окружении
|
||||
// без живого authentik/controller дают типизированную AuthV2Error (login→network_error,
|
||||
// getAccessToken→wallet_locked). Конкретный код не пиннингуем — важно, что ошибка
|
||||
// типизированная и код валиден для enum'а. Синхронно со smoke.test.ts.
|
||||
const errorCodes = Object.values(sdk.AuthV2ErrorCode)
|
||||
for (const [name, call] of [
|
||||
['login', () => sdk.login({ issuer: 'https://coop.stub/application/o/coopid/', email: 'a@b.c', password: 'p' })],
|
||||
['getAccessToken', () => sdk.getAccessToken()],
|
||||
]) {
|
||||
const err = await call().then(() => null, e => e)
|
||||
assert(err instanceof sdk.AuthV2Error && errorCodes.includes(err.code), `${name}: типизированная AuthV2Error`)
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(run).then(
|
||||
() => {
|
||||
console.log('[electron-smoke] все проверки пройдены')
|
||||
app.exit(0)
|
||||
},
|
||||
(e) => {
|
||||
console.error('[electron-smoke] FAIL:', e)
|
||||
app.exit(1)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Лаунчер electron-таргета (Story 9.13): резолвит бинарь electron, проверяет
|
||||
* наличие dist (smoke идёт против собранного артефакта), на безголовом хосте
|
||||
* заворачивает запуск в `xvfb-run -a` (electron на Linux требует X-сервер).
|
||||
* `--no-sandbox` — для контейнерных/CI-окружений без user namespaces.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import electronPath from 'electron'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pkgRoot = join(here, '..', '..')
|
||||
|
||||
if (!existsSync(join(pkgRoot, 'dist', 'index.cjs'))) {
|
||||
console.error('[electron-smoke] dist/index.cjs не найден — сначала `pnpm build` (test:cross-runtime делает это сам)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const electronArgs = ['--no-sandbox', join(here, 'electron-main.cjs')]
|
||||
const needXvfb = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
|
||||
|
||||
const [cmd, args] = needXvfb
|
||||
? ['xvfb-run', ['-a', electronPath, ...electronArgs]]
|
||||
: [electronPath, electronArgs]
|
||||
|
||||
const res = spawnSync(cmd, args, { stdio: 'inherit', cwd: pkgRoot })
|
||||
|
||||
if (res.error) {
|
||||
console.error(`[electron-smoke] не удалось запустить ${cmd}: ${res.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(res.status ?? 1)
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Cross-runtime smoke (Story 9.13): один и тот же сценарий гоняется в Node
|
||||
* (`pnpm test:node`) и в браузере (`pnpm test:browser`, chromium headless);
|
||||
* electron-таргет (`pnpm test:electron`) выполняет те же проверки против
|
||||
* собранного dist (см. electron-main.cjs).
|
||||
*
|
||||
* Правила файла:
|
||||
* - только ПУБЛИЧНЫЙ API пакета (как у клиентов) и никаких Node-специфичных
|
||||
* глобалов (Buffer, process, fs) — иначе браузерный таргет перестаёт быть
|
||||
* честным; сеть стабится через globalThis.fetch;
|
||||
* - дорогой Argon2id (KDF vault'а) выполняется ОДИН раз в beforeAll: в
|
||||
* chromium pure-JS KDF в разы медленнее Node (минуты), per-test KDF
|
||||
* упирается в таймауты. Тесты зависят от порядка: lockWallet — последний.
|
||||
*/
|
||||
import type { Wallet } from '../../src'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AuthV2Error,
|
||||
AuthV2ErrorCode,
|
||||
encryptPrivateKey,
|
||||
getAccessToken,
|
||||
isWalletUnlocked,
|
||||
lockWallet,
|
||||
login,
|
||||
signDocument,
|
||||
unlockWallet,
|
||||
verifyDocumentOffline,
|
||||
} from '../../src'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
describe('cross-runtime smoke: vault → wallet → подпись → верификация', () => {
|
||||
let wallet: Wallet
|
||||
|
||||
beforeAll(async () => {
|
||||
// Стаб контроллера: GET /coop/vault/... отдаёт заранее зашифрованный blob.
|
||||
const blob = await encryptPrivateKey(KEY, PW, { subject_type: 'participant', subject_id: ACCOUNT })
|
||||
vi.stubGlobal('fetch', async () =>
|
||||
new Response(JSON.stringify(blob), { status: 200, headers: { 'content-type': 'application/json' } }))
|
||||
wallet = await unlockWallet({ apiUrl: 'https://coop.stub', account: ACCOUNT, password: PW })
|
||||
// 10 мин: два прогона Argon2id (encrypt + unlock) — в браузере это минуты
|
||||
}, 600_000)
|
||||
|
||||
afterAll(() => {
|
||||
lockWallet()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('vault round-trip + unlockWallet: WebCrypto AES-GCM и Argon2id работают в этом рантайме', () => {
|
||||
expect(wallet.account).toBe(ACCOUNT)
|
||||
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
})
|
||||
|
||||
it('signDocument → compact JWS, verifyDocumentOffline подтверждает подпись', async () => {
|
||||
const jws = await signDocument({ payload: 'кросс-рантайм smoke-документ' })
|
||||
expect(jws.split('.')).toHaveLength(3)
|
||||
|
||||
const verdict = await verifyDocumentOffline({
|
||||
jws,
|
||||
signedAtBlock: 100,
|
||||
authority: [{ public_key: wallet.publicKey, valid_from_block: 1, valid_to_block: null }],
|
||||
})
|
||||
expect(verdict.valid).toBe(true)
|
||||
})
|
||||
|
||||
// SDK-сторона login/getAccessToken реализована (вход 1.7 + flow-executor Story 11.2).
|
||||
// Smoke фиксирует кросс-рантайм: модуль грузится, функции вызываемы, канал
|
||||
// типизированных ошибок работает в каждом рантайме. В stub-окружении без живого
|
||||
// authentik/controller: login → AuthV2Error(network_error) (flow-executor не достучался),
|
||||
// getAccessToken → AuthV2Error(wallet_locked) (нет активной сессии). Конкретный код
|
||||
// не пиннингуем жёстко — важно, что ошибка типизированная (AuthV2Error) в любом рантайме.
|
||||
it('login()/getAccessToken(): вызываемы и дают типизированную AuthV2Error в каждом рантайме', async () => {
|
||||
for (const call of [
|
||||
() => login({ issuer: 'https://coop.stub/application/o/coopid/', email: 'a@b.c', password: 'p' }),
|
||||
() => getAccessToken(),
|
||||
]) {
|
||||
const err = await call().then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
// код — валидное значение enum'а (канал типизированных ошибок целостен)
|
||||
expect(Object.values(AuthV2ErrorCode)).toContain((err as AuthV2Error).code)
|
||||
}
|
||||
})
|
||||
|
||||
// Последний по порядку: запирает общий кошелёк из beforeAll
|
||||
it('запертый кошелёк: signDocument даёт типизированную WalletLocked, не runtime-краш', async () => {
|
||||
lockWallet()
|
||||
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from '../src/index'
|
||||
|
||||
describe('describeAuthV2Error (Story 1.11)', () => {
|
||||
it('каталог покрывает каждый код ошибки', () => {
|
||||
for (const code of Object.values(AuthV2ErrorCode))
|
||||
expect(AUTH_V2_ERROR_VIEWS[code], code).toBeDefined()
|
||||
})
|
||||
|
||||
it('по AC: неверные учётные данные — общее сообщение без раскрытия поля', () => {
|
||||
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'whatever'))
|
||||
expect(view.code).toBe(AuthV2ErrorCode.InvalidCredentials)
|
||||
expect(view.message).toBe('Неверный email или пароль.')
|
||||
// перечисляет оба поля через «или» — не раскрывает, какое именно неверно (security)
|
||||
expect(view.message).toMatch(/email или пароль/i)
|
||||
expect(view.message).not.toMatch(/не найден|не существует|нет такого|такой email/i)
|
||||
expect(view.keepSession).toBe(false)
|
||||
})
|
||||
|
||||
it('по AC: vault не расшифровывается — предлагает восстановление доступа', () => {
|
||||
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'bad key'))
|
||||
expect(view.action).toBe('recover')
|
||||
expect(view.message).toMatch(/восстановить доступ/i)
|
||||
})
|
||||
|
||||
it('по AC: сетевая ошибка — «проверьте интернет» и НЕ разлогинивает (NFR20/FR50)', () => {
|
||||
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.NetworkError, 'fetch failed'))
|
||||
expect(view.action).toBe('check_connection')
|
||||
expect(view.message).toMatch(/проверьте интернет/i)
|
||||
expect(view.keepSession).toBe(true)
|
||||
})
|
||||
|
||||
it('внешний сбой (кооператив недоступен) — сессия сохраняется', () => {
|
||||
expect(describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'down')).keepSession).toBe(true)
|
||||
})
|
||||
|
||||
it('неожиданная (не-AuthV2) ошибка — безопасный фолбэк без утечки technical message', () => {
|
||||
const view = describeAuthV2Error(new Error('TypeError: cannot read property x of undefined'))
|
||||
expect(view.code).toBeNull()
|
||||
expect(view.message).not.toMatch(/TypeError|undefined/)
|
||||
expect(view.keepSession).toBe(true)
|
||||
})
|
||||
|
||||
it('сообщение берётся из каталога, а не из e.message сервера', () => {
|
||||
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.TimestampTooOld, 'raw server text'))
|
||||
expect(view.message).not.toBe('raw server text')
|
||||
expect(view.message).toBe(AUTH_V2_ERROR_VIEWS[AuthV2ErrorCode.TimestampTooOld].message)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { exportFullQR } from '../src/export'
|
||||
import { lockWallet } from '../src/wallet'
|
||||
import { storeUnlocked } from '../src/wallet/storage'
|
||||
|
||||
const CERT = 'header.payload.sig'
|
||||
|
||||
afterEach(() => lockWallet())
|
||||
|
||||
function unlock(): void {
|
||||
storeUnlocked({ account: 'ant', publicKey: 'PUB_K1_ant', privateKey: '5KdummyWIF' })
|
||||
}
|
||||
|
||||
describe('exportFullQR (Story 4.9)', () => {
|
||||
it('заперт vault → WalletLocked, consent даже не запрашивается', async () => {
|
||||
lockWallet()
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
await expect(exportFullQR(CERT, { confirm })).rejects.toMatchObject({ code: AuthV2ErrorCode.WalletLocked })
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consent отклонён → ConsentRequired', async () => {
|
||||
unlock()
|
||||
await expect(exportFullQR(CERT, { confirm: async () => false })).rejects.toMatchObject({ code: AuthV2ErrorCode.ConsentRequired })
|
||||
})
|
||||
|
||||
it('reject в confirm трактуется как отказ → ConsentRequired', async () => {
|
||||
unlock()
|
||||
const confirm = async (): Promise<boolean> => {
|
||||
throw new Error('dialog closed')
|
||||
}
|
||||
await expect(exportFullQR(CERT, { confirm })).rejects.toMatchObject({ code: AuthV2ErrorCode.ConsentRequired })
|
||||
})
|
||||
|
||||
it('разблокирован + consent дан → payload-байты с данными сертификата', async () => {
|
||||
unlock()
|
||||
const bytes = await exportFullQR(CERT, { confirm: async () => true })
|
||||
expect(bytes).toBeInstanceOf(Uint8Array)
|
||||
expect(new TextDecoder().decode(bytes)).toBe(CERT)
|
||||
})
|
||||
|
||||
it('ошибки — типизированная AuthV2Error', async () => {
|
||||
lockWallet()
|
||||
const err = await exportFullQR(CERT, { confirm: async () => true }).catch(e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as api from '../src/index'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/index'
|
||||
|
||||
const PUBLIC_API = [
|
||||
'login',
|
||||
'loginWithMagicLink',
|
||||
'recover',
|
||||
'getAccessToken',
|
||||
'getParticipantCertificate',
|
||||
'logout',
|
||||
'verifyOffline',
|
||||
'signDocument',
|
||||
'signTimestamp',
|
||||
'getWallet',
|
||||
'rotateKey',
|
||||
'exportFullQR',
|
||||
] as const
|
||||
|
||||
// Методы, ещё не реализованные (бросают not_implemented). По мере реализации
|
||||
// историй метод уходит отсюда: getWallet — 2.2, signTimestamp — 2.4, verifyOffline — 4.4,
|
||||
// exportFullQR — 4.9 (гейтится unlocked vault + consent, не not_implemented), login —
|
||||
// 1.7/11.2 (flow-executor → network_error без живого authentik), recover — Эпик 3,
|
||||
// getAccessToken — 1.7 (wallet_locked без сессии), loginWithMagicLink — 12.2 (confirm-флоу
|
||||
// + повторный handshake). Реальный stub остался один: rotateKey (3.3).
|
||||
const IMPLEMENTED = new Set(['getWallet', 'signTimestamp', 'getParticipantCertificate', 'logout', 'signDocument', 'verifyOffline', 'exportFullQR', 'login', 'recover', 'getAccessToken', 'loginWithMagicLink'])
|
||||
const STILL_STUBBED = PUBLIC_API.filter(m => !IMPLEMENTED.has(m))
|
||||
|
||||
describe('@coopenomics/auth — скелет SDK', () => {
|
||||
it('экспортирует всю публичную поверхность', () => {
|
||||
for (const method of PUBLIC_API)
|
||||
expect(api[method], method).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('stub\'ы отказывают типизированной AuthV2Error(not_implemented), а не молчат', async () => {
|
||||
for (const method of STILL_STUBBED) {
|
||||
const err = await (api[method] as () => Promise<unknown>)().then(
|
||||
() => null,
|
||||
e => e,
|
||||
)
|
||||
expect(err, method).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code, method).toBe(AuthV2ErrorCode.NotImplemented)
|
||||
}
|
||||
})
|
||||
|
||||
it('ошибка сериализуется в формат OAuth 2.0', () => {
|
||||
const err = new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Неверный email или пароль')
|
||||
expect(err.toJSON()).toEqual({
|
||||
error: 'invalid_credentials',
|
||||
error_description: 'Неверный email или пароль',
|
||||
})
|
||||
})
|
||||
|
||||
it('exportProofQR НЕ экспортируется в MVP (anonymous-форма — Growth, Story 4.9)', () => {
|
||||
expect((api as Record<string, unknown>).exportProofQR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('trust anchor и список нод доступны (placeholder до release-pin/9.5)', () => {
|
||||
expect(api.TRUST_ANCHOR_ANO_CERT_PUBKEY).toBeNull()
|
||||
expect(api.COOPOS_PUBLIC_NODES).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const lockWallet = vi.fn()
|
||||
|
||||
// keystore-затирание мокаем на уровне модуля wallet — logout обязан вызвать его
|
||||
// независимо от исхода сетевого запроса.
|
||||
vi.mock('../src/wallet', () => ({
|
||||
lockWallet,
|
||||
}))
|
||||
|
||||
const { logout } = await import('../src/oidc')
|
||||
|
||||
describe('logout (Story 1.10)', () => {
|
||||
beforeEach(() => {
|
||||
lockWallet.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('шлёт POST /coop/logout с refresh/access и затирает keystore', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await logout({ apiUrl: 'https://coop.example/', refreshToken: 'r1', accessToken: 'a1' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, opts] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://coop.example/coop/logout')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ refresh_token: 'r1', access_token: 'a1' })
|
||||
expect(lockWallet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('затирает keystore ДАЖЕ при недоступности сервера (finally)', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(logout({ apiUrl: 'https://coop.example', refreshToken: 'r1' })).resolves.toBeUndefined()
|
||||
expect(lockWallet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { canonicalMigrationMessage, migrate } from '../src/migration'
|
||||
import { configureCoopId } from '../src/oidc/client'
|
||||
|
||||
const WIF = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
|
||||
beforeEach(() => configureCoopId({ apiUrl: 'https://coop.example' }))
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('canonicalMigrationMessage (Story 11.4)', () => {
|
||||
it('фиксированный алфавитный порядок ключей: pw_hash, purpose, ts', () => {
|
||||
expect(canonicalMigrationMessage({ ts: 'T', pw_hash: 'H' }))
|
||||
.toBe('{"pw_hash":"H","purpose":"coopid-key-migration","ts":"T"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrate (Story 11.4) — «ключ → пароль»', () => {
|
||||
it('невалидный WIF → InvalidCredentials, без сетевого запроса', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(migrate({ email: 'a@e.com', privateKey: 'not-a-key', newPassword: 'Strong#Pass1' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'invalid_credentials' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ошибка сервера (400 weak_password) пробрасывается; vault не сохраняется', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ error: 'weak_password', error_description: 'Пароль слишком короткий' }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(migrate({ email: 'a@e.com', privateKey: WIF, newPassword: 'weakweak' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'weak_password' })
|
||||
// только POST /coop/migration, без POST /coop/vault
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/migration')
|
||||
})
|
||||
|
||||
it('happy path: POST /coop/migration → username, затем saveToVault (POST /coop/vault)', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ username: 'ant' }) }) // migration
|
||||
.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) }) // vault store
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await migrate({ email: 'a@e.com', privateKey: WIF, newPassword: 'Strong#Pass1' })
|
||||
|
||||
expect(result).toEqual({ username: 'ant' })
|
||||
// 1) запрос миграции
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/migration')
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(body.email).toBe('a@e.com')
|
||||
expect(body.new_password).toBe('Strong#Pass1')
|
||||
expect(typeof body.timestamp).toBe('string')
|
||||
expect(body.signature).toMatch(/^SIG_/)
|
||||
// 2) сохранение vault новым паролём (subject = возвращённый username)
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('https://coop.example/coop/vault')
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toMatchObject({ subject_type: 'participant', subject_id: 'ant' })
|
||||
}, 60000)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { authenticateWithFlowExecutor } from '../src/oidc/flow-executor'
|
||||
|
||||
const ISSUER = 'https://coop.example/application/o/coopid/'
|
||||
const FLOW_URL = 'https://coop.example/api/v3/flows/executor/default-authentication-flow/?query='
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
/** Ответ flow-executor: 200 + тело challenge'а. */
|
||||
function challenge(body: unknown, status = 200) {
|
||||
return { ok: status < 400, status, json: async () => body }
|
||||
}
|
||||
|
||||
describe('authenticateWithFlowExecutor (Story 11.2) — встроенный фактор-1 authentik', () => {
|
||||
it('раздельные стадии identification → password → redirect: успех, верные URL/тела/credentials', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-identification' })) // GET
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' })) // POST email
|
||||
.mockResolvedValueOnce(challenge({ type: 'redirect' })) // POST password → завершение
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'user@e.com', password: 'S3cret!' })
|
||||
|
||||
// GET старта flow
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(FLOW_URL)
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('GET')
|
||||
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
|
||||
// POST identification — uid_field + password (совмещённая форма безопасна и для раздельной)
|
||||
expect(fetchMock.mock.calls[1][1].method).toBe('POST')
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ uid_field: 'user@e.com', password: 'S3cret!' })
|
||||
// POST password
|
||||
expect(JSON.parse(fetchMock.mock.calls[2][1].body)).toEqual({ password: 'S3cret!' })
|
||||
expect(fetchMock.mock.calls[2][1].credentials).toBe('include')
|
||||
})
|
||||
|
||||
it('совмещённая identification сразу завершает flow (redirect)', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-identification' }))
|
||||
.mockResolvedValueOnce(challenge({ type: 'redirect' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('завершение по стадии ak-stage-user-login (сессия установлена)', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-user-login' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('неверный пароль (response_errors, HTTP 400) → InvalidCredentials с сообщением authentik', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password', response_errors: { password: [{ string: 'Неверный пароль', code: 'invalid' }] } }, 400))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'bad' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'invalid_credentials', message: 'Неверный пароль' })
|
||||
})
|
||||
|
||||
it('ak-stage-access-denied → InvalidCredentials', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(challenge({ component: 'ak-stage-access-denied' })))
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'invalid_credentials' })
|
||||
})
|
||||
|
||||
it('неподдерживаемая интерактивная стадия (MFA) → InvalidCredentials с её именем', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-authenticator-validate' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'invalid_credentials', message: expect.stringContaining('ak-stage-authenticator-validate') })
|
||||
})
|
||||
|
||||
it('сеть упала на старте → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'network_error' })
|
||||
})
|
||||
|
||||
it('эхо CSRF-cookie уходит в заголовке X-authentik-CSRF (same-origin)', async () => {
|
||||
vi.stubGlobal('document', { cookie: 'foo=1; authentik_csrf=tok123; bar=2' })
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
|
||||
.mockResolvedValueOnce(challenge({ type: 'redirect' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })
|
||||
expect(fetchMock.mock.calls[0][1].headers['X-authentik-CSRF']).toBe('tok123')
|
||||
expect(fetchMock.mock.calls[1][1].headers['X-authentik-CSRF']).toBe('tok123')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PrivateKey } from '@wharfkit/antelope'
|
||||
import { SignJWT } from 'jose'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error } from '../src/errors'
|
||||
import { performTimestampHandshake } from '../src/oidc/handshake'
|
||||
import { clearSession, currentTokens } from '../src/oidc/tokens'
|
||||
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PUB = PrivateKey.from(KEY).toPublic().toString()
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
async function bindingTokenFor(sub: string, jti = 'jti-1'): Promise<string> {
|
||||
return new SignJWT({ stage_completed: 'password' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(sub)
|
||||
.setJti(jti)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('120s')
|
||||
.sign(new TextEncoder().encode('test-binding-secret'))
|
||||
}
|
||||
|
||||
function okJson(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearSession()
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wipeKeystore()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('performTimestampHandshake (Story 1.7) — handshake внутрь SDK', () => {
|
||||
it('bind → sign → verify: токены/сертификат + сессия сохранена; credentials:include на обоих', async () => {
|
||||
const binding = await bindingTokenFor(ACCOUNT)
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
|
||||
.mockResolvedValueOnce(okJson({ access_token: 'acc', refresh_token: 'ref', participant_certificate: 'cert', degraded: false }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const r = await performTimestampHandshake('https://coop.example/')
|
||||
|
||||
expect(r).toMatchObject({ accessToken: 'acc', refreshToken: 'ref', participantCertificate: 'cert', degraded: false })
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/session/bind')
|
||||
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('https://coop.example/coop/verify/timestamp')
|
||||
expect(fetchMock.mock.calls[1][1].credentials).toBe('include')
|
||||
const verifyBody = JSON.parse(fetchMock.mock.calls[1][1].body)
|
||||
expect(verifyBody.binding_token).toBe(binding)
|
||||
expect(typeof verifyBody.signature).toBe('string')
|
||||
expect(typeof verifyBody.timestamp).toBe('string')
|
||||
expect(currentTokens()).toEqual({ accessToken: 'acc', refreshToken: 'ref' })
|
||||
})
|
||||
|
||||
it('degraded-вход (узел недоступен/ключ не финализирован) прокидывается', async () => {
|
||||
const binding = await bindingTokenFor(ACCOUNT)
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
|
||||
.mockResolvedValueOnce(okJson({ access_token: 'a', refresh_token: 'r', degraded: true, degraded_reason: 'rpc_unavailable' })))
|
||||
|
||||
const r = await performTimestampHandshake('https://coop.example')
|
||||
expect(r.degraded).toBe(true)
|
||||
expect(r.degradedReason).toBe('rpc_unavailable')
|
||||
})
|
||||
|
||||
it('bind 401 → InvalidCredentials (сессия authentik не подтверждена), сессия не сохраняется', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) }))
|
||||
await expect(performTimestampHandshake('https://coop.example')).rejects.toBeInstanceOf(AuthV2Error)
|
||||
expect(currentTokens()).toBeNull()
|
||||
})
|
||||
|
||||
it('verify !ok → AuthV2Error с кодом из тела (OAuth2 error)', async () => {
|
||||
const binding = await bindingTokenFor(ACCOUNT)
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ error: 'chain_verification_failed', error_description: 'Подпись не соответствует ключу' }) }))
|
||||
|
||||
await expect(performTimestampHandshake('https://coop.example')).rejects.toMatchObject({ code: 'chain_verification_failed' })
|
||||
})
|
||||
|
||||
it('кошелёк заперт → WalletLocked (подпись невозможна, verify не вызывается)', async () => {
|
||||
wipeKeystore()
|
||||
const binding = await bindingTokenFor(ACCOUNT)
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(performTimestampHandshake('https://coop.example')).rejects.toMatchObject({ code: 'wallet_locked' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1) // только bind, до verify не дошли
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Граница повторного входа (authentik-сессия + on-chain timestamp-handshake) — сетевые
|
||||
// интеграции; мокаем их, чтобы юнит-тест проверял детерминированный confirm-флоу:
|
||||
// генерацию пары, AAD vault'а, тело запроса confirm и маппинг ошибок. Крипто vault'а
|
||||
// и antelope здесь настоящие — round-trip ключа проверяется по-настоящему.
|
||||
vi.mock('../src/oidc/client', async importActual => ({
|
||||
...(await importActual<typeof import('../src/oidc/client')>()),
|
||||
authenticateWithAuthentik: vi.fn().mockResolvedValue({ id_token: 'ID_TOK' }),
|
||||
}))
|
||||
vi.mock('../src/oidc/handshake', async importActual => ({
|
||||
...(await importActual<typeof import('../src/oidc/handshake')>()),
|
||||
performTimestampHandshake: vi.fn().mockResolvedValue({ accessToken: 'AT', refreshToken: 'RT', participantCertificate: 'CERT', degraded: false }),
|
||||
}))
|
||||
vi.mock('../src/wallet', async importActual => ({
|
||||
...(await importActual<typeof import('../src/wallet')>()),
|
||||
unlockWallet: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const { loginWithMagicLink } = await import('../src/oidc')
|
||||
const { authenticateWithAuthentik, configureCoopId } = await import('../src/oidc/client')
|
||||
const { performTimestampHandshake } = await import('../src/oidc/handshake')
|
||||
const { decryptPrivateKey } = await import('../src/vault/encrypt')
|
||||
const { unlockWallet } = await import('../src/wallet')
|
||||
|
||||
const BASE = 'https://coop.example'
|
||||
// account больше НЕ параметр — сервер возвращает его в ответе confirm (резолвит из токена).
|
||||
const ACCOUNT = 'ant'
|
||||
const PARAMS = {
|
||||
issuer: 'https://coop.example/application/o/coopid/',
|
||||
email: 'ant@example.com',
|
||||
token: 'magic-token-xyz',
|
||||
totp: '123456',
|
||||
newPassword: 'Strong#NewPass1',
|
||||
}
|
||||
|
||||
beforeEach(() => configureCoopId({ apiUrl: BASE }))
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('loginWithMagicLink (Story 12.2) — восстановление доступа', () => {
|
||||
it('happy: новая пара → корректное тело confirm → vault round-trip → повторный вход', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ username: ACCOUNT }) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await loginWithMagicLink(PARAMS)
|
||||
|
||||
// единственный сетевой вызов — confirm (unlock/handshake/authentik замоканы).
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/coop/recovery/confirm`)
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(body.token).toBe(PARAMS.token)
|
||||
expect(body.code).toBe(PARAMS.totp)
|
||||
expect(body.password).toBe(PARAMS.newPassword)
|
||||
expect(body.public_key).toMatch(/^PUB_K1_/)
|
||||
expect(body.vault).toMatchObject({ cipher_version: expect.any(String), salt: expect.any(String), ciphertext: expect.any(String) })
|
||||
|
||||
// AAD больше НЕ зависит от account: тот же блоб расшифровывается под ЛЮБЫМ
|
||||
// subject_id (важен только тип субъекта) — регресс-гард решения 2026-06-15.
|
||||
// И приватный ключ соответствует отправленному public_key — целостность пары.
|
||||
const wif = await decryptPrivateKey(body.vault, PARAMS.newPassword, { subject_type: 'participant', subject_id: 'any-other-account' })
|
||||
expect(PrivateKey.from(wif).toPublic().toString()).toBe(body.public_key)
|
||||
|
||||
// повторный вход новым контуром выполнен; account взят из ответа confirm.
|
||||
expect(authenticateWithAuthentik).toHaveBeenCalledWith(expect.objectContaining({ email: PARAMS.email, password: PARAMS.newPassword, issuer: PARAMS.issuer }))
|
||||
expect(unlockWallet).toHaveBeenCalledWith({ apiUrl: BASE, account: ACCOUNT, password: PARAMS.newPassword })
|
||||
expect(performTimestampHandshake).toHaveBeenCalledWith(BASE)
|
||||
expect(result).toEqual({ accessToken: 'AT', idToken: 'ID_TOK', participantCertificate: 'CERT' })
|
||||
}, 60000)
|
||||
|
||||
it('confirm вернул 200 без username → InvalidRecoveryToken; повторный вход не запускается', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }))
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_recovery_token' })
|
||||
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
|
||||
expect(performTimestampHandshake).not.toHaveBeenCalled()
|
||||
}, 60000)
|
||||
|
||||
it('неверный TOTP (400 invalid_2fa_code) → проброс; повторный вход не запускается', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'invalid_2fa_code', error_description: 'Неверный код' }) }))
|
||||
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_2fa_code' })
|
||||
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
|
||||
expect(performTimestampHandshake).not.toHaveBeenCalled()
|
||||
}, 60000)
|
||||
|
||||
it('недействительный токен (400 invalid_recovery_token) → проброс', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'invalid_recovery_token', error_description: 'Ссылка недействительна' }) }))
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_recovery_token' })
|
||||
}, 60000)
|
||||
|
||||
it('429 → TooManyRecoveryAttempts', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }))
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'too_many_recovery_attempts' })
|
||||
}, 60000)
|
||||
|
||||
it('сеть упала на confirm → NetworkError; повторный вход не запускается', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'network_error' })
|
||||
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
|
||||
}, 60000)
|
||||
|
||||
it('storage задан → локальная копия нового блоба сохранена под account', async () => {
|
||||
const store = new Map<string, string>()
|
||||
const storage = {
|
||||
get: vi.fn(async (k: string) => store.get(k) ?? null),
|
||||
set: vi.fn(async (k: string, v: string) => { store.set(k, v) }),
|
||||
remove: vi.fn(async (k: string) => { store.delete(k) }),
|
||||
}
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ username: ACCOUNT }) }))
|
||||
|
||||
await loginWithMagicLink({ ...PARAMS, storage })
|
||||
|
||||
expect(storage.set).toHaveBeenCalledTimes(1)
|
||||
const saved = JSON.parse(storage.set.mock.calls[0][1] as string)
|
||||
expect(saved.account).toBe(ACCOUNT)
|
||||
expect(saved.blob).toMatchObject({ cipher_version: expect.any(String) })
|
||||
}, 60000)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { recover } from '../src/oidc'
|
||||
import { configureCoopId } from '../src/oidc/client'
|
||||
|
||||
beforeEach(() => configureCoopId({ apiUrl: 'https://coop.example' }))
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('recover (Эпик 3) — запрос восстановления доступа', () => {
|
||||
it('шлёт POST /coop/recovery/request с email (202 — нормальный путь, анти-enumeration)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await recover('user@example.com')
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/recovery/request')
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ email: 'user@example.com' })
|
||||
})
|
||||
|
||||
it('429 → TooManyRecoveryAttempts', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }))
|
||||
await expect(recover('u@e.com')).rejects.toMatchObject({ code: 'too_many_recovery_attempts' })
|
||||
})
|
||||
|
||||
it('сеть упала → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
|
||||
await expect(recover('u@e.com')).rejects.toMatchObject({ code: 'network_error' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { StorageAdapter } from '../src/wallet'
|
||||
import { SignJWT } from 'jose'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error } from '../src/errors'
|
||||
import { clearSession, configureTokenStorage, currentTokens, getAccessToken, restoreSession, setSession } from '../src/oidc/tokens'
|
||||
|
||||
/** JWT с заданным exp (сек от now); содержимое не важно — getAccessToken читает только exp. */
|
||||
async function jwtExpIn(seconds: number): Promise<string> {
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${seconds}s`)
|
||||
.sign(new TextEncoder().encode('t'))
|
||||
}
|
||||
|
||||
function okJson(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body }
|
||||
}
|
||||
|
||||
/** In-memory StorageAdapter для тестов персистентности токенов. */
|
||||
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
|
||||
const m = new Map<string, string>()
|
||||
return {
|
||||
get: async k => m.get(k) ?? null,
|
||||
set: async (k, v) => void m.set(k, v),
|
||||
remove: async k => void m.delete(k),
|
||||
dump: () => Object.fromEntries(m),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => clearSession())
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
configureTokenStorage(null)
|
||||
clearSession()
|
||||
})
|
||||
|
||||
describe('getAccessToken lifecycle (Story 1.7) — токен внутри SDK, авто-refresh', () => {
|
||||
it('нет активной сессии → бросает', async () => {
|
||||
await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
|
||||
it('свежий access → возвращается без обращения к сети', async () => {
|
||||
const access = await jwtExpIn(3600)
|
||||
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
expect(await getAccessToken()).toBe(access)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('истёкший access → refresh через /coop/refresh, новая пара сохраняется', async () => {
|
||||
const expired = await jwtExpIn(-10)
|
||||
const fresh = await jwtExpIn(3600)
|
||||
setSession('https://coop.example/', { accessToken: expired, refreshToken: 'r-old' })
|
||||
const fetchMock = vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
expect(await getAccessToken()).toBe(fresh)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/refresh')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ refresh_token: 'r-old' })
|
||||
expect(currentTokens()).toEqual({ accessToken: fresh, refreshToken: 'r-new' })
|
||||
})
|
||||
|
||||
it('refresh 401 → SessionBindingExpired (нужен повторный вход)', async () => {
|
||||
const expired = await jwtExpIn(-10)
|
||||
setSession('https://coop.example', { accessToken: expired, refreshToken: 'r' })
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }))
|
||||
|
||||
await expect(getAccessToken()).rejects.toMatchObject({ code: 'session_binding_expired' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('персистентность токенов (паритет с легаси — переживание reload)', () => {
|
||||
it('setSession персистит пару токенов в storage', async () => {
|
||||
const storage = memoryStorage()
|
||||
const access = await jwtExpIn(3600)
|
||||
configureTokenStorage(storage)
|
||||
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
|
||||
expect(JSON.stringify(storage.dump())).toContain(access)
|
||||
})
|
||||
|
||||
it('restoreSession поднимает пару после «reload» (RAM пуста, storage цел) — сессия переживает F5', async () => {
|
||||
const access = await jwtExpIn(3600)
|
||||
// «До reload»: storage с записью, как её оставил setSession в прошлой сессии.
|
||||
const storage = memoryStorage()
|
||||
await storage.set('coopid.session.tokens', JSON.stringify({ apiBase: 'https://coop.example', tokens: { accessToken: access, refreshToken: 'r' } }))
|
||||
// «После reload»: RAM пуста (beforeEach), подключаем storage и восстанавливаем.
|
||||
configureTokenStorage(storage)
|
||||
|
||||
expect(await restoreSession()).toBe(true)
|
||||
expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' })
|
||||
expect(await getAccessToken()).toBe(access) // токен жив без сети
|
||||
})
|
||||
|
||||
it('нет персистентной записи → restoreSession=false (нужен вход)', async () => {
|
||||
configureTokenStorage(memoryStorage())
|
||||
expect(await restoreSession()).toBe(false)
|
||||
await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
|
||||
it('активная RAM-сессия не перетирается restoreSession', async () => {
|
||||
const access = await jwtExpIn(3600)
|
||||
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
|
||||
configureTokenStorage(memoryStorage()) // пустой storage
|
||||
expect(await restoreSession()).toBe(true) // уже в RAM
|
||||
expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' })
|
||||
})
|
||||
|
||||
it('refresh обновляет персистентную копию (на диске свежий refresh-токен)', async () => {
|
||||
const storage = memoryStorage()
|
||||
const expired = await jwtExpIn(-10)
|
||||
const fresh = await jwtExpIn(3600)
|
||||
configureTokenStorage(storage)
|
||||
setSession('https://coop.example', { accessToken: expired, refreshToken: 'r-old' })
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' })))
|
||||
|
||||
expect(await getAccessToken()).toBe(fresh)
|
||||
const dump = JSON.stringify(storage.dump())
|
||||
expect(dump).toContain('r-new')
|
||||
expect(dump).not.toContain('r-old')
|
||||
})
|
||||
|
||||
it('clearSession стирает персистентную копию (logout)', async () => {
|
||||
const storage = memoryStorage()
|
||||
const access = await jwtExpIn(3600)
|
||||
configureTokenStorage(storage)
|
||||
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
|
||||
expect(Object.keys(storage.dump())).toHaveLength(1)
|
||||
clearSession()
|
||||
expect(Object.keys(storage.dump())).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { StorageAdapter } from '../src/wallet'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import {
|
||||
clearPinCache,
|
||||
DEFAULT_PIN,
|
||||
getWallet,
|
||||
hasPinCache,
|
||||
isWalletUnlocked,
|
||||
lockWallet,
|
||||
persistPinCache,
|
||||
unlockWithPin,
|
||||
} from '../src/wallet'
|
||||
import { storeUnlocked } from '../src/wallet/storage'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const ACCOUNT = 'ant'
|
||||
const PUB = 'PUB_K1_'
|
||||
|
||||
/** In-memory StorageAdapter (в браузере — localStorage/IndexedDB, тут — Map). */
|
||||
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
|
||||
const m = new Map<string, string>()
|
||||
return {
|
||||
get: async k => m.get(k) ?? null,
|
||||
set: async (k, v) => void m.set(k, v),
|
||||
remove: async k => void m.delete(k),
|
||||
dump: () => Object.fromEntries(m),
|
||||
}
|
||||
}
|
||||
|
||||
/** Кладёт ключ в RAM-keystore, как это делает unlockWallet после входа паролём. */
|
||||
function putUnlocked(): void {
|
||||
// publicKey деривуется внутри unlockWallet/unlockWithPin; здесь для setup
|
||||
// достаточно любого валидного вида — реальный pub проверяем после unlockWithPin.
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: 'PUB_K1_setup', privateKey: KEY })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
describe('pin-кэш: persist → unlock без пароля и round-trip к серверу', () => {
|
||||
it('кастомный ПИН: persist под ПИН, lock, unlock тем же ПИН → тот же account и валидный publicKey', async () => {
|
||||
const storage = memoryStorage()
|
||||
putUnlocked()
|
||||
await persistPinCache({ pin: '135790', storage })
|
||||
lockWallet()
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
|
||||
const wallet = await unlockWithPin({ pin: '135790', storage })
|
||||
expect(wallet).not.toBeNull()
|
||||
expect(wallet!.account).toBe(ACCOUNT)
|
||||
expect(wallet!.publicKey).toMatch(new RegExp(`^${PUB}`))
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
// keystore реально наполнен правильным ключом — getWallet отдаёт тот же pub
|
||||
expect((await getWallet()).publicKey).toBe(wallet!.publicKey)
|
||||
}, 30000)
|
||||
|
||||
it('дефолтный ПИН (000000): persist и unlock без аргумента pin — прозрачно', async () => {
|
||||
const storage = memoryStorage()
|
||||
putUnlocked()
|
||||
await persistPinCache({ storage }) // pin не задан → DEFAULT_PIN
|
||||
lockWallet()
|
||||
|
||||
const wallet = await unlockWithPin({ storage }) // pin не задан → DEFAULT_PIN
|
||||
expect(wallet).not.toBeNull()
|
||||
expect(wallet!.account).toBe(ACCOUNT)
|
||||
// тем же значением расшифровывается и явный DEFAULT_PIN
|
||||
lockWallet()
|
||||
expect(await unlockWithPin({ pin: DEFAULT_PIN, storage })).not.toBeNull()
|
||||
}, 30000)
|
||||
|
||||
it('неверный ПИН → VaultDecryptionFailed, keystore остаётся пуст', async () => {
|
||||
const storage = memoryStorage()
|
||||
putUnlocked()
|
||||
await persistPinCache({ pin: '111111', storage })
|
||||
lockWallet()
|
||||
|
||||
const err = await unlockWithPin({ pin: '999999', storage }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
describe('pin-кэш: отсутствие кэша и жизненный цикл', () => {
|
||||
it('нет кэша → unlockWithPin возвращает null (нужен полный вход паролём)', async () => {
|
||||
const storage = memoryStorage()
|
||||
expect(await unlockWithPin({ storage })).toBeNull()
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
})
|
||||
|
||||
it('hasPinCache: false → persist → true → clear → false', async () => {
|
||||
const storage = memoryStorage()
|
||||
expect(await hasPinCache(storage)).toBe(false)
|
||||
putUnlocked()
|
||||
await persistPinCache({ storage })
|
||||
expect(await hasPinCache(storage)).toBe(true)
|
||||
await clearPinCache(storage)
|
||||
expect(await hasPinCache(storage)).toBe(false)
|
||||
}, 30000)
|
||||
|
||||
it('persistPinCache при запертом кошельке → WalletLocked (нечего кэшировать)', async () => {
|
||||
const storage = memoryStorage()
|
||||
lockWallet()
|
||||
const err = await persistPinCache({ storage }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pin-кэш: at-rest — на диске только шифр', () => {
|
||||
it('сохранённая запись не содержит WIF в открытом виде', async () => {
|
||||
const storage = memoryStorage()
|
||||
putUnlocked()
|
||||
await persistPinCache({ pin: '424242', storage })
|
||||
expect(JSON.stringify(storage.dump())).not.toContain(KEY)
|
||||
}, 30000)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
compareSchemaVersions,
|
||||
type CoopIdSchemaPolicy,
|
||||
createSchemaPolicyCache,
|
||||
isSchemaVersionSupported,
|
||||
SCHEMA_POLICY_CACHE_TTL_MS,
|
||||
} from '../src/index'
|
||||
|
||||
const POLICY: CoopIdSchemaPolicy = { current_version: '1', min_supported_version: '1', deprecation: { 0: '2026-01-01' } }
|
||||
|
||||
describe('compareSchemaVersions (Story 4.10)', () => {
|
||||
it('числовое сравнение версий', () => {
|
||||
expect(compareSchemaVersions('0', '1')).toBeLessThan(0)
|
||||
expect(compareSchemaVersions('2', '1')).toBeGreaterThan(0)
|
||||
expect(compareSchemaVersions('1', '1')).toBe(0)
|
||||
})
|
||||
|
||||
it('нечисловая версия → лексикографический фолбэк', () => {
|
||||
expect(compareSchemaVersions('a', 'b')).toBeLessThan(0)
|
||||
expect(compareSchemaVersions('v2', 'v2')).toBe(0)
|
||||
})
|
||||
|
||||
it('isSchemaVersionSupported: не старее минимума', () => {
|
||||
expect(isSchemaVersionSupported('1', '1')).toBe(true)
|
||||
expect(isSchemaVersionSupported('2', '1')).toBe(true)
|
||||
expect(isSchemaVersionSupported('0', '1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSchemaPolicyCache (Story 4.10, FR72)', () => {
|
||||
it('в пределах TTL отдаёт кэш без повторного fetch', async () => {
|
||||
let t = 1000
|
||||
const fetchPolicy = vi.fn().mockResolvedValue(POLICY)
|
||||
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
|
||||
expect(await cache.getMinSupportedVersion()).toBe('1')
|
||||
t += SCHEMA_POLICY_CACHE_TTL_MS - 1
|
||||
await cache.getPolicy()
|
||||
expect(fetchPolicy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('после истечения TTL рефетчит политику', async () => {
|
||||
let t = 1000
|
||||
const fetchPolicy = vi.fn().mockResolvedValue(POLICY)
|
||||
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
|
||||
await cache.getPolicy()
|
||||
t += SCHEMA_POLICY_CACHE_TTL_MS + 1
|
||||
await cache.getPolicy()
|
||||
expect(fetchPolicy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('офлайн (fetch упал) после истечения TTL → отдаёт stale-кэш', async () => {
|
||||
let t = 1000
|
||||
const fetchPolicy = vi.fn()
|
||||
.mockResolvedValueOnce(POLICY)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
|
||||
await cache.getPolicy()
|
||||
t += SCHEMA_POLICY_CACHE_TTL_MS + 1
|
||||
expect(await cache.getMinSupportedVersion()).toBe('1')
|
||||
})
|
||||
|
||||
it('нет кэша и fetch упал → пробрасывает ошибку', async () => {
|
||||
const fetchPolicy = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
const cache = createSchemaPolicyCache({ fetchPolicy })
|
||||
await expect(cache.getPolicy()).rejects.toThrow('offline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Checksum256, PrivateKey, PublicKey, Signature } from '@wharfkit/antelope'
|
||||
import { base64url, SignJWT } from 'jose'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from '../src/signing'
|
||||
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const ACCOUNT = 'ant'
|
||||
const PUB = PrivateKey.from(KEY).toPublic().toString()
|
||||
|
||||
/** Поддельный, но корректно сформированный session_binding_token (signTimestamp его только декодирует). */
|
||||
async function makeToken(sub = ACCOUNT, jti = 'jti-abc-123'): Promise<string> {
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(sub)
|
||||
.setJti(jti)
|
||||
.setExpirationTime('2m')
|
||||
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wipeKeystore()
|
||||
})
|
||||
|
||||
describe('signTimestamp: COOPOS-native recoverable подпись', () => {
|
||||
it('подписывает {ts,jti,sub}; recoverMessage канонического сообщения даёт тот же pubkey', async () => {
|
||||
const token = await makeToken(ACCOUNT, 'jti-abc-123')
|
||||
const res = await signTimestamp({ sessionBindingToken: token })
|
||||
|
||||
expect(res.sub).toBe(ACCOUNT)
|
||||
expect(res.binding_token_jti).toBe('jti-abc-123')
|
||||
expect(res.public_key).toBe(PUB)
|
||||
expect(res.signature).toMatch(/^SIG_K1_/)
|
||||
|
||||
const msg = new TextEncoder().encode(canonicalTimestampMessage({
|
||||
ts: res.ts,
|
||||
binding_token_jti: res.binding_token_jti,
|
||||
sub: res.sub,
|
||||
}))
|
||||
const recovered = Signature.from(res.signature).recoverMessage(msg).toString()
|
||||
expect(recovered).toBe(PUB)
|
||||
})
|
||||
|
||||
it('подмена сообщения ломает восстановление pubkey (integrity)', async () => {
|
||||
const res = await signTimestamp({ sessionBindingToken: await makeToken() })
|
||||
const tampered = new TextEncoder().encode(canonicalTimestampMessage({
|
||||
ts: res.ts,
|
||||
binding_token_jti: res.binding_token_jti,
|
||||
sub: 'someone-else',
|
||||
}))
|
||||
const recovered = Signature.from(res.signature).recoverMessage(tampered).toString()
|
||||
expect(recovered).not.toBe(PUB)
|
||||
})
|
||||
|
||||
it('запертый кошелёк → WalletLocked', async () => {
|
||||
wipeKeystore()
|
||||
const err = await signTimestamp({ sessionBindingToken: await makeToken() }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
|
||||
it('кошелёк не совпадает с субъектом токена → ClientWalletMismatch', async () => {
|
||||
const err = await signTimestamp({ sessionBindingToken: await makeToken('petrov') }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ClientWalletMismatch)
|
||||
})
|
||||
|
||||
it('битый токен → SessionBindingExpired', async () => {
|
||||
const err = await signTimestamp({ sessionBindingToken: 'not-a-jwt' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
|
||||
})
|
||||
|
||||
it('токен без sub/jti → SessionBindingExpired', async () => {
|
||||
const noClaims = await new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
|
||||
const err = await signTimestamp({ sessionBindingToken: noClaims }).then(() => null, e => e)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
|
||||
})
|
||||
})
|
||||
|
||||
describe('signChainDigest: подпись tx-дайджеста ключом из keystore (мост подписи CoopID)', () => {
|
||||
it('подписывает дайджест; recoverDigest даёт pubkey подписанта (ключ наружу не выходит — только подпись)', async () => {
|
||||
const digestHex = 'ab'.repeat(32) // 32 байта signing-дайджеста
|
||||
const sig = await signChainDigest(digestHex)
|
||||
expect(sig).toMatch(/^SIG_K1_/)
|
||||
const recovered = Signature.from(sig).recoverDigest(Checksum256.from(digestHex)).toString()
|
||||
expect(recovered).toBe(PUB)
|
||||
})
|
||||
|
||||
it('подмена дайджеста ломает восстановление pubkey (integrity)', async () => {
|
||||
const sig = await signChainDigest('ab'.repeat(32))
|
||||
const recovered = Signature.from(sig).recoverDigest(Checksum256.from('cd'.repeat(32))).toString()
|
||||
expect(recovered).not.toBe(PUB)
|
||||
})
|
||||
|
||||
it('запертый кошелёк → WalletLocked (подпись транзакции невозможна без unlock)', async () => {
|
||||
wipeKeystore()
|
||||
const err = await signChainDigest('ab'.repeat(32)).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canonicalTimestampMessage: детерминизм', () => {
|
||||
it('фиксированный алфавитный порядок ключей', () => {
|
||||
expect(canonicalTimestampMessage({ ts: 't', binding_token_jti: 'j', sub: 's' }))
|
||||
.toBe('{"binding_token_jti":"j","sub":"s","ts":"t"}')
|
||||
})
|
||||
})
|
||||
|
||||
/** Верифицирует compact JWS ES256K: разбирает подпись R||S и проверяет её ключом. */
|
||||
function verifyJws(jws: string, pub: string): boolean {
|
||||
const [h, p, s] = jws.split('.')
|
||||
const rs = base64url.decode(s)
|
||||
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
|
||||
return sig.verifyMessage(new TextEncoder().encode(`${h}.${p}`), PublicKey.from(pub))
|
||||
}
|
||||
|
||||
describe('signDocument: локальная подпись compact JWS (Story 2.3)', () => {
|
||||
it('возвращает JWS <header>.<payload>.<signature>; header alg/kid, payload round-trip, подпись валидна', async () => {
|
||||
const doc = new TextEncoder().encode('Протокол собрания №1')
|
||||
const jws = await signDocument({ payload: doc, alg: 'ES256K' })
|
||||
|
||||
const parts = jws.split('.')
|
||||
expect(parts).toHaveLength(3)
|
||||
|
||||
const header = JSON.parse(new TextDecoder().decode(base64url.decode(parts[0])))
|
||||
expect(header).toEqual({ alg: 'ES256K', kid: ACCOUNT })
|
||||
|
||||
// payload декодируется обратно в исходные байты
|
||||
expect(base64url.decode(parts[1])).toEqual(doc)
|
||||
|
||||
// подпись — ровно 64 байта (R||S, без recovery) и валидна для pubkey подписанта
|
||||
expect(base64url.decode(parts[2]).length).toBe(64)
|
||||
expect(verifyJws(jws, PUB)).toBe(true)
|
||||
})
|
||||
|
||||
it('строковый payload эквивалентен его UTF-8 байтам', async () => {
|
||||
const text = 'привет, кооператив'
|
||||
const fromString = await signDocument({ payload: text })
|
||||
const fromBytes = await signDocument({ payload: new TextEncoder().encode(text) })
|
||||
// RFC6979-детерминизм K1: одинаковый вход → одинаковый JWS
|
||||
expect(fromString).toBe(fromBytes)
|
||||
expect(verifyJws(fromString, PUB)).toBe(true)
|
||||
})
|
||||
|
||||
it('integrity: подделка payload в JWS ломает проверку подписи', async () => {
|
||||
const jws = await signDocument({ payload: 'исходный документ' })
|
||||
const [h, , s] = jws.split('.')
|
||||
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
|
||||
expect(verifyJws(tampered, PUB)).toBe(false)
|
||||
})
|
||||
|
||||
it('alg по умолчанию — ES256K', async () => {
|
||||
const jws = await signDocument({ payload: 'x' })
|
||||
const header = JSON.parse(new TextDecoder().decode(base64url.decode(jws.split('.')[0])))
|
||||
expect(header.alg).toBe('ES256K')
|
||||
})
|
||||
|
||||
it('неподдерживаемый alg → AuthV2Error', async () => {
|
||||
const err = await signDocument({ payload: 'x', alg: 'RS256' as 'ES256K' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
|
||||
it('запертый кошелёк → WalletLocked', async () => {
|
||||
wipeKeystore()
|
||||
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { StorageAdapter } from '../src/wallet'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { decryptPrivateKey } from '../src/vault'
|
||||
import { clearLocalVault, loadLocalVault, saveLocalVault, saveToVault, storeVaultBlob } from '../src/wallet'
|
||||
|
||||
const BLOB = {
|
||||
cipher_version: 'aes-256-gcm-v1',
|
||||
kdf_version: 'argon2id-v1',
|
||||
salt: 'c2FsdA',
|
||||
nonce: 'bm9uY2U',
|
||||
ciphertext: 'Y2lwaGVy',
|
||||
auth_tag: 'dGFn',
|
||||
}
|
||||
|
||||
/** In-memory StorageAdapter для теста локальной копии. */
|
||||
function memStorage(): StorageAdapter & { map: Map<string, string> } {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
map,
|
||||
get: async k => map.get(k) ?? null,
|
||||
set: async (k, v) => void map.set(k, v),
|
||||
remove: async k => void map.delete(k),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('storeVaultBlob (Story 11.3) — POST /coop/vault', () => {
|
||||
it('шлёт POST с плоскими subject_type/subject_id + полями blob; 201 ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB)
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/vault')
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ ...BLOB, subject_type: 'participant', subject_id: 'ant' })
|
||||
})
|
||||
|
||||
it('не-ok ответ → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }))
|
||||
await expect(storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'network_error' })
|
||||
})
|
||||
|
||||
it('сеть упала → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
|
||||
await expect(storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'network_error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('локальная копия vault (Story 11.3)', () => {
|
||||
it('save → load возвращает blob того же аккаунта', async () => {
|
||||
const storage = memStorage()
|
||||
await saveLocalVault(storage, 'ant', BLOB)
|
||||
expect(await loadLocalVault(storage, 'ant')).toEqual(BLOB)
|
||||
})
|
||||
|
||||
it('load для другого аккаунта → null (чужой blob не отдаём)', async () => {
|
||||
const storage = memStorage()
|
||||
await saveLocalVault(storage, 'ant', BLOB)
|
||||
expect(await loadLocalVault(storage, 'someone-else')).toBeNull()
|
||||
})
|
||||
|
||||
it('нет записи → null; clear удаляет', async () => {
|
||||
const storage = memStorage()
|
||||
expect(await loadLocalVault(storage, 'ant')).toBeNull()
|
||||
await saveLocalVault(storage, 'ant', BLOB)
|
||||
await clearLocalVault(storage)
|
||||
expect(await loadLocalVault(storage, 'ant')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveToVault (Story 11.3) — encrypt → POST → локальная копия', () => {
|
||||
it('шифрует ключ паролём, шлёт на сервер и кладёт локально; blob расшифровывается обратно', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const storage = memStorage()
|
||||
const wif = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
|
||||
const blob = await saveToVault({ apiUrl: 'https://coop.example', account: 'ant', privateKey: wif, password: 'P@ss', storage })
|
||||
|
||||
// сервер получил blob с субъектом ant
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/vault')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ subject_type: 'participant', subject_id: 'ant', cipher_version: 'aes-256-gcm-v1' })
|
||||
// локальная копия сохранена
|
||||
expect(await loadLocalVault(storage, 'ant')).toEqual(blob)
|
||||
// round-trip: тем же паролём ключ восстанавливается (неверный пароль покрыт wallet.test.ts)
|
||||
expect(await decryptPrivateKey(blob, 'P@ss', { subject_type: 'participant', subject_id: 'ant' })).toBe(wif)
|
||||
}, 60000)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { decryptPrivateKey, encryptPrivateKey } from '../src/vault'
|
||||
import type { VaultSubject } from '../src/vault'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const SUBJECT: VaultSubject = { subject_type: 'participant', subject_id: 'ant' }
|
||||
|
||||
describe('vault: Argon2id + AES-256-GCM', () => {
|
||||
it('round-trip: encrypt → decrypt тем же паролем даёт исходный ключ', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
expect(blob.cipher_version).toBe('aes-256-gcm-v1')
|
||||
expect(blob.kdf_version).toBe('argon2id-v1')
|
||||
const restored = await decryptPrivateKey(blob, PW, SUBJECT)
|
||||
expect(restored).toBe(KEY)
|
||||
})
|
||||
|
||||
it('каждое шифрование — новые salt и nonce', async () => {
|
||||
const a = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const b = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
expect(a.salt).not.toBe(b.salt)
|
||||
expect(a.nonce).not.toBe(b.nonce)
|
||||
expect(a.ciphertext).not.toBe(b.ciphertext)
|
||||
})
|
||||
|
||||
it('неверный пароль → VaultDecryptionFailed', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const err = await decryptPrivateKey(blob, 'wrong-password-000000000000', SUBJECT).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
})
|
||||
|
||||
it('подмена subject_id НЕ ломает расшифровку: AAD не зависит от account (решение 2026-06-15)', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
// другой subject_id, тот же тип и пароль → расшифровка проходит (id не в AAD).
|
||||
const restored = await decryptPrivateKey(blob, PW, { subject_type: 'participant', subject_id: 'petrov' })
|
||||
expect(restored).toBe(KEY)
|
||||
})
|
||||
|
||||
it('подмена типа субъекта (AAD) ломает расшифровку', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const err = await decryptPrivateKey(blob, PW, { subject_type: 'coop', subject_id: 'ant' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
})
|
||||
}, 30000)
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PrivateKey } from '@wharfkit/antelope'
|
||||
import { base64url } from 'jose'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { type KeyValidityWindow, verifyDocumentOffline } from '../src/index'
|
||||
import { signDocument } from '../src/signing'
|
||||
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
|
||||
|
||||
const KEY1 = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PUB1 = PrivateKey.from(KEY1).toPublic().toString()
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
// Второй ключ — результат ротации (Story 3.3). Генерим, чтобы не хардкодить.
|
||||
const key2 = PrivateKey.generate('K1')
|
||||
const KEY2 = key2.toString()
|
||||
const PUB2 = key2.toPublic().toString()
|
||||
|
||||
// key1 активен в блоках [100,200], затем ротация на key2 [201, сейчас].
|
||||
const authority: KeyValidityWindow[] = [
|
||||
{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 },
|
||||
{ public_key: PUB2, valid_from_block: 201, valid_to_block: null },
|
||||
]
|
||||
|
||||
async function signWith(wif: string, payload: string): Promise<string> {
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: PrivateKey.from(wif).toPublic().toString(), privateKey: wif })
|
||||
const jws = await signDocument({ payload })
|
||||
wipeKeystore()
|
||||
return jws
|
||||
}
|
||||
|
||||
afterEach(() => wipeKeystore())
|
||||
|
||||
describe('verifyDocumentOffline: backward-валидность (Story 2.5)', () => {
|
||||
it('подпись старым ключом валидна, если ключ был активен на момент подписи (хотя сейчас ротирован)', async () => {
|
||||
const jws = await signWith(KEY1, 'протокол собрания №7')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.matched_key).toBe(PUB1)
|
||||
})
|
||||
|
||||
it('та же подпись на блоке ПОСЛЕ ротации → невалидна (key_not_active_at_signing)', async () => {
|
||||
const jws = await signWith(KEY1, 'протокол собрания №7')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 250, authority })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('key_not_active_at_signing')
|
||||
})
|
||||
|
||||
it('подпись текущим ключом в его окне → валидна', async () => {
|
||||
const jws = await signWith(KEY2, 'протокол собрания №8')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 300, authority })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.matched_key).toBe(PUB2)
|
||||
})
|
||||
|
||||
it('подделка payload → signature_mismatch', async () => {
|
||||
const jws = await signWith(KEY1, 'оригинальный документ')
|
||||
const [h, , s] = jws.split('.')
|
||||
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
|
||||
const res = await verifyDocumentOffline({ jws: tampered, signedAtBlock: 150, authority })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('signature_mismatch')
|
||||
})
|
||||
|
||||
it('чужой ключ (нет в хронологии подписанта) → signature_mismatch', async () => {
|
||||
const jws = await signWith(KEY2, 'документ')
|
||||
const onlyKey1: KeyValidityWindow[] = [{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 }]
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority: onlyKey1 })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('signature_mismatch')
|
||||
})
|
||||
|
||||
it('некорректный JWS → malformed_jws', async () => {
|
||||
expect((await verifyDocumentOffline({ jws: 'a.b', signedAtBlock: 1, authority })).reason).toBe('malformed_jws')
|
||||
})
|
||||
|
||||
it('неподдерживаемый alg → unsupported_alg', async () => {
|
||||
const h = base64url.encode(new TextEncoder().encode(JSON.stringify({ alg: 'RS256' })))
|
||||
const p = base64url.encode(new TextEncoder().encode('x'))
|
||||
const sig = base64url.encode(new Uint8Array(64))
|
||||
const res = await verifyDocumentOffline({ jws: `${h}.${p}.${sig}`, signedAtBlock: 1, authority })
|
||||
expect(res.reason).toBe('unsupported_alg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { PrivateKey as PrivateKeyType } from '@wharfkit/antelope'
|
||||
import { PrivateKey } from '@wharfkit/antelope'
|
||||
import { base64url } from 'jose'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type CoopChainLink, verifyOffline } from '../src/index'
|
||||
|
||||
// Цепь доверия ano → voskhod → vostok(issuer). Ключи генерим, чтобы не хардкодить.
|
||||
const anoKey = PrivateKey.generate('K1')
|
||||
const voskhodKey = PrivateKey.generate('K1')
|
||||
const issuerKey = PrivateKey.generate('K1') // vostok — издатель, его ключом подписан cert
|
||||
|
||||
const PUB_ANO = anoKey.toPublic().toString()
|
||||
const PUB_VOSKHOD = voskhodKey.toPublic().toString()
|
||||
const PUB_ISSUER = issuerKey.toPublic().toString()
|
||||
|
||||
const CHAIN: CoopChainLink[] = [
|
||||
{ account: 'ano', public_key: PUB_ANO },
|
||||
{ account: 'voskhod', public_key: PUB_VOSKHOD },
|
||||
{ account: 'vostok', public_key: PUB_ISSUER },
|
||||
]
|
||||
|
||||
// Полный доверенный кэш (chain_manifests_cache) — все звенья известны.
|
||||
const TRUSTED: Record<string, string> = { ano: PUB_ANO, voskhod: PUB_VOSKHOD, vostok: PUB_ISSUER }
|
||||
|
||||
const NOW = 1_900_000_000_000 // фиксированный «сейчас» (мс)
|
||||
const FUTURE_EXP = Math.floor(NOW / 1000) + 3600 // +1ч
|
||||
const PAST_EXP = Math.floor(NOW / 1000) - 10
|
||||
|
||||
interface CertOpts {
|
||||
chain?: CoopChainLink[]
|
||||
exp?: number
|
||||
alg?: string
|
||||
signWith?: PrivateKeyType
|
||||
schemaVersion?: string
|
||||
}
|
||||
|
||||
/** Собрать compact JWS-сертификат (формат CertificateService, Story 1.8). */
|
||||
function makeCert(opts: CertOpts = {}): string {
|
||||
const signer = opts.signWith ?? issuerKey
|
||||
const header = base64url.encode(JSON.stringify({ alg: opts.alg ?? 'ES256K', typ: 'JWT', kid: signer.toPublic().toString() }))
|
||||
const payload = base64url.encode(JSON.stringify({
|
||||
coopname: 'vostok',
|
||||
coop_chain: opts.chain ?? CHAIN,
|
||||
exp: opts.exp ?? FUTURE_EXP,
|
||||
sub: 'uuid-1',
|
||||
jti: 'serial-123',
|
||||
claim_schema_version: opts.schemaVersion ?? '1',
|
||||
}))
|
||||
const signingInput = `${header}.${payload}`
|
||||
const sig = signer.signMessage(new TextEncoder().encode(signingInput))
|
||||
const rs = sig.data.array.slice(1) // [recovery(1), r(32), s(32)] → R||S
|
||||
return `${signingInput}.${base64url.encode(rs)}`
|
||||
}
|
||||
|
||||
describe('verifyOffline: офлайн-проверка удостоверения (Story 4.4)', () => {
|
||||
it('валидный сертификат с доверенной цепью и якорем → valid, issuer', async () => {
|
||||
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.issuer).toBe('vostok')
|
||||
expect(res.reason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exp в прошлом → expired', async () => {
|
||||
const res = await verifyOffline(makeCert({ exp: PAST_EXP }), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('expired')
|
||||
})
|
||||
|
||||
it('цепь не укоренена в известном ano (чужой якорь) → untrusted_anchor', async () => {
|
||||
const other = PrivateKey.generate('K1').toPublic().toString()
|
||||
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, trustAnchor: other, now: NOW })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('untrusted_anchor')
|
||||
})
|
||||
|
||||
it('звено издателя не совпадает с доверенным кэшем → untrusted_issuer', async () => {
|
||||
const foreign = PrivateKey.generate('K1').toPublic().toString()
|
||||
const res = await verifyOffline(makeCert(), {
|
||||
trustedKeys: { ano: PUB_ANO, voskhod: PUB_VOSKHOD, vostok: foreign },
|
||||
now: NOW,
|
||||
})
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('untrusted_issuer')
|
||||
})
|
||||
|
||||
it('без доверенного кэша ключей → fail-closed (untrusted_issuer)', async () => {
|
||||
const res = await verifyOffline(makeCert(), { trustAnchor: PUB_ANO, now: NOW })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('untrusted_issuer')
|
||||
})
|
||||
|
||||
it('подпись чужим ключом при валидной цепи → signature_mismatch', async () => {
|
||||
// cert подписан foreignKey, но coop_chain заявляет издателя PUB_ISSUER;
|
||||
// verifyOffline сверяет подпись с ключом издателя из цепи (не с kid) → провал.
|
||||
const cert = makeCert({ signWith: PrivateKey.generate('K1') })
|
||||
const res = await verifyOffline(cert, { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('signature_mismatch')
|
||||
})
|
||||
|
||||
it('high-S подпись (не каноническая, как у jose/Node) → принимается через low-S нормализацию', async () => {
|
||||
const cert = makeCert()
|
||||
const [h, p, s] = cert.split('.')
|
||||
const rs = base64url.decode(s)
|
||||
// S → n − S (переводим каноническую low-S подпись wharfkit в high-S вариант).
|
||||
const n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
|
||||
let sVal = 0n
|
||||
for (const b of rs.slice(32, 64))
|
||||
sVal = (sVal << 8n) | BigInt(b)
|
||||
let high = n - sVal
|
||||
const highRs = new Uint8Array(rs)
|
||||
for (let i = 63; i >= 32; i--) {
|
||||
highRs[i] = Number(high & 0xFFn)
|
||||
high >>= 8n
|
||||
}
|
||||
const highCert = `${h}.${p}.${base64url.encode(highRs)}`
|
||||
const res = await verifyOffline(highCert, { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.issuer).toBe('vostok')
|
||||
})
|
||||
|
||||
it('некорректный JWS (две части) → malformed_certificate', async () => {
|
||||
const res = await verifyOffline('a.b', { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.reason).toBe('malformed_certificate')
|
||||
})
|
||||
|
||||
it('неподдерживаемый alg → unsupported_alg', async () => {
|
||||
const res = await verifyOffline(makeCert({ alg: 'RS256' }), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.reason).toBe('unsupported_alg')
|
||||
})
|
||||
|
||||
it('пустой coop_chain → malformed_certificate', async () => {
|
||||
const res = await verifyOffline(makeCert({ chain: [] }), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.reason).toBe('malformed_certificate')
|
||||
})
|
||||
|
||||
it('версия схемы старее min_supported_version → unsupported_schema_version (Story 4.10)', async () => {
|
||||
const res = await verifyOffline(makeCert({ schemaVersion: '0' }), { trustedKeys: TRUSTED, now: NOW, minSchemaVersion: '1' })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('unsupported_schema_version')
|
||||
})
|
||||
|
||||
it('версия схемы = min_supported_version → ось схемы пройдена, cert валиден (Story 4.10)', async () => {
|
||||
const res = await verifyOffline(makeCert({ schemaVersion: '1' }), { trustedKeys: TRUSTED, now: NOW, minSchemaVersion: '1' })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.issuer).toBe('vostok')
|
||||
})
|
||||
|
||||
it('без minSchemaVersion ось схемы не гейтит — даже старая версия проходит (Story 4.10)', async () => {
|
||||
const res = await verifyOffline(makeCert({ schemaVersion: '0' }), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('не делает сетевых запросов (fetch недоступен) → всё равно verdict', async () => {
|
||||
const orig = globalThis.fetch
|
||||
globalThis.fetch = (() => {
|
||||
throw new Error('сеть запрещена в офлайн-проверке')
|
||||
}) as typeof fetch
|
||||
try {
|
||||
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(true)
|
||||
}
|
||||
finally {
|
||||
globalThis.fetch = orig
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { encryptPrivateKey } from '../src/vault'
|
||||
import {
|
||||
getWallet,
|
||||
isWalletUnlocked,
|
||||
lockWallet,
|
||||
unlockWallet,
|
||||
Wallet,
|
||||
} from '../src/wallet'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const ACCOUNT = 'ant'
|
||||
const API = 'http://stub'
|
||||
|
||||
/** Подменяет fetch так, чтобы GET vault отдавал заранее зашифрованный blob. */
|
||||
async function stubVaultFetch(password = PW): Promise<void> {
|
||||
const blob = await encryptPrivateKey(KEY, password, { subject_type: 'participant', subject_id: ACCOUNT })
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => blob }) as any))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
describe('wallet: unlock + in-memory keystore + getWallet', () => {
|
||||
it('unlock → getWallet отдаёт account и деривованный publicKey', async () => {
|
||||
await stubVaultFetch()
|
||||
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
expect(wallet.account).toBe(ACCOUNT)
|
||||
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
|
||||
const again = await getWallet()
|
||||
expect(again.publicKey).toBe(wallet.publicKey)
|
||||
}, 30000)
|
||||
|
||||
it('неверный пароль → VaultDecryptionFailed, keystore остаётся пуст', async () => {
|
||||
await stubVaultFetch(PW)
|
||||
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: 'wrong-password-000000000000' })
|
||||
.then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
}, 30000)
|
||||
|
||||
it('getWallet при запертом кошельке → WalletLocked', async () => {
|
||||
const err = await getWallet().then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
|
||||
it('сеть недоступна → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) }) as any))
|
||||
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wallet: несериализуемость приватного ключа', () => {
|
||||
it('JSON.stringify(wallet) не раскрывает приватный ключ', async () => {
|
||||
await stubVaultFetch()
|
||||
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
const serialized = JSON.stringify(wallet)
|
||||
expect(serialized).not.toContain(KEY)
|
||||
expect(JSON.parse(serialized)).toEqual({ account: ACCOUNT, publicKey: wallet.publicKey })
|
||||
// приватного ключа нет ни в одном перечислимом свойстве обёртки
|
||||
expect(Object.values(wallet as unknown as Record<string, unknown>)).not.toContain(KEY)
|
||||
expect(wallet).toBeInstanceOf(Wallet)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
describe('wallet: lock/wipe', () => {
|
||||
it('lockWallet затирает keystore → getWallet бросает; идемпотентно', async () => {
|
||||
await stubVaultFetch()
|
||||
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
lockWallet()
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
lockWallet() // повторно — без ошибки
|
||||
const err = await getWallet().then(() => null, e => e)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
}, 30000)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Браузерный таргет cross-runtime smoke (Story 9.13): vitest browser mode,
|
||||
* playwright + chromium headless. Гоняет ТОЛЬКО test/cross-runtime/** —
|
||||
* остальные юнит-тесты пакета остаются Node-таргетом (`pnpm test`).
|
||||
*/
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/cross-runtime/**/*.test.ts'],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
name: 'chromium',
|
||||
headless: true,
|
||||
screenshotFailures: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -5,8 +5,13 @@
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
@@ -28,5 +33,114 @@
|
||||
"consistent-return": "off",
|
||||
"jest/expect-expect": "off",
|
||||
"security/detect-object-injection": "off"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"src/application/**/*.ts",
|
||||
"src/domain/**/*.ts"
|
||||
],
|
||||
"excludedFiles": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/application/agreement/use-cases/agreement.interactor.ts",
|
||||
"src/application/decision/use-cases/decision.interactor.ts",
|
||||
"src/application/cooplace/interactors/cooplace.interactor.ts",
|
||||
"src/application/participant/interactors/participant.interactor.ts",
|
||||
"src/application/system/interactors/load-contacts.interactor.ts",
|
||||
"src/application/system/dto/blockchain-info.dto.ts",
|
||||
"src/application/system/interactors/wif.interactor.ts",
|
||||
"src/domain/process-registry/services/process-registry.service.ts",
|
||||
"src/domain/branch/interfaces/branch-blockchain.port.ts",
|
||||
"src/domain/common/ports/soviet-blockchain.port.ts",
|
||||
"src/domain/common/ports/blockchain.port.ts",
|
||||
"src/domain/blockchain/types/transaction-result.type.ts",
|
||||
"src/domain/cooplace/interfaces/cooplace-blockchain.port.ts",
|
||||
"src/domain/auth/services/auth-domain.service.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"@wharfkit/*",
|
||||
"oidc-client-ts",
|
||||
"ioredis"
|
||||
],
|
||||
"message": "Гексагональный инвариант auth-v2: application/ и domain/ не импортируют wharfkit/oidc-client-ts/ioredis напрямую — только через порты (infrastructure-адаптеры). excludedFiles — легаси-долг до Эпика 7."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/application/auth-v2/**/*.ts"
|
||||
],
|
||||
"excludedFiles": [
|
||||
"src/**/*.spec.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "ImportSpecifier[imported.name='AuthRoles']",
|
||||
"message": "no-authroles-in-authv2: в auth-v2 запрещён роле-ориентированный @AuthRoles — используйте capability-ориентированный @CheckAbility + AuthorizationGuard (Эпик 6). Legacy auth/ сохраняет @AuthRoles до Phase-3 cleanup."
|
||||
},
|
||||
{
|
||||
"selector": "Decorator[expression.callee.name='AuthRoles']",
|
||||
"message": "no-authroles-in-authv2: в auth-v2 запрещён роле-ориентированный @AuthRoles — используйте capability-ориентированный @CheckAbility + AuthorizationGuard (Эпик 6). Legacy auth/ сохраняет @AuthRoles до Phase-3 cleanup."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"excludedFiles": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/application/auth-v2/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 1.4: начальные схемы coop_domain_db (отдельная БД в coop-postgres,
|
||||
* Story 1.1). Миграция открывает СОБСТВЕННОЕ подключение из config.coopDomainDb —
|
||||
* главный dataSource (voskhod) не используется и не передаётся дальше.
|
||||
*
|
||||
* Таблицы:
|
||||
* - vaults — зашифрованные блобы (subject_type-discriminated);
|
||||
* - audit_events — append-only журнал: месячные партиции + DEFAULT,
|
||||
* триггер запрещает UPDATE/DELETE даже владельцу;
|
||||
* - chain_manifests_cache — TTL-aware кеш authority из COOPOS;
|
||||
* - legacy_user_mapping — UUID ↔ Mongo ObjectID на миграционный период;
|
||||
* - access_rules — декларативная матрица CASL Layer 2 (семантика — Story 6.2).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: init coop_domain_db (vaults, audit_events, chain cache, mapping, access_rules)',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
|
||||
try {
|
||||
await db.initialize();
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS vaults (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject_type text NOT NULL CHECK (subject_type IN ('participant', 'coop', 'council_action')),
|
||||
subject_id text NOT NULL,
|
||||
cipher_version smallint NOT NULL DEFAULT 1,
|
||||
kdf_version smallint NOT NULL DEFAULT 1,
|
||||
salt bytea NOT NULL,
|
||||
nonce bytea NOT NULL,
|
||||
ciphertext bytea NOT NULL,
|
||||
auth_tag bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT vaults_subject_uq UNIQUE (subject_type, subject_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
event text NOT NULL,
|
||||
subject_id text,
|
||||
actor text,
|
||||
result text NOT NULL CHECK (result IN ('success', 'failure', 'degraded')),
|
||||
context jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ip inet,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (id, created_at)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
`);
|
||||
|
||||
// Партиции: текущий месяц, следующий и DEFAULT-страховка (создание
|
||||
// последующих помесячных — забота operational runbook, Stories 9.8/9.10).
|
||||
await db.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
m0 date := date_trunc('month', now())::date;
|
||||
m1 date := (date_trunc('month', now()) + interval '1 month')::date;
|
||||
m2 date := (date_trunc('month', now()) + interval '2 month')::date;
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
|
||||
to_char(m0, 'YYYY_MM'), m0, m1);
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
|
||||
to_char(m1, 'YYYY_MM'), m1, m2);
|
||||
EXECUTE 'CREATE TABLE IF NOT EXISTS audit_events_default PARTITION OF audit_events DEFAULT';
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Append-only: триггер держит инвариант даже для владельца таблицы
|
||||
// (GRANT/REVOKE ниже — декларация, заработает буквально при отдельной
|
||||
// migration-роли из prod-плейбука).
|
||||
await db.query(`
|
||||
CREATE OR REPLACE FUNCTION audit_events_append_only() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_events is append-only: % is forbidden', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`);
|
||||
await db.query(`
|
||||
DROP TRIGGER IF EXISTS audit_events_no_update_delete ON audit_events;
|
||||
CREATE TRIGGER audit_events_no_update_delete
|
||||
BEFORE UPDATE OR DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();
|
||||
`);
|
||||
// ROW-триггер не ловит TRUNCATE — закрываем statement-триггером.
|
||||
await db.query(`
|
||||
DROP TRIGGER IF EXISTS audit_events_no_truncate ON audit_events;
|
||||
CREATE TRIGGER audit_events_no_truncate
|
||||
BEFORE TRUNCATE ON audit_events
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION audit_events_append_only();
|
||||
`);
|
||||
// username — идентификатор в DDL, биндинг невозможен: валидируем строго.
|
||||
const role = config.coopDomainDb.username;
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(role))
|
||||
throw new Error(`Недопустимое имя роли COOP_DOMAIN_DB_USERNAME: ${role}`);
|
||||
await db.query(`
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ${role};
|
||||
GRANT INSERT, SELECT ON audit_events TO ${role};
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS chain_manifests_cache (
|
||||
account text PRIMARY KEY,
|
||||
manifest jsonb NOT NULL,
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS legacy_user_mapping (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mongo_object_id text NOT NULL UNIQUE,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_version text NOT NULL DEFAULT 'legacy' CHECK (auth_version IN ('legacy', 'oidc', 'dual')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS access_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
role text NOT NULL,
|
||||
action text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT access_rules_uq UNIQUE (role, action, subject)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events (subject_id, created_at);`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_events_event ON audit_events (event, created_at);`);
|
||||
|
||||
logger.info('coop_domain_db: vaults, audit_events(+партиции, append-only), chain_manifests_cache, legacy_user_mapping, access_rules — готовы');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`coop_domain_db init failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.0 не реализован намеренно: audit_events append-only, удаление схем — только вручную.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 4.7: manual revoke (compromised-key MVP). Таблица `revoked_keys` в
|
||||
* coop_domain_db фиксирует ручной отзыв ключа пайщика председателем: durable
|
||||
* pending-state (AC «или с переходом на pending state») — пока `recovered_at IS NULL`,
|
||||
* ключ пайщика считается отозванным и пайщик обязан пройти recovery (Эпик 3) для
|
||||
* получения нового. `reason`/`revoked_by` — для audit-трейла KeyRevokedManually.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: revoked_keys',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS revoked_keys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_id text NOT NULL,
|
||||
reason text NOT NULL,
|
||||
revoked_by text NOT NULL,
|
||||
revoked_at timestamptz NOT NULL DEFAULT now(),
|
||||
recovered_at timestamptz
|
||||
);
|
||||
`);
|
||||
// Активный отзыв (ожидает recovery) ищется по target_id + recovered_at IS NULL.
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_revoked_keys_target_active ON revoked_keys (target_id, recovered_at);`);
|
||||
logger.info('revoked_keys создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`revoked_keys migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.10 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 8.1: audit_events schema (append-only + помесячные партиции + триггеры +
|
||||
* GRANT) уже создан в V2.4.0. Эта миграция закрывает остаток AC, отложенный тогда:
|
||||
* - колонка `user_agent` (форензика, наполняет 8.2);
|
||||
* - выделенная read-only роль `coop_audit_reader` (AC: INSERT приложению, SELECT читателю
|
||||
* аудита) — в V2.4.0 разделение ролей было отложено в prod-плейбук;
|
||||
* - форвард-роллинг партиций на текущий+2 месяца (идемпотентно);
|
||||
* - re-assert append-only грантов приложению.
|
||||
* Имена колонок event/context/created_at сохранены намеренно (боевая партиционированная
|
||||
* append-only таблица, ~30 точек записи) — семантически = AC action/metadata/timestamp.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: audit_events user_agent + coop_audit_reader role',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
|
||||
// 1. user_agent — AC явно перечисляет в структуре; nullable (backfill невозможен).
|
||||
await db.query(`ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS user_agent text;`);
|
||||
|
||||
// 2. coop_audit_reader (NOLOGIN group-role) — SELECT-only для читателя аудита.
|
||||
// Создание под insufficient_privilege-страховкой: в рестриктед prod без CREATEROLE
|
||||
// роль заведёт плейбук, миграция не падает (как и декларация грантов в V2.4.0).
|
||||
await db.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coop_audit_reader') THEN
|
||||
BEGIN
|
||||
CREATE ROLE coop_audit_reader NOLOGIN;
|
||||
EXCEPTION WHEN insufficient_privilege THEN
|
||||
RAISE NOTICE 'coop_audit_reader не создан: нет CREATEROLE; роль заведёт prod-playbook';
|
||||
END;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await db.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coop_audit_reader') THEN
|
||||
EXECUTE 'GRANT SELECT ON audit_events TO coop_audit_reader';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// 3. Форвард-роллинг партиций: текущий + следующие 2 месяца (идемпотентно).
|
||||
await db.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
m date;
|
||||
m_next date;
|
||||
i int;
|
||||
BEGIN
|
||||
FOR i IN 0..2 LOOP
|
||||
m := (date_trunc('month', now()) + (i || ' month')::interval)::date;
|
||||
m_next := (date_trunc('month', now()) + ((i + 1) || ' month')::interval)::date;
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
|
||||
to_char(m, 'YYYY_MM'), m, m_next);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// 4. Re-assert append-only грантов приложению (идемпотентно; имя роли валидируем —
|
||||
// идентификатор в DDL, биндинг невозможен).
|
||||
const role = config.coopDomainDb.username;
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(role))
|
||||
throw new Error(`Недопустимое имя роли COOP_DOMAIN_DB_USERNAME: ${role}`);
|
||||
await db.query(`
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ${role};
|
||||
GRANT INSERT, SELECT ON audit_events TO ${role};
|
||||
`);
|
||||
|
||||
logger.info('audit_events: user_agent добавлен, coop_audit_reader + партиции + гранты выровнены');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`audit_events schema migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.11 не реализован: audit_events append-only, дроп колонки/роли — только вручную.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 6.11: назначаемые наборы возможностей (расширяемые роли). Председатель
|
||||
* назначает пайщику именованный НАБОР («бухгалтер»/«кассир»); правила набора живут в
|
||||
* `access_rules` (subject_type='capability_set', Story 6.2) — движок CASL переиспользуется.
|
||||
*
|
||||
* - `capability_sets` — реестр шаблонов (платформенные seed + кооп-кастом).
|
||||
* - `participant_capability_sets` — назначение пайщик→набор (UNIQUE username+set_key,
|
||||
* revoked_at для отзыва, expires_at для TTL).
|
||||
*
|
||||
* Seed: два платформенных набора (accountant/cashier) + их правила доступа к столам.
|
||||
* Имена subject доступа (`AccountingDesk`/`PaymentRegistry`) — grant-строки для desktop
|
||||
* meta.requires; уточняются при разводке desktop-gating.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: capability_sets',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS capability_sets (
|
||||
set_key text PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
builtin boolean NOT NULL DEFAULT false,
|
||||
coopname text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS participant_capability_sets (
|
||||
id bigserial PRIMARY KEY,
|
||||
username text NOT NULL,
|
||||
set_key text NOT NULL,
|
||||
granted_by text NOT NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
CONSTRAINT uq_participant_capability_set UNIQUE (username, set_key)
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_participant_capability_sets_username ON participant_capability_sets (username);`);
|
||||
|
||||
// --- Seed платформенных наборов (идемпотентно) ---
|
||||
await db.query(`
|
||||
INSERT INTO capability_sets (set_key, title, description, builtin, coopname) VALUES
|
||||
('accountant', 'Бухгалтер', 'Доступ к столу бухгалтера (чтение реестра и связанных данных кооператива).', true, NULL),
|
||||
('cashier', 'Кассир', 'Управление реестром платежей: подтверждение входящих и исходящих платежей.', true, NULL)
|
||||
ON CONFLICT (set_key) DO NOTHING;
|
||||
`);
|
||||
|
||||
// --- Seed правил наборов в access_rules (идемпотентно через NOT EXISTS) ---
|
||||
const seedRules: Array<{ set: string; action: string; resource: string }> = [
|
||||
{ set: 'accountant', action: 'read', resource: 'AccountingDesk' },
|
||||
{ set: 'cashier', action: 'read', resource: 'PaymentRegistry' },
|
||||
{ set: 'cashier', action: 'confirm', resource: 'PaymentRegistry' },
|
||||
];
|
||||
for (const r of seedRules) {
|
||||
await db.query(
|
||||
`INSERT INTO access_rules (subject_type, subject_id, effect, action, resource_type, conditions, expires_at, updated_at)
|
||||
SELECT 'capability_set', $1, 'allow', $2, $3, NULL, NULL, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM access_rules
|
||||
WHERE subject_type = 'capability_set' AND subject_id = $1
|
||||
AND action = $2 AND resource_type = $3
|
||||
)`,
|
||||
[r.set, r.action, r.resource],
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('capability_sets + participant_capability_sets созданы, seed accountant/cashier применён');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`capability_sets migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.12 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 2.1 (фикс схемы 1.4): cipher_version/kdf_version в `vaults` —
|
||||
* текстовые версии ('aes-256-gcm-v1' / 'argon2id-v1'), а не smallint.
|
||||
* V2.4.0 объявила их smallint по недосмотру; правим отдельной миграцией,
|
||||
* т.к. применённые миграции иммутабельны. Таблица на этом этапе пуста.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: vaults.cipher_version/kdf_version → text',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN cipher_version TYPE text USING cipher_version::text`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN kdf_version TYPE text USING kdf_version::text`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN cipher_version SET DEFAULT 'aes-256-gcm-v1'`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN kdf_version SET DEFAULT 'argon2id-v1'`);
|
||||
logger.info('vaults: cipher_version/kdf_version → text');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`vault version columns alter failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.1 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 3.6: второй фактор (TOTP / Google Authenticator). Таблица
|
||||
* `two_factor` в coop_domain_db — один секрет на пайщика (subject_id = user.id).
|
||||
* `secret_enc` — Base32-секрет, зашифрованный server-key (aes.ts); сервер обязан
|
||||
* его читать, чтобы проверять коды (это НЕ ключ пайщика — инвариант vault цел).
|
||||
* `enabled=false` — секрет выпущен, но enrollment ещё не подтверждён первым кодом.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: two_factor (TOTP secrets)',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS two_factor (
|
||||
subject_id text PRIMARY KEY,
|
||||
secret_enc text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
confirmed_at timestamptz
|
||||
);
|
||||
`);
|
||||
logger.info('two_factor создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`two_factor migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.2 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 3.4: альтернативный recovery по offline-коду. Таблица
|
||||
* `offline_recovery_code` в coop_domain_db — один код на пайщика (subject_id =
|
||||
* user.id). `code_hash` — keyed HMAC-SHA256(server_secret, code), детерминирован
|
||||
* для lookup по точному хешу; сырой код не хранится. Single-use: строка удаляется
|
||||
* после выдачи recovery-токена (см. OfflineRecoveryService).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: offline_recovery_code',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS offline_recovery_code (
|
||||
subject_id text PRIMARY KEY,
|
||||
code_hash text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
logger.info('offline_recovery_code создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`offline_recovery_code migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.3 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 3.5: стратегия восстановления пайщика. Таблица `recovery_strategy`
|
||||
* в coop_domain_db — одна запись на пайщика (subject_id = user.id). `strategy` —
|
||||
* один из email_magic_link|offline_code|council; отсутствие записи = дефолт
|
||||
* email_magic_link (обратная совместимость с 3.1).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: recovery_strategy',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS recovery_strategy (
|
||||
subject_id text PRIMARY KEY,
|
||||
strategy text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
logger.info('recovery_strategy создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`recovery_strategy migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.4 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 4.2: per-coop правила применения типов верификации. Таблица
|
||||
* `verification_rules` в coop_domain_db — одна запись на действие (action_code =
|
||||
* открытый идентификатор, PRIMARY KEY). `required_types` — обязательные типы
|
||||
* верификации (`text[]`). Отсутствие записи = действие без ограничений по верификации.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: verification_rules',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS verification_rules (
|
||||
action_code text PRIMARY KEY,
|
||||
required_types text[] NOT NULL DEFAULT '{}',
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
logger.info('verification_rules создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`verification_rules migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.5 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 4.6: настройки кооператива в coop_domain_db (таблица `coop_settings`).
|
||||
* Singleton-строка (`id = 1`) — coop_domain_db обслуживает один кооператив, поэтому
|
||||
* глобальные настройки = одна запись; новые настройки добавляются колонками.
|
||||
* `cert_ttl_seconds` — срок жизни participant_certificate (default 3600 = 1ч);
|
||||
* короткий TTL ограничивает окно атаки при компрометации ключа (Story 4.6 AC).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: coop_settings',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS coop_settings (
|
||||
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
cert_ttl_seconds integer NOT NULL DEFAULT 3600,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await db.query(`INSERT INTO coop_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;`);
|
||||
logger.info('coop_settings создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`coop_settings migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.6 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 6.2: CASL Layer 2 — декларативные точечные права. Таблица
|
||||
* `access_rules` в coop_domain_db: правило навешивается на роль (`subject_type='role'`,
|
||||
* `subject_id` = имя core-роли) либо на конкретного пайщика (`subject_type='participant'`,
|
||||
* `subject_id` = username). `effect` allow/deny, `action`/`resource_type` — CASL-словарь,
|
||||
* `conditions` — CASL-условия (jsonb). `expires_at` — TTL точечных capabilities (Story 6.7).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: access_rules',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
// V2.4.0 init создал плейсхолдер access_rules со старой схемой (role/action/subject)
|
||||
// без колонки subject_type. На такой БД `CREATE TABLE IF NOT EXISTS` ниже = no-op,
|
||||
// а индекс по subject_type упал бы. Сносим пустой плейсхолдер (Layer 2 ещё ниоткуда
|
||||
// не пишется → DROP безопасен). Идемпотентно: при правильной схеме DROP не выполняется.
|
||||
await db.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'access_rules'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'access_rules' AND column_name = 'subject_type'
|
||||
) THEN
|
||||
DROP TABLE access_rules;
|
||||
RAISE NOTICE 'access_rules: снесён плейсхолдер V2.4.0';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS access_rules (
|
||||
id bigserial PRIMARY KEY,
|
||||
subject_type text NOT NULL,
|
||||
subject_id text NOT NULL,
|
||||
effect text NOT NULL DEFAULT 'allow',
|
||||
action text NOT NULL,
|
||||
resource_type text NOT NULL,
|
||||
conditions jsonb,
|
||||
expires_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_access_rules_principal ON access_rules (subject_type, subject_id);`);
|
||||
logger.info('access_rules создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`access_rules migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.7 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 6.3: реконсиляция таблицы `access_rules`. В init-миграции V2.4.0
|
||||
* `access_rules` была создана плейсхолдером со старой схемой (`role/action/subject/
|
||||
* conditions`), а Story 6.2 (V2.4.7) добавила правильную схему через
|
||||
* `CREATE TABLE IF NOT EXISTS` — но на любой БД, где V2.4.0 отработала раньше, V2.4.7
|
||||
* становилась no-op, и таблица оставалась со старой схемой. Из-за этого репозиторий
|
||||
* Layer 2 (`PostgresAccessRulesRepository`, колонки `subject_type/subject_id/effect/
|
||||
* resource_type/expires_at`) упал бы в рантайме на `column "subject_type" does not exist`.
|
||||
*
|
||||
* Фикс forward-миграцией (канон append-only, без правки применённых V2.4.0/V2.4.7):
|
||||
* если таблица существует, но без колонки `subject_type` (= старый плейсхолдер) —
|
||||
* пересоздаём с правильной схемой. Данных в плейсхолдере нет (CoopID не в проде,
|
||||
* Layer 2 ни из какого UI ещё не пишет), поэтому DROP безопасен. Идемпотентно:
|
||||
* на БД с уже правильной схемой DROP не выполняется, CREATE IF NOT EXISTS — no-op.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: reconcile access_rules schema (Layer 2)',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
// Старый плейсхолдер из V2.4.0 (нет колонки subject_type) — снести.
|
||||
await db.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'access_rules'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'access_rules' AND column_name = 'subject_type'
|
||||
) THEN
|
||||
DROP TABLE access_rules;
|
||||
RAISE NOTICE 'access_rules: снесён плейсхолдер V2.4.0';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
// Правильная схема Layer 2 (идентична V2.4.7 — единый источник).
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS access_rules (
|
||||
id bigserial PRIMARY KEY,
|
||||
subject_type text NOT NULL,
|
||||
subject_id text NOT NULL,
|
||||
effect text NOT NULL DEFAULT 'allow',
|
||||
action text NOT NULL,
|
||||
resource_type text NOT NULL,
|
||||
conditions jsonb,
|
||||
expires_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_access_rules_principal ON access_rules (subject_type, subject_id);`);
|
||||
logger.info('access_rules: схема Layer 2 реконсилирована');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`access_rules reconcile failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.8 не реализован: реконсиляция схемы необратима без потери данных.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 6.8: multi-party critical actions. Таблица `pending_critical_actions`
|
||||
* в coop_domain_db: критическое действие (исключение пайщика, смена ролей совета,
|
||||
* force-recovery, смена типов верификации) живёт в pending-состоянии до сбора 2 подписей
|
||||
* (инициатор + ≥1 член совета) в окне ≤24ч, иначе истекает. `confirmations` — jsonb-массив
|
||||
* `{by, at}`; `payload` — параметры действия (хэшируется в audit при финализации, Story 6.10).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: pending_critical_actions',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS pending_critical_actions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
action_type text NOT NULL,
|
||||
actor_id text NOT NULL,
|
||||
target_id text NOT NULL,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
confirmations jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
finalized_at timestamptz
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_pending_critical_actions_status ON pending_critical_actions (status, expires_at);`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_pending_critical_actions_target ON pending_critical_actions (target_id);`);
|
||||
logger.info('pending_critical_actions создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`pending_critical_actions migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.9 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -73,6 +73,7 @@
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
"@aws-sdk/client-s3": "^3.1035.0",
|
||||
"@casl/ability": "^7.0.0",
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
@@ -106,6 +107,7 @@
|
||||
"@wharfkit/contract": "^1.1.5",
|
||||
"@wharfkit/session": "^1.4.0",
|
||||
"@wharfkit/wallet-plugin-privatekey": "^1.1.0",
|
||||
"@willsoto/nestjs-prometheus": "^6",
|
||||
"apollo-server-express": "^3.13.0",
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -144,6 +146,7 @@
|
||||
"joi": "^17.13.1",
|
||||
"joi-to-json": "^4.3.0",
|
||||
"joi-to-typescript": "^4.13.0",
|
||||
"jose": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jspdf": "^4.2.0",
|
||||
"libxmljs2": "^0.37.0",
|
||||
@@ -161,6 +164,7 @@
|
||||
"passport-jwt": "^4.0.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pm2": "^5.1.0",
|
||||
"prom-client": "^15",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io-client": "^4.7.5",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
|
||||
|
||||
// Infrastructure modules
|
||||
import { DatabaseModule } from './infrastructure/database/database.module';
|
||||
@@ -29,6 +30,7 @@ import { FreeDecisionDomainModule } from './domain/free-decision/free-decision.m
|
||||
import { AgreementDomainModule } from './domain/agreement/agreement-domain.module';
|
||||
import { ParticipantDomainModule } from './domain/participant/participant-domain.module';
|
||||
import { AuthDomainModule } from './domain/auth/auth.module';
|
||||
import { AuthV2Module } from './application/auth-v2/auth-v2.module';
|
||||
import { AgendaDomainModule } from './domain/agenda/agenda-domain.module';
|
||||
import { CooplaceDomainModule } from './domain/cooplace/cooplace.module';
|
||||
import { DesktopDomainModule } from './domain/desktop/desktop-domain.module';
|
||||
@@ -93,6 +95,11 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
},
|
||||
]),
|
||||
ScheduleModule.forRoot(), // @Interval/@Cron — нужен outbox-worker'у Центра уведомлений
|
||||
// Prometheus pull-метрики (Story 9.11, NFR28): GET /metrics в Prometheus
|
||||
// exposition format + процессные метрики Node на глобальном реестре prom-client
|
||||
// (туда же пишут доменные счётчики AuthMetricsService). Доступ снаружи режет
|
||||
// edge (Caddy, Story 9.2) — как и нативный /metrics authentik.
|
||||
PrometheusModule.register({ defaultMetrics: { enabled: true } }),
|
||||
// Infrastructure modules
|
||||
MongooseModule.forRoot(config.mongoose.url),
|
||||
DatabaseModule,
|
||||
@@ -113,6 +120,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
}),
|
||||
// Domain modules
|
||||
AuthDomainModule,
|
||||
AuthV2Module,
|
||||
RegistrationDomainModule,
|
||||
OnboardingDomainModule,
|
||||
AgendaDomainModule,
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import type { SessionsService } from '../sessions/sessions.service';
|
||||
import type { TwoFactorService } from '../two-factor/two-factor.service';
|
||||
import type { RecoveryStrategyService } from '../recovery/recovery-strategy.service';
|
||||
import type { SecurityIncidentService } from '../security/security-incident.service';
|
||||
import { RecoveryStrategy } from '~/domain/auth-v2/recovery-strategy/recovery-strategy.types';
|
||||
import { AccountSecurityResolver } from './account-security.resolver';
|
||||
|
||||
const USER = { id: 'u1', username: 'payer1', role: 'user' };
|
||||
const IP = '203.0.113.7';
|
||||
|
||||
function build() {
|
||||
const sessions = {
|
||||
list: jest.fn(async () => [
|
||||
{
|
||||
id: 's1',
|
||||
device: 'Firefox',
|
||||
ip: '10.0.0.1',
|
||||
createdAt: '2026-06-13T00:00:00Z',
|
||||
lastSeenAt: '2026-06-14T00:00:00Z',
|
||||
current: true,
|
||||
},
|
||||
]),
|
||||
revoke: jest.fn(async () => undefined),
|
||||
revokeAll: jest.fn(async () => ({ revoked: 3 })),
|
||||
};
|
||||
const twoFactor = {
|
||||
beginEnrollment: jest.fn(async () => ({ secret: 'BASE32SECRET', otpauthUri: 'otpauth://totp/x' })),
|
||||
activate: jest.fn(async () => undefined),
|
||||
disable: jest.fn(async () => undefined),
|
||||
};
|
||||
const recoveryStrategy = {
|
||||
getStrategy: jest.fn(async () => RecoveryStrategy.OfflineCode),
|
||||
setStrategy: jest.fn(async () => undefined),
|
||||
};
|
||||
const incidents = {
|
||||
report: jest.fn(async () => ({ revoked: 2 })),
|
||||
};
|
||||
const resolver = new AccountSecurityResolver(
|
||||
sessions as unknown as SessionsService,
|
||||
twoFactor as unknown as TwoFactorService,
|
||||
recoveryStrategy as unknown as RecoveryStrategyService,
|
||||
incidents as unknown as SecurityIncidentService,
|
||||
);
|
||||
return { resolver, sessions, twoFactor, recoveryStrategy, incidents };
|
||||
}
|
||||
|
||||
describe('AccountSecurityResolver', () => {
|
||||
it('getSessions маппит сессии в snake_case и передаёт refresh-токен текущей сессии', async () => {
|
||||
const { resolver, sessions } = build();
|
||||
const out = await resolver.getSessions(USER, 'rt-current');
|
||||
expect(sessions.list).toHaveBeenCalledWith('u1', 'rt-current');
|
||||
expect(out).toEqual([
|
||||
{
|
||||
id: 's1',
|
||||
device: 'Firefox',
|
||||
ip: '10.0.0.1',
|
||||
created_at: '2026-06-13T00:00:00Z',
|
||||
last_seen_at: '2026-06-14T00:00:00Z',
|
||||
current: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('getRecoveryStrategy возвращает стратегию пайщика', async () => {
|
||||
const { resolver, recoveryStrategy } = build();
|
||||
const out = await resolver.getRecoveryStrategy(USER);
|
||||
expect(recoveryStrategy.getStrategy).toHaveBeenCalledWith('u1');
|
||||
expect(out).toBe(RecoveryStrategy.OfflineCode);
|
||||
});
|
||||
|
||||
it('revokeSession завершает сессию по id с IP для аудита', async () => {
|
||||
const { resolver, sessions } = build();
|
||||
const ok = await resolver.revokeSession({ session_id: 's1' }, USER, IP);
|
||||
expect(ok).toBe(true);
|
||||
expect(sessions.revoke).toHaveBeenCalledWith('u1', 's1', IP);
|
||||
});
|
||||
|
||||
it('revokeAllSessions возвращает число завершённых', async () => {
|
||||
const { resolver, sessions } = build();
|
||||
const out = await resolver.revokeAllSessions(USER, IP);
|
||||
expect(sessions.revokeAll).toHaveBeenCalledWith('u1', IP);
|
||||
expect(out).toEqual({ revoked: 3 });
|
||||
});
|
||||
|
||||
it('enrollTwoFactor маппит otpauthUri в otpauth_uri', async () => {
|
||||
const { resolver, twoFactor } = build();
|
||||
const out = await resolver.enrollTwoFactor(USER);
|
||||
expect(twoFactor.beginEnrollment).toHaveBeenCalledWith('u1', 'payer1');
|
||||
expect(out).toEqual({ secret: 'BASE32SECRET', otpauth_uri: 'otpauth://totp/x' });
|
||||
});
|
||||
|
||||
it('activateTwoFactor подтверждает второй фактор кодом', async () => {
|
||||
const { resolver, twoFactor } = build();
|
||||
const ok = await resolver.activateTwoFactor({ code: '123456' }, USER, IP);
|
||||
expect(ok).toBe(true);
|
||||
expect(twoFactor.activate).toHaveBeenCalledWith('u1', '123456', IP);
|
||||
});
|
||||
|
||||
it('disableTwoFactor отключает второй фактор кодом', async () => {
|
||||
const { resolver, twoFactor } = build();
|
||||
const ok = await resolver.disableTwoFactor({ code: '654321' }, USER, IP);
|
||||
expect(ok).toBe(true);
|
||||
expect(twoFactor.disable).toHaveBeenCalledWith('u1', '654321', IP);
|
||||
});
|
||||
|
||||
it('setRecoveryStrategy меняет стратегию со step-up кодом', async () => {
|
||||
const { resolver, recoveryStrategy } = build();
|
||||
const ok = await resolver.setRecoveryStrategy({ strategy: RecoveryStrategy.Council, code: '000111' }, USER, IP);
|
||||
expect(ok).toBe(true);
|
||||
expect(recoveryStrategy.setStrategy).toHaveBeenCalledWith('u1', RecoveryStrategy.Council, '000111', IP);
|
||||
});
|
||||
|
||||
it('reportNotMe зовёт report с source=settings и подозрительной сессией', async () => {
|
||||
const { resolver, incidents } = build();
|
||||
const out = await resolver.reportNotMe({ session_id: 's9' }, USER, IP);
|
||||
expect(incidents.report).toHaveBeenCalledWith({
|
||||
subjectId: 'u1',
|
||||
ip: IP,
|
||||
source: 'settings',
|
||||
reportedSessionId: 's9',
|
||||
});
|
||||
expect(out).toEqual({ revoked: 2 });
|
||||
});
|
||||
|
||||
it('reportNotMe без session_id передаёт reportedSessionId=null', async () => {
|
||||
const { resolver, incidents } = build();
|
||||
await resolver.reportNotMe({}, USER, IP);
|
||||
expect(incidents.report).toHaveBeenCalledWith({
|
||||
subjectId: 'u1',
|
||||
ip: IP,
|
||||
source: 'settings',
|
||||
reportedSessionId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import { ClientIp, RefreshTokenHeader } from '~/application/auth/decorators/request-meta.decorator';
|
||||
import { RecoveryStrategy } from '~/domain/auth-v2/recovery-strategy/recovery-strategy.types';
|
||||
import { SessionsService } from '../sessions/sessions.service';
|
||||
import { TwoFactorService } from '../two-factor/two-factor.service';
|
||||
import { RecoveryStrategyService } from '../recovery/recovery-strategy.service';
|
||||
import { SecurityIncidentService } from '../security/security-incident.service';
|
||||
import {
|
||||
AccountSessionDTO,
|
||||
ReportNotMeInputDTO,
|
||||
RevokeSessionInputDTO,
|
||||
RevokedSessionsResultDTO,
|
||||
SetRecoveryStrategyInputDTO,
|
||||
TwoFactorCodeInputDTO,
|
||||
TwoFactorEnrollmentDTO,
|
||||
} from './dto/account-security.dto';
|
||||
|
||||
interface ICurrentUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL-фасад самообслуживания безопасности аккаунта (Фаза 2 миграции REST→GraphQL/SDK).
|
||||
* Заменяет REST-контроллеры `coop/sessions`, `coop/2fa`, `coop/recovery/strategy` и
|
||||
* JWT-метод `coop/security/not-me` — фронт ходит через @coopenomics/sdk (Zeus), нового
|
||||
* способа взаимодействия с бэкендом наружу не появляется.
|
||||
*
|
||||
* Все операции — для текущего залогиненного пайщика (subject = `user.id`) под
|
||||
* `GqlJwtAuthGuard`. IP и refresh-токен текущей сессии — транспорт, берутся из
|
||||
* request-meta декораторов, не из GraphQL-переменных.
|
||||
*/
|
||||
@Resolver()
|
||||
export class AccountSecurityResolver {
|
||||
constructor(
|
||||
private readonly sessions: SessionsService,
|
||||
private readonly twoFactor: TwoFactorService,
|
||||
private readonly recoveryStrategy: RecoveryStrategyService,
|
||||
private readonly incidents: SecurityIncidentService,
|
||||
) {}
|
||||
|
||||
@Query(() => [AccountSessionDTO], {
|
||||
name: 'getSessions',
|
||||
description: 'Активные сессии текущего пайщика (текущая помечается current)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async getSessions(
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
@RefreshTokenHeader() currentRefreshToken: string | null,
|
||||
): Promise<AccountSessionDTO[]> {
|
||||
const sessions = await this.sessions.list(user.id, currentRefreshToken);
|
||||
return sessions.map((s) => ({
|
||||
id: s.id,
|
||||
device: s.device,
|
||||
ip: s.ip,
|
||||
created_at: s.createdAt,
|
||||
last_seen_at: s.lastSeenAt,
|
||||
current: s.current,
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => RecoveryStrategy, {
|
||||
name: 'getRecoveryStrategy',
|
||||
description: 'Текущая стратегия восстановления доступа пайщика',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async getRecoveryStrategy(@CurrentUser() user: ICurrentUser): Promise<RecoveryStrategy> {
|
||||
return this.recoveryStrategy.getStrategy(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'revokeSession',
|
||||
description: 'Завершить конкретную сессию пайщика',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async revokeSession(
|
||||
@Args('data', { type: () => RevokeSessionInputDTO }) data: RevokeSessionInputDTO,
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
@ClientIp() ip: string | null,
|
||||
): Promise<boolean> {
|
||||
await this.sessions.revoke(user.id, data.session_id, ip);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => RevokedSessionsResultDTO, {
|
||||
name: 'revokeAllSessions',
|
||||
description: 'Завершить все сессии пайщика',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async revokeAllSessions(
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
@ClientIp() ip: string | null,
|
||||
): Promise<RevokedSessionsResultDTO> {
|
||||
return this.sessions.revokeAll(user.id, ip);
|
||||
}
|
||||
|
||||
@Mutation(() => TwoFactorEnrollmentDTO, {
|
||||
name: 'enrollTwoFactor',
|
||||
description: 'Начать подключение второго фактора: выпустить секрет и otpauth-URI для QR',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async enrollTwoFactor(@CurrentUser() user: ICurrentUser): Promise<TwoFactorEnrollmentDTO> {
|
||||
const challenge = await this.twoFactor.beginEnrollment(user.id, user.username);
|
||||
return { secret: challenge.secret, otpauth_uri: challenge.otpauthUri };
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'activateTwoFactor',
|
||||
description: 'Подтвердить подключение второго фактора первым кодом',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async activateTwoFactor(
|
||||
@Args('data', { type: () => TwoFactorCodeInputDTO }) data: TwoFactorCodeInputDTO,
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
@ClientIp() ip: string | null,
|
||||
): Promise<boolean> {
|
||||
await this.twoFactor.activate(user.id, data.code, ip);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'disableTwoFactor',
|
||||
description: 'Отключить второй фактор (требует валидный код)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async disableTwoFactor(
|
||||
@Args('data', { type: () => TwoFactorCodeInputDTO }) data: TwoFactorCodeInputDTO,
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
@ClientIp() ip: string | null,
|
||||
): Promise<boolean> {
|
||||
await this.twoFactor.disable(user.id, data.code, ip);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'setRecoveryStrategy',
|
||||
description: 'Сменить стратегию восстановления (требует step-up второго фактора)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async setRecoveryStrategy(
|
||||
@Args('data', { type: () => SetRecoveryStrategyInputDTO }) data: SetRecoveryStrategyInputDTO,
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
@ClientIp() ip: string | null,
|
||||
): Promise<boolean> {
|
||||
await this.recoveryStrategy.setStrategy(user.id, data.strategy, data.code, ip);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => RevokedSessionsResultDTO, {
|
||||
name: 'reportNotMe',
|
||||
description: 'Сигнал «Это не я»: немедленно завершить все сессии пайщика',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async reportNotMe(
|
||||
@Args('data', { type: () => ReportNotMeInputDTO }) data: ReportNotMeInputDTO,
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
@ClientIp() ip: string | null,
|
||||
): Promise<RevokedSessionsResultDTO> {
|
||||
return this.incidents.report({
|
||||
subjectId: user.id,
|
||||
ip,
|
||||
source: 'settings',
|
||||
reportedSessionId: data.session_id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { RecoveryStrategy } from '~/domain/auth-v2/recovery-strategy/recovery-strategy.types';
|
||||
|
||||
/**
|
||||
* GraphQL-контракт самообслуживания безопасности аккаунта пайщика (Фаза 2 миграции
|
||||
* REST→GraphQL/SDK): активные сессии (Story 3.7), второй фактор (3.6), стратегия
|
||||
* восстановления (3.5), сигнал «Это не я» (3.10). Зеркало доменных типов auth-v2,
|
||||
* поля — snake_case по канону GraphQL этого репозитория; резолвер маппит camelCase
|
||||
* сервисов в snake_case этих DTO.
|
||||
*
|
||||
* Зачем GraphQL, а не REST: единый типизированный фасад фронта — @coopenomics/sdk
|
||||
* (Zeus). Нового способа взаимодействия с бэкендом наружу не появляется; bearer
|
||||
* живёт только в SDK. Транспорт (IP, refresh-токен текущей сессии) — через
|
||||
* request-meta декораторы, не как GraphQL-переменные.
|
||||
*/
|
||||
|
||||
registerEnumType(RecoveryStrategy, {
|
||||
name: 'RecoveryStrategy',
|
||||
description: 'Разрешённый канал восстановления доступа пайщика (активен ровно один)',
|
||||
});
|
||||
|
||||
/** Активная сессия пайщика — устройство, с которого выполнен вход. */
|
||||
@ObjectType('AccountSession')
|
||||
export class AccountSessionDTO {
|
||||
@Field(() => String, { description: 'Идентификатор сессии (для точечного завершения)' })
|
||||
id!: string;
|
||||
|
||||
@Field(() => String, { description: 'Устройство входа (User-Agent); заглушка, если метаданные не сохранялись' })
|
||||
device!: string;
|
||||
|
||||
@Field(() => String, { description: 'IP входа; заглушка, если метаданные не сохранялись' })
|
||||
ip!: string;
|
||||
|
||||
@Field(() => String, { description: 'Время создания сессии (ISO)' })
|
||||
created_at!: string;
|
||||
|
||||
@Field(() => String, { description: 'Последняя зафиксированная активность (ISO)' })
|
||||
last_seen_at!: string;
|
||||
|
||||
@Field(() => Boolean, { description: 'Текущая сессия (с которой выполнен запрос)' })
|
||||
current!: boolean;
|
||||
}
|
||||
|
||||
/** Результат массового завершения сессий. */
|
||||
@ObjectType('RevokedSessionsResult')
|
||||
export class RevokedSessionsResultDTO {
|
||||
@Field(() => Int, { description: 'Сколько активных сессий завершено' })
|
||||
revoked!: number;
|
||||
}
|
||||
|
||||
/** Вызов на подключение второго фактора: данные для ручного ввода и QR. */
|
||||
@ObjectType('TwoFactorEnrollment')
|
||||
export class TwoFactorEnrollmentDTO {
|
||||
@Field(() => String, { description: 'Base32-секрет для ручного ввода в приложение-аутентификатор' })
|
||||
secret!: string;
|
||||
|
||||
@Field(() => String, { description: 'otpauth://-URI для QR-кода' })
|
||||
otpauth_uri!: string;
|
||||
}
|
||||
|
||||
/** Вход на завершение конкретной сессии. */
|
||||
@InputType('RevokeSessionInput')
|
||||
export class RevokeSessionInputDTO {
|
||||
@Field(() => String, { description: 'Идентификатор завершаемой сессии' })
|
||||
session_id!: string;
|
||||
}
|
||||
|
||||
/** Вход на операции второго фактора, требующие TOTP-кода (активация/отключение). */
|
||||
@InputType('TwoFactorCodeInput')
|
||||
export class TwoFactorCodeInputDTO {
|
||||
@Field(() => String, { description: 'Одноразовый код из приложения-аутентификатора' })
|
||||
code!: string;
|
||||
}
|
||||
|
||||
/** Вход на смену стратегии восстановления (требует step-up второго фактора). */
|
||||
@InputType('SetRecoveryStrategyInput')
|
||||
export class SetRecoveryStrategyInputDTO {
|
||||
@Field(() => RecoveryStrategy, { description: 'Новая стратегия восстановления' })
|
||||
strategy!: RecoveryStrategy;
|
||||
|
||||
@Field(() => String, { description: 'TOTP-код для подтверждения смены (step-up)' })
|
||||
code!: string;
|
||||
}
|
||||
|
||||
/** Вход на сигнал «Это не я» из настроек ЛК. */
|
||||
@InputType('ReportNotMeInput')
|
||||
export class ReportNotMeInputDTO {
|
||||
@Field(() => String, { nullable: true, description: 'Идентификатор подозрительной сессии (опционально, из настроек)' })
|
||||
session_id?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
/** Ключ метаданных, который читает `AuditActionInterceptor` (Story 8.5). */
|
||||
export const AUDIT_ACTION = 'coopid:audit-action';
|
||||
|
||||
/** Описание метки авто-аудита: категория действия (по умолчанию `admin`). */
|
||||
export interface AuditActionMeta {
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Помечает резолвер/endpoint для автоматического аудита админ-действия (Story 8.5):
|
||||
* `AuditActionInterceptor` пишет `audit_events` без ручного вызова `AuditService.record`
|
||||
* в каждом резолвере (защита от человеческого фактора). Имя события строится из имени
|
||||
* хэндлера: `coopid.<category>.<handlerName>`.
|
||||
*
|
||||
* @example `@AuditAction()` → `coopid.admin.changeRoles`
|
||||
*/
|
||||
export function AuditAction(category = 'admin'): MethodDecorator & ClassDecorator {
|
||||
const meta: AuditActionMeta = { category };
|
||||
return SetMetadata(AUDIT_ACTION, meta);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CallHandler, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { firstValueFrom, of, throwError } from 'rxjs';
|
||||
import { AuditActionInterceptor } from './audit-action.interceptor';
|
||||
import type { AuditService } from './audit.service';
|
||||
|
||||
function makeInterceptor(meta: { category: string } | undefined) {
|
||||
const record = jest.fn((_rec: Parameters<AuditService['record']>[0]) => Promise.resolve());
|
||||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(meta) } as unknown as Reflector;
|
||||
const interceptor = new AuditActionInterceptor(reflector, { record } as unknown as AuditService);
|
||||
return { interceptor, record };
|
||||
}
|
||||
|
||||
function httpCtx(handlerName: string, req: Record<string, unknown>): ExecutionContext {
|
||||
return {
|
||||
getType: () => 'http',
|
||||
getHandler: () => ({ name: handlerName }),
|
||||
getClass: () => class {},
|
||||
switchToHttp: () => ({ getRequest: () => req }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
/** Фейковый GraphQL ExecutionContext (как в authorization.guard.spec). */
|
||||
function gqlCtx(handlerName: string, args: Record<string, unknown>, reqObj: unknown): ExecutionContext {
|
||||
const gqlArgs = [{}, args, { req: reqObj }, {}];
|
||||
return {
|
||||
getType: () => 'graphql',
|
||||
getHandler: () => ({ name: handlerName }),
|
||||
getClass: () => class {},
|
||||
getArgs: () => gqlArgs,
|
||||
getArgByIndex: (i: number) => gqlArgs[i],
|
||||
switchToHttp: () => ({ getRequest: () => ({}) }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
const okHandler = (value: unknown = { ok: true }): CallHandler => ({ handle: () => of(value) });
|
||||
const errHandler = (err: unknown): CallHandler => ({ handle: () => throwError(() => err) });
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
describe('AuditActionInterceptor — авто-аудит admin-действий (Story 8.5)', () => {
|
||||
it('нет @AuditAction → запись не делается, хэндлер проходит', async () => {
|
||||
const { interceptor, record } = makeInterceptor(undefined);
|
||||
const res = await firstValueFrom(interceptor.intercept(httpCtx('changeRoles', { user: {} }), okHandler('R')));
|
||||
await flush();
|
||||
expect(res).toBe('R');
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('HTTP success: event=coopid.admin.<handler>, subjectId=target_id, actor, result=success', async () => {
|
||||
const { interceptor, record } = makeInterceptor({ category: 'admin' });
|
||||
const req = { user: { username: 'chief' }, params: { target_id: 'p1' }, query: {}, body: { role: 'member' } };
|
||||
await firstValueFrom(interceptor.intercept(httpCtx('changeRoles', req), okHandler()));
|
||||
await flush();
|
||||
expect(record).toHaveBeenCalledWith({
|
||||
event: 'coopid.admin.changeRoles',
|
||||
subjectId: 'p1',
|
||||
actor: 'chief',
|
||||
result: 'success',
|
||||
context: { target_id: 'p1', role: 'member' },
|
||||
});
|
||||
});
|
||||
|
||||
it('subjectId фолбэк на id, если нет target_id', async () => {
|
||||
const { interceptor, record } = makeInterceptor({ category: 'admin' });
|
||||
const req = { user: { username: 'chief' }, params: {}, query: {}, body: { id: 'x9' } };
|
||||
await firstValueFrom(interceptor.intercept(httpCtx('grantCapability', req), okHandler()));
|
||||
await flush();
|
||||
expect(record).toHaveBeenCalledWith(expect.objectContaining({ event: 'coopid.admin.grantCapability', subjectId: 'x9' }));
|
||||
});
|
||||
|
||||
it('GraphQL: args берутся из data', async () => {
|
||||
const { interceptor, record } = makeInterceptor({ category: 'admin' });
|
||||
const ctx = gqlCtx('excludeParticipant', { data: { target_id: 'p7', reason: 'x' } }, { user: { username: 'chief' } });
|
||||
await firstValueFrom(interceptor.intercept(ctx, okHandler()));
|
||||
await flush();
|
||||
expect(record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'coopid.admin.excludeParticipant',
|
||||
subjectId: 'p7',
|
||||
context: { target_id: 'p7', reason: 'x' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('секреты выкинуты в _redacted (вкл. вложенность), запись проходит secret-blacklist', async () => {
|
||||
const { interceptor, record } = makeInterceptor({ category: 'admin' });
|
||||
const req = {
|
||||
user: { username: 'chief' },
|
||||
params: { target_id: 'p1' },
|
||||
query: {},
|
||||
body: { access_token: 'T', nested: { private_key: 'K', keep: 1 }, signature: 'S', ok: 'v' },
|
||||
};
|
||||
await firstValueFrom(interceptor.intercept(httpCtx('rotate', req), okHandler()));
|
||||
await flush();
|
||||
const ctx = record.mock.calls[0][0].context as Record<string, unknown>;
|
||||
expect(ctx.ok).toBe('v');
|
||||
expect(ctx.access_token).toBeUndefined();
|
||||
expect(ctx.signature).toBeUndefined();
|
||||
expect(ctx._redacted).toEqual(expect.arrayContaining(['access_token', 'signature']));
|
||||
expect((ctx.nested as Record<string, unknown>).private_key).toBeUndefined();
|
||||
expect((ctx.nested as Record<string, unknown>)._redacted).toEqual(['private_key']);
|
||||
expect((ctx.nested as Record<string, unknown>).keep).toBe(1);
|
||||
});
|
||||
|
||||
it('ошибка резолвера → result=failure + rethrow исходной ошибки', async () => {
|
||||
const { interceptor, record } = makeInterceptor({ category: 'admin' });
|
||||
const boom = new Error('boom');
|
||||
const req = { user: { username: 'chief' }, params: { target_id: 'p1' }, query: {}, body: {} };
|
||||
await expect(firstValueFrom(interceptor.intercept(httpCtx('changeRoles', req), errHandler(boom)))).rejects.toBe(boom);
|
||||
await flush();
|
||||
expect(record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'coopid.admin.changeRoles',
|
||||
result: 'failure',
|
||||
context: expect.objectContaining({ _error: 'boom' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { Observable, catchError, tap, throwError } from 'rxjs';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AUDIT_ACTION, type AuditActionMeta } from './audit-action.decorator';
|
||||
|
||||
/** Ключи, имена которых нельзя оставлять в audit-metadata (как secret-blacklist 8.2). */
|
||||
const SECRET_KEY_PATTERNS = ['password', 'private_key', 'token', 'secret', 'signature'];
|
||||
|
||||
/**
|
||||
* Авто-аудит админ-действий (Story 8.5). Резолвер/endpoint, помеченный `@AuditAction`,
|
||||
* пишет `audit_events` автоматически: `event = coopid.<category>.<handlerName>`,
|
||||
* `subject_id = args.target_id || args.id`, `result = success|failure`,
|
||||
* `metadata = sanitizeArgs(args)` (ключи-секреты выкинуты).
|
||||
*
|
||||
* Извлечение user/args из GraphQL и HTTP — тем же приёмом, что `AuthorizationGuard` (6.4).
|
||||
* Запись best-effort (в `.catch`): аудит не подменяет исходный результат/ошибку резолвера.
|
||||
* Применяется точечно `@UseInterceptors(AuditActionInterceptor)` на admin-контроллере,
|
||||
* НЕ как глобальный APP_INTERCEPTOR (канон 6.4/6.5).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditActionInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(AuditActionInterceptor.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const meta = this.reflector.getAllAndOverride<AuditActionMeta | undefined>(AUDIT_ACTION, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!meta) return next.handle(); // endpoint не помечен — не аудируем
|
||||
|
||||
const { user, args } = this.extract(context);
|
||||
const event = `coopid.${meta.category}.${context.getHandler().name}`;
|
||||
const subjectId = (args.target_id ?? args.id ?? null) as string | null;
|
||||
const actor = (user?.username ?? null) as string | null;
|
||||
const metadata = this.sanitizeArgs(args);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
void this.record({ event, subjectId, actor, result: 'success', context: metadata });
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
void this.record({ event, subjectId, actor, result: 'failure', context: { ...metadata, _error: message } });
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** best-effort: провал аудита не должен ронять/подменять ответ резолвера. */
|
||||
private async record(rec: Parameters<AuditService['record']>[0]): Promise<void> {
|
||||
try {
|
||||
await this.audit.record(rec);
|
||||
} catch (e) {
|
||||
this.logger.error(`audit-action запись не удалась (${rec.event}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private extract(context: ExecutionContext): { user: { username?: string } | undefined; args: Record<string, unknown> } {
|
||||
if (context.getType<GqlContextType>() === 'graphql') {
|
||||
const gql = GqlExecutionContext.create(context);
|
||||
const req = gql.getContext().req;
|
||||
const a = gql.getArgs<Record<string, unknown>>();
|
||||
const args = (a?.data ?? a?.filter ?? a ?? {}) as Record<string, unknown>;
|
||||
return { user: req?.user, args };
|
||||
}
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const args = { ...(req?.params ?? {}), ...(req?.query ?? {}), ...(req?.body ?? {}) } as Record<string, unknown>;
|
||||
return { user: req?.user, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно убирает ключи-секреты (на всех уровнях вложенности); выкинутые имена —
|
||||
* в `_redacted[]`. Именно удаление, а не маска значения: secret-blacklist `AuditService`
|
||||
* бросает на запретный КЛЮЧ, поэтому ключ оставлять нельзя.
|
||||
*/
|
||||
private sanitizeArgs(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
const redacted: string[] = [];
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (SECRET_KEY_PATTERNS.some((p) => key.toLowerCase().includes(p))) {
|
||||
redacted.push(key);
|
||||
continue;
|
||||
}
|
||||
out[key] = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? this.sanitizeArgs(value as Record<string, unknown>)
|
||||
: value;
|
||||
}
|
||||
if (redacted.length) out._redacted = redacted;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { AuditService, assertContextHasNoSecrets } from './audit.service';
|
||||
|
||||
/**
|
||||
* Story 8.2: структурированные audit-поля. DataSource замокан через spy на приватный
|
||||
* getDataSource — проверяем форму INSERT (включая первоклассную колонку user_agent) и
|
||||
* инвариант secret-blacklist, без реального coop_domain_db.
|
||||
*/
|
||||
describe('AuditService — структурированные поля (Story 8.2)', () => {
|
||||
function setup() {
|
||||
const query = jest.fn((_sql: string, _params: unknown[]) => Promise.resolve(undefined));
|
||||
const service = new AuditService();
|
||||
jest.spyOn(service as unknown as { getDataSource: () => Promise<unknown> }, 'getDataSource').mockResolvedValue({ query });
|
||||
return { service, query };
|
||||
}
|
||||
|
||||
it('record пишет user_agent отдельным параметром (а не в context)', async () => {
|
||||
const { service, query } = setup();
|
||||
await service.record({
|
||||
event: 'coopid.login.successful',
|
||||
subjectId: 'u1',
|
||||
actor: 'ant',
|
||||
result: 'success',
|
||||
ip: '1.2.3.4',
|
||||
userAgent: 'Chrome/120',
|
||||
context: { device_new: true },
|
||||
});
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = query.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('user_agent');
|
||||
expect(sql).toContain('INSERT INTO audit_events');
|
||||
// Порядок: event, subject_id, actor, result, context(json), ip, user_agent.
|
||||
expect(params[0]).toBe('coopid.login.successful');
|
||||
expect(params[1]).toBe('u1');
|
||||
expect(params[2]).toBe('ant');
|
||||
expect(params[3]).toBe('success');
|
||||
expect(JSON.parse(params[4] as string)).toEqual({ device_new: true });
|
||||
expect(params[5]).toBe('1.2.3.4');
|
||||
expect(params[6]).toBe('Chrome/120');
|
||||
});
|
||||
|
||||
it('userAgent отсутствует → null', async () => {
|
||||
const { service, query } = setup();
|
||||
await service.record({ event: 'e', result: 'success' });
|
||||
const [, params] = query.mock.calls[0] as [string, unknown[]];
|
||||
expect(params[6]).toBeNull();
|
||||
});
|
||||
|
||||
it('context/ip/subject/actor по умолчанию: пустой json, null', async () => {
|
||||
const { service, query } = setup();
|
||||
await service.record({ event: 'e', result: 'failure' });
|
||||
const [, params] = query.mock.calls[0] as [string, unknown[]];
|
||||
expect(params[1]).toBeNull(); // subject_id
|
||||
expect(params[2]).toBeNull(); // actor
|
||||
expect(JSON.parse(params[4] as string)).toEqual({}); // context
|
||||
expect(params[5]).toBeNull(); // ip
|
||||
});
|
||||
|
||||
it('secret-blacklist: ключ с token/secret/password/private_key/signature → throw (вкл. вложенность)', async () => {
|
||||
const { service, query } = setup();
|
||||
await expect(service.record({ event: 'e', result: 'success', context: { access_token: 'x' } })).rejects.toThrow(/секрет/);
|
||||
await expect(service.record({ event: 'e', result: 'success', context: { nested: { api_secret: 'x' } } })).rejects.toThrow(/секрет/);
|
||||
// Запись в БД не дошла.
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assertContextHasNoSecrets пропускает безопасные ключи (user_agent, ip_unknown)', () => {
|
||||
expect(() => assertContextHasNoSecrets({ user_agent: 'Chrome', ip_unknown: 'internal_call' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '~/config/config';
|
||||
|
||||
export type AuditResult = 'success' | 'failure' | 'degraded';
|
||||
|
||||
export interface AuditRecord {
|
||||
event: string;
|
||||
subjectId?: string | null;
|
||||
actor?: string | null;
|
||||
result: AuditResult;
|
||||
context?: Record<string, unknown>;
|
||||
ip?: string | null;
|
||||
/**
|
||||
* User-Agent инициатора (форензика, Story 8.2). Первоклассная колонка `user_agent`
|
||||
* (миграция V2.4.11). Отсутствует → null; причину отсутствия (internal call и т.п.)
|
||||
* вызывающий ставит флагом в `context` (конвенция explicit-null-with-reason,
|
||||
* docs/audit/event-schema.md), сам сервис её не домысливает.
|
||||
*/
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
/** Ключи, которым нечего делать в audit-контексте ни на каком уровне вложенности. */
|
||||
const SECRET_KEY_PATTERNS = ['password', 'private_key', 'token', 'secret', 'signature'];
|
||||
|
||||
/**
|
||||
* Blacklist-инвариант аудита (архитектура CoopID): попытка записать секрет
|
||||
* в context — программная ошибка, бросаем сразу, а не маскируем.
|
||||
*/
|
||||
export function assertContextHasNoSecrets(context: Record<string, unknown>, path = ''): void {
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (SECRET_KEY_PATTERNS.some((p) => lower.includes(p)))
|
||||
throw new Error(`AuditService: ключ «${path}${key}» похож на секрет — запись в audit_events запрещена`);
|
||||
if (value && typeof value === 'object' && !Array.isArray(value))
|
||||
assertContextHasNoSecrets(value as Record<string, unknown>, `${path}${key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись в append-only audit_events (coop_domain_db). Подключение ленивое:
|
||||
* coop-postgres недоступен → ошибка только у вызывающего, запуск coopback
|
||||
* не зависит от этого сервиса.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
private ds: DataSource | null = null;
|
||||
private initializing: Promise<DataSource> | null = null;
|
||||
|
||||
private getDataSource(): Promise<DataSource> {
|
||||
if (this.ds?.isInitialized) return Promise.resolve(this.ds);
|
||||
if (!this.initializing) {
|
||||
this.initializing = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
})
|
||||
.initialize()
|
||||
.then((ds) => {
|
||||
this.ds = ds;
|
||||
return ds;
|
||||
})
|
||||
.finally(() => {
|
||||
this.initializing = null;
|
||||
});
|
||||
}
|
||||
return this.initializing;
|
||||
}
|
||||
|
||||
async record(record: AuditRecord): Promise<void> {
|
||||
const context = record.context ?? {};
|
||||
assertContextHasNoSecrets(context);
|
||||
const ds = await this.getDataSource();
|
||||
await ds.query(
|
||||
`INSERT INTO audit_events (event, subject_id, actor, result, context, ip, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
record.event,
|
||||
record.subjectId ?? null,
|
||||
record.actor ?? null,
|
||||
record.result,
|
||||
JSON.stringify(context),
|
||||
record.ip ?? null,
|
||||
record.userAgent ?? null,
|
||||
],
|
||||
);
|
||||
this.logger.log(`audit: ${record.event} subject=${record.subjectId ?? '-'} result=${record.result}`);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.ds?.isInitialized) await this.ds.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# CoopID — схема audit-событий (`audit_events`)
|
||||
|
||||
Канонический контракт аудита аутентификации CoopID. Источник истины для следователя/
|
||||
compliance: каждое событие безопасности пишется в append-only таблицу `audit_events`
|
||||
(coop_domain_db) через `AuditService.record`.
|
||||
|
||||
> Истории: 8.1 (схема + append-only + партиции), **8.2 (структурированные поля — этот
|
||||
> документ)**, 8.3/8.4 (проводка OIDC/key-rotation), 8.7 (лог-санитайзер).
|
||||
|
||||
## Таблица `audit_events`
|
||||
|
||||
Append-only: помесячные партиции `PARTITION BY RANGE`, триггеры запрещают `UPDATE`/`DELETE`/
|
||||
`TRUNCATE` (`RAISE EXCEPTION 'audit_events is append-only'`). `GRANT INSERT,SELECT` приложению
|
||||
(`coop_app_user`), `GRANT SELECT` читателю аудита (`coop_audit_reader`). Подробности — миграции
|
||||
`V2.4.0` + `V2.4.11`.
|
||||
|
||||
| Колонка | Тип | Семантика |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` IDENTITY | PK (часть с `created_at` для партиции) |
|
||||
| `event` | `text` NOT NULL | Имя события (см. каталог ниже). **= AC `action`** |
|
||||
| `subject_id` | `text` | Над кем/над чем событие (пайщик, действие) |
|
||||
| `actor` | `text` | Кто инициировал (username). Канон CoopID сверх AC |
|
||||
| `result` | `text` NOT NULL | `success` \| `failure` \| `degraded` |
|
||||
| `context` | `jsonb` NOT NULL `{}` | Доп. метаданные события. **= AC `metadata`** |
|
||||
| `ip` | `inet` | IP инициатора, либо `null` |
|
||||
| `user_agent` | `text` | User-Agent инициатора, либо `null` (Story 8.2) |
|
||||
| `created_at` | `timestamptz` NOT NULL `now()` | Время. **= AC `timestamp`** |
|
||||
|
||||
**Дрейф имён ↔ AC (прав код, см. 8.1):** боевая таблица создана в `V2.4.0` до формализации
|
||||
AC; имена `event`/`context`/`created_at` сохранены (партиционированная append-only таблица,
|
||||
~30 точек записи) и семантически равны AC `action`/`metadata`/`timestamp`. `actor` —
|
||||
дополнительная колонка CoopID (AC отдельного инициатора не выделяет).
|
||||
|
||||
## Обязательные поля при записи
|
||||
|
||||
`AuditService.record({ event, subjectId?, actor?, result, context?, ip?, userAgent? })`:
|
||||
- **обязательны**: `event`, `result`;
|
||||
- остальные — заполняются, когда данные доступны; отсутствующее значение → `null`
|
||||
(`subjectId`/`ip`/`userAgent`) либо `{}` (`context`).
|
||||
|
||||
## Конвенция explicit-null-with-reason
|
||||
|
||||
Если поле физически отсутствует — пишем `null`, а **причину отсутствия** кладём флагом в
|
||||
`context`. Причину знает только вызывающий (internal call? нет HTTP-заголовка?), поэтому
|
||||
`AuditService` её НЕ домысливает — это caller-side-конвенция:
|
||||
|
||||
```ts
|
||||
// внутренний вызов без HTTP-контекста: IP неизвестен по причине
|
||||
await audit.record({
|
||||
event: 'coopid.session.revoked_all',
|
||||
subjectId: userId,
|
||||
result: 'success',
|
||||
ip: null,
|
||||
context: { ip_unknown: 'internal_call' },
|
||||
});
|
||||
```
|
||||
|
||||
Так следователь отличает «IP не записали по ошибке» от «IP осознанно отсутствует, потому
|
||||
что вызов внутренний».
|
||||
|
||||
## Инвариант secret-blacklist
|
||||
|
||||
`context` НЕ должен содержать секретов. Ключ (на любом уровне вложенности), чьё имя содержит
|
||||
`password` / `private_key` / `token` / `secret` / `signature`, приводит к `throw` в
|
||||
`assertContextHasNoSecrets` — это программная ошибка вызывающего, а не повод маскировать.
|
||||
Поэтому в `context` нельзя называть ключ так, чтобы он попал под blacklist (например, для
|
||||
хэша подписи использовать `payload_hash`, а не `signature`).
|
||||
|
||||
## Каталог имён событий
|
||||
|
||||
Self-service / вход:
|
||||
- `coopid.login.successful` — успешный вход (этап 2 verify-timestamp), `actor`+UA+IP.
|
||||
- `coopid.verify.timestamp` — проверка подписи timestamp.
|
||||
- `coopid.auth.degraded` — degraded-вход при недоступном COOPOS (`result: degraded`).
|
||||
- `coopid.logout` — RP-initiated logout.
|
||||
- `coopid.session.revoked` / `coopid.session.revoked_all` — отзыв сессии(й).
|
||||
|
||||
Recovery / 2FA:
|
||||
- `coopid.recovery.requested` / `confirmed` / `cancelled` — жизненный цикл recovery.
|
||||
- `coopid.recovery.strategy_changed` — смена recovery-стратегии.
|
||||
- `coopid.2fa.enabled` / `coopid.2fa.disabled` — управление TOTP.
|
||||
|
||||
Безопасность:
|
||||
- `coopid.security.account_locked` — escalating-lockout (трекер хэширован).
|
||||
- `coopid.security.suspicious_login_reported` — «это не я».
|
||||
- `WeakPasswordRejected` — отклонён слабый пароль при регистрации/смене.
|
||||
|
||||
Критические действия (multi-party, Эпик 6):
|
||||
- `CriticalActionConfirmed` / `CriticalActionExpired` — финализация/истечение (оба подписанта
|
||||
+ `payload_hash`).
|
||||
- `ForceRecoveryConsentRequested` / `ForceRecoveryConsentGranted` / `ForceRecoveryAuthorized` /
|
||||
`ForceRecoveryDenied` — force-recovery rules.
|
||||
- `KeyRevokedManually` — ручной отзыв скомпрометированного ключа (`reason`+`chairman_id`).
|
||||
|
||||
Назначаемые роли (Story 6.11):
|
||||
- `CapabilitySetAssigned` / `CapabilitySetRevoked` — назначение/отзыв председателем набора
|
||||
возможностей пайщику (`set_key`+`expires_at`; actor=председатель, subject=пайщик).
|
||||
|
||||
OIDC-операции (Story 8.3, источник — native-события authentik через webhook):
|
||||
- `OidcLoginSuccess` — успешный вход (authentik `login`).
|
||||
- `OidcLogout` — выход (authentik `logout`).
|
||||
- `OidcTokenIssued` — выдача токена при авторизации приложения (authentik `authorize_application`).
|
||||
- `Authentik<Action>` — зеркало прочих native-событий authentik (`AuthentikLoginFailed`,
|
||||
`AuthentikSuspiciousRequest`, …; result=failure для security-действий). Любое будущее
|
||||
подписанное событие потечёт автоматически.
|
||||
- (отложено) `OidcTokenRevoked`/refresh — в authentik 2026.2 нет надёжного native-action.
|
||||
|
||||
Key rotation (`KeyRotated` с цепочкой `RecoveryInitiated`/`RecoveryConfirmed`) — Story 8.4.
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
import { AuthV2InfrastructureModule } from '~/infrastructure/auth-v2/auth-v2-infrastructure.module';
|
||||
import { TokenApplicationModule } from '~/application/token/token-application.module';
|
||||
import { TWO_FACTOR_VERIFIER } from '~/domain/auth-v2/ports/two-factor.port';
|
||||
import { RECOVERY_FINALIZATION_PORT } from '~/domain/auth-v2/ports/recovery-finalization.port';
|
||||
import { AuditService } from './audit/audit.service';
|
||||
import { AuditActionInterceptor } from './audit/audit-action.interceptor';
|
||||
import { AuthentikEventsController } from './authentik-events.controller';
|
||||
import { SessionBindingService } from './session-binding/session-binding.service';
|
||||
import { SessionBindingController } from './session-binding/session-binding.controller';
|
||||
import { VaultService } from './vault/vault.service';
|
||||
import { VaultController } from './vault/vault.controller';
|
||||
import { VerifyTimestampService } from './verify-timestamp/verify-timestamp.service';
|
||||
import { VerifyTimestampController } from './verify-timestamp/verify-timestamp.controller';
|
||||
import { DeviceTrackingService } from './device-tracking/device-tracking.service';
|
||||
import { NewDeviceNotificationService } from './device-tracking/new-device-notification.service';
|
||||
import { SecurityEventNotificationService } from './security-events/security-event-notification.service';
|
||||
import { CertificateService } from './certificate/certificate.service';
|
||||
import { CertSettingsService } from './certificate/cert-settings.service';
|
||||
import { CertificateResolver } from './certificate/certificate.resolver';
|
||||
import { CoopIdClaimsPolicyController } from './certificate/coopid-claims-policy.controller';
|
||||
import { CoopIdSchemaPolicyController } from './certificate/coopid-schema-policy.controller';
|
||||
import { AuthorizationModule } from './authorization/authorization.module';
|
||||
import { AuthMetricsModule } from './metrics/auth-metrics.module';
|
||||
import { VerificationTypesService } from './verification/verification-types.service';
|
||||
import { VerificationRulesService } from './verification/verification-rules.service';
|
||||
import { VerificationRuleGuard } from './verification/verification-rule.guard';
|
||||
import { LogoutService } from './logout/logout.service';
|
||||
import { LogoutController } from './logout/logout.controller';
|
||||
import { AuthRateLimitGuard } from './rate-limit/auth-rate-limit.guard';
|
||||
import { RecoveryService } from './recovery/recovery.service';
|
||||
import { RecoveryConfirmService } from './recovery/recovery-confirm.service';
|
||||
import { OfflineRecoveryService } from './recovery/offline-recovery.service';
|
||||
import { RecoveryStrategyService } from './recovery/recovery-strategy.service';
|
||||
import { RecoveryFinalizationService } from './recovery/recovery-finalization.service';
|
||||
import { RecoveryController } from './recovery/recovery.controller';
|
||||
import { RefreshService } from './refresh/refresh.service';
|
||||
import { RefreshController } from './refresh/refresh.controller';
|
||||
import { MigrationService } from './migration/migration.service';
|
||||
import { MigrationController } from './migration/migration.controller';
|
||||
import { TwoFactorService } from './two-factor/two-factor.service';
|
||||
import { SessionsService } from './sessions/sessions.service';
|
||||
import { SecurityIncidentService } from './security/security-incident.service';
|
||||
import { SecurityIncidentController } from './security/security-incident.controller';
|
||||
import { CriticalActionsService } from './critical-actions/critical-actions.service';
|
||||
import { ForceRecoveryService } from './force-recovery/force-recovery.service';
|
||||
import { ForceRecoveryController } from './force-recovery/force-recovery.controller';
|
||||
import { KeyRevocationService } from './key-revocation/key-revocation.service';
|
||||
import { CapabilitySetService } from './authorization/capability-set.service';
|
||||
import { AuthorizationResolver } from './authorization/authorization.resolver';
|
||||
import { AccountSecurityResolver } from './account-security/account-security.resolver';
|
||||
import { CriticalActionsResolver } from './critical-actions/critical-actions.resolver';
|
||||
|
||||
/**
|
||||
* auth-v2 (CoopID): новый контур аутентификации. Живёт рядом с legacy `auth/`
|
||||
* до Phase 3 миграции (Эпик 7). Здесь: аудит, приёмник событий authentik,
|
||||
* session_binding_token-мост, vault-хранилище и второй этап (timestamp-verify);
|
||||
* certificate/id_token — Story 1.8. BLOCKCHAIN_PORT/USER_DOMAIN_SERVICE — @Global.
|
||||
*/
|
||||
@Module({
|
||||
imports: [RedisModule, AuthV2InfrastructureModule, TokenApplicationModule, AuthorizationModule, AuthMetricsModule],
|
||||
// SecurityIncidentController/ForceRecoveryController остаются REST только ради magic-link
|
||||
// `:token`-эндпоинтов (клик из письма без SDK-контекста); их JWT-методы переведены в
|
||||
// GraphQL/SDK (AccountSecurityResolver/CriticalActionsResolver, Фаза 2 миграции).
|
||||
controllers: [AuthentikEventsController, SessionBindingController, VaultController, VerifyTimestampController, CoopIdClaimsPolicyController, CoopIdSchemaPolicyController, LogoutController, RefreshController, RecoveryController, MigrationController, SecurityIncidentController, ForceRecoveryController],
|
||||
providers: [
|
||||
AuditService, AuditActionInterceptor, SessionBindingService, VaultService, VerifyTimestampService, CertificateService, CertSettingsService, VerificationTypesService, VerificationRulesService, VerificationRuleGuard, LogoutService, RefreshService, MigrationService, AuthRateLimitGuard, RecoveryService, RecoveryConfirmService, OfflineRecoveryService, RecoveryStrategyService, RecoveryFinalizationService, TwoFactorService, DeviceTrackingService, NewDeviceNotificationService, SecurityEventNotificationService, SessionsService, SecurityIncidentService, CriticalActionsService, ForceRecoveryService, KeyRevocationService, CapabilitySetService, AuthorizationResolver, CertificateResolver, AccountSecurityResolver, CriticalActionsResolver,
|
||||
// Узкий verifier-порт для потребителей (recovery Story 3.2, 2FA-вход) → тот же сервис.
|
||||
{ provide: TWO_FACTOR_VERIFIER, useExisting: TwoFactorService },
|
||||
// Финализация recovery (Story 3.3): ротация ключа через registrator::changekey + vault + отзыв сессий + аудит.
|
||||
{ provide: RECOVERY_FINALIZATION_PORT, useExisting: RecoveryFinalizationService },
|
||||
],
|
||||
exports: [AuditService, SessionBindingService, VaultService, VerifyTimestampService, CertificateService, VerificationTypesService, VerificationRulesService, VerificationRuleGuard, LogoutService, TwoFactorService, TWO_FACTOR_VERIFIER],
|
||||
})
|
||||
export class AuthV2Module {}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { assertContextHasNoSecrets } from './audit/audit.service';
|
||||
import { mapAuthentikEvent } from './authentik-events.controller';
|
||||
|
||||
describe('mapAuthentikEvent (Story 1.5 + 8.3)', () => {
|
||||
describe('policy_execution → слабый пароль (Story 1.5)', () => {
|
||||
it('passing=false ⇒ WeakPasswordRejected', () => {
|
||||
const r = mapAuthentikEvent({
|
||||
event_action: 'policy_execution',
|
||||
passing: false,
|
||||
event_user: 'ant',
|
||||
messages: ['too short'],
|
||||
created: '2026-06-13T00:00:00',
|
||||
});
|
||||
expect(r).toEqual({
|
||||
event: 'WeakPasswordRejected',
|
||||
subjectId: 'ant',
|
||||
actor: 'ant',
|
||||
result: 'failure',
|
||||
context: { messages: ['too short'], authentik_created: '2026-06-13T00:00:00' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passing=true ⇒ null (не наш кейс)', () => {
|
||||
expect(mapAuthentikEvent({ event_action: 'policy_execution', passing: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC-операции → семантические Oidc* (Story 8.3)', () => {
|
||||
it('login ⇒ OidcLoginSuccess (success) + ip + app в контексте', () => {
|
||||
const r = mapAuthentikEvent({
|
||||
event_action: 'login',
|
||||
event_user: 'ant',
|
||||
client_ip: '1.2.3.4',
|
||||
app: 'CoopID',
|
||||
created: '2026-06-13T10:00:00',
|
||||
});
|
||||
expect(r).toEqual({
|
||||
event: 'OidcLoginSuccess',
|
||||
subjectId: 'ant',
|
||||
actor: 'ant',
|
||||
result: 'success',
|
||||
ip: '1.2.3.4',
|
||||
context: { authentik_action: 'login', app: 'CoopID', authentik_created: '2026-06-13T10:00:00' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logout ⇒ OidcLogout', () => {
|
||||
expect(mapAuthentikEvent({ event_action: 'logout', event_user: 'ant' })?.event).toBe('OidcLogout');
|
||||
});
|
||||
|
||||
it('authorize_application ⇒ OidcTokenIssued с app', () => {
|
||||
const r = mapAuthentikEvent({ event_action: 'authorize_application', event_user: 'ant', app: 'Gitea' });
|
||||
expect(r?.event).toBe('OidcTokenIssued');
|
||||
expect(r?.result).toBe('success');
|
||||
expect(r?.context?.app).toBe('Gitea');
|
||||
});
|
||||
});
|
||||
|
||||
describe('native-события authentik → Authentik* (Story 8.3)', () => {
|
||||
it('login_failed ⇒ AuthentikLoginFailed (failure)', () => {
|
||||
const r = mapAuthentikEvent({ event_action: 'login_failed', event_user: 'ant', client_ip: '9.9.9.9' });
|
||||
expect(r?.event).toBe('AuthentikLoginFailed');
|
||||
expect(r?.result).toBe('failure');
|
||||
expect(r?.ip).toBe('9.9.9.9');
|
||||
});
|
||||
|
||||
it('suspicious_request ⇒ AuthentikSuspiciousRequest (failure)', () => {
|
||||
const r = mapAuthentikEvent({ event_action: 'suspicious_request', event_user: 'ant' });
|
||||
expect(r?.event).toBe('AuthentikSuspiciousRequest');
|
||||
expect(r?.result).toBe('failure');
|
||||
});
|
||||
|
||||
it('неизвестный action ⇒ Authentik<PascalCase> (success по умолчанию)', () => {
|
||||
const r = mapAuthentikEvent({ event_action: 'user_write', event_user: 'ant' });
|
||||
expect(r?.event).toBe('AuthentikUserWrite');
|
||||
expect(r?.result).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
it('нет event_action ⇒ null', () => {
|
||||
expect(mapAuthentikEvent({})).toBeNull();
|
||||
expect(mapAuthentikEvent({ event_user: 'ant' })).toBeNull();
|
||||
});
|
||||
|
||||
it('контекст любого замапленного события проходит secret-blacklist аудита', () => {
|
||||
for (const action of ['login', 'logout', 'authorize_application', 'login_failed', 'suspicious_request', 'user_write']) {
|
||||
const r = mapAuthentikEvent({ event_action: action, event_user: 'ant', app: 'X' });
|
||||
expect(() => assertContextHasNoSecrets(r?.context ?? {})).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Logger,
|
||||
Post,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import config from '~/config/config';
|
||||
import { AuditService, AuditRecord, AuditResult } from './audit/audit.service';
|
||||
|
||||
/** Payload webhook-mapping'ов authentik (coopid-webhook-body / coopid-oidc-webhook-body). */
|
||||
export interface AuthentikWebhookBody {
|
||||
severity?: string;
|
||||
event_action?: string;
|
||||
event_user?: string | null;
|
||||
// policy_execution (Story 1.5)
|
||||
passing?: boolean | null;
|
||||
messages?: string[] | null;
|
||||
// OIDC / native-события (Story 8.3)
|
||||
client_ip?: string | null;
|
||||
app?: string | null;
|
||||
created?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Семантические имена для ключевых OIDC-операций. Остальные подписанные
|
||||
* native-события authentik зеркалятся как `Authentik<Action>` (Story 8.3 — AC:
|
||||
* Oidc* для операций + Authentik* для native-событий). Все OIDC-операции
|
||||
* выполняет authentik, контроллер узнаёт о них только через этот webhook.
|
||||
*/
|
||||
const OIDC_EVENT_MAP: Record<string, string> = {
|
||||
login: 'OidcLoginSuccess',
|
||||
logout: 'OidcLogout',
|
||||
authorize_application: 'OidcTokenIssued',
|
||||
};
|
||||
|
||||
/** Native-действия authentik, для которых result = failure (а не success). */
|
||||
const FAILURE_ACTIONS = new Set(['login_failed', 'suspicious_request', 'policy_exception']);
|
||||
|
||||
function pascalCase(action: string): string {
|
||||
return action
|
||||
.split(/[_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0].toUpperCase() + w.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг webhook-события authentik в audit-запись. Чистая функция (юнит-тесты).
|
||||
* - policy_execution + passing=false ⇒ слабый пароль (Story 1.5; execution_logging
|
||||
* включён ТОЛЬКО у парольной политики CoopID).
|
||||
* - login/logout/authorize_application ⇒ семантические Oidc* (Story 8.3).
|
||||
* - прочие подписанные native-события ⇒ Authentik<Action> (Story 8.3).
|
||||
*/
|
||||
export function mapAuthentikEvent(body: AuthentikWebhookBody): AuditRecord | null {
|
||||
const action = body?.event_action;
|
||||
if (!action) return null;
|
||||
|
||||
if (action === 'policy_execution') {
|
||||
if (body.passing !== false) return null;
|
||||
return {
|
||||
event: 'WeakPasswordRejected',
|
||||
subjectId: body.event_user ?? null,
|
||||
actor: body.event_user ?? null,
|
||||
result: 'failure',
|
||||
context: {
|
||||
messages: body.messages ?? [],
|
||||
authentik_created: body.created ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const event = OIDC_EVENT_MAP[action] ?? `Authentik${pascalCase(action)}`;
|
||||
const result: AuditResult = FAILURE_ACTIONS.has(action) ? 'failure' : 'success';
|
||||
return {
|
||||
event,
|
||||
subjectId: body.event_user ?? null,
|
||||
actor: body.event_user ?? null,
|
||||
result,
|
||||
ip: body.client_ip ?? null,
|
||||
context: {
|
||||
authentik_action: action,
|
||||
app: body.app ?? null,
|
||||
authentik_created: body.created ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function tokenMatches(provided: string | undefined, expected: string): boolean {
|
||||
if (!provided || !expected) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal-приёмник событий authentik (Story 1.5). Не маршрутизируется caddy —
|
||||
* достижим только из docker-сети; дополнительно защищён shared-токеном.
|
||||
*/
|
||||
@Controller('coop/internal')
|
||||
export class AuthentikEventsController {
|
||||
private readonly logger = new Logger(AuthentikEventsController.name);
|
||||
|
||||
constructor(private readonly audit: AuditService) {}
|
||||
|
||||
@Post('authentik-events')
|
||||
@HttpCode(204)
|
||||
async handle(
|
||||
@Headers('x-authentik-webhook-token') token: string | undefined,
|
||||
@Body() body: AuthentikWebhookBody,
|
||||
): Promise<void> {
|
||||
if (!tokenMatches(token, config.authV2.webhookToken)) throw new UnauthorizedException();
|
||||
|
||||
const record = mapAuthentikEvent(body);
|
||||
if (!record) return; // не наш кейс — молча принимаем, authentik не должен ретраить
|
||||
|
||||
try {
|
||||
await this.audit.record(record);
|
||||
} catch (e) {
|
||||
this.logger.error(`Не удалось записать audit ${record.event}: ${e instanceof Error ? e.message : e}`);
|
||||
throw new ServiceUnavailableException('audit storage unavailable');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# CASL-авторизация auth-v2 (Эпик 6)
|
||||
|
||||
Реальный `@casl/ability`-фундамент авторизации платформы. Заменяет роле-ориентированный
|
||||
`@AuthRoles` на capability-ориентированный `can(action, Subject)` (Layered Authorization Pattern).
|
||||
|
||||
## Слои (Эпик 6)
|
||||
|
||||
| Layer | Что | Story |
|
||||
|-------|-----|-------|
|
||||
| 1 — Static Ability | `AbilityFactory.createForParticipant` собирает Ability из core-ролей по статической матрице | 6.1 (этот код) |
|
||||
| 2 — `access_rules` matrix | декларативные allow/deny из БД мерджатся в Ability; инвалидация активных сессий через Redis pub/sub | 6.2 |
|
||||
| 3 — PolicyHandler registry | политики с DB-lookup (`@PolicyHandler('same-coop-voting')`) | 6.3 |
|
||||
| 4 — `AuthorizationGuard` | единый guard на все 4 слоя; HTTP REST + GraphQL; читает Ability из session-стора | 6.4 |
|
||||
|
||||
## Роли (канон платформы)
|
||||
|
||||
`CoreRole = 'User' | 'Member' | 'Chairman'` (`core-roles.ts`), маппинг из `user.role` JWT.
|
||||
Иерархия аддитивна: Chairman ⊃ Member ⊃ User. Это существующий канон контроллера
|
||||
(CLAUDE.md «3 базовые роли») — НЕ вводить синонимы `participant`/`council_member`.
|
||||
|
||||
## Матрица Layer 1 (статическая)
|
||||
|
||||
| Субъект | User (пайщик) | Member (совет) | Chairman (председатель) |
|
||||
|---------|---------------|----------------|--------------------------|
|
||||
| Certificate | read (свой) | — | — |
|
||||
| Session | read/update (свои) | — | — |
|
||||
| RecoveryStrategy | manage (своя, вкл. 2FA) | — | — |
|
||||
| Participant | — | read | update (роли, 6.6) |
|
||||
| VerificationRule | — | read | manage |
|
||||
| CoopSettings | — | — | manage |
|
||||
| CriticalAction | — | read + **confirm** (6.8) | + create (инициатор) |
|
||||
| Capability | — | — | create (6.7) |
|
||||
| AuditEvent | — | read | (наследует) |
|
||||
|
||||
Критические действия (исключение, смена ролей совета, force-recovery) финализируются только
|
||||
двумя подписями (Story 6.8) — Chairman инициирует (`create`), Member подтверждает (`confirm`).
|
||||
|
||||
## Контракт миграции marketplace-matrix → эта CASL
|
||||
|
||||
На ветке `marketplace2` есть CASL-совместимый зачаток
|
||||
(`extensions/marketplace/application/access/marketplace-access-matrix.ts`,
|
||||
`Record<role, Record<resource, action[]>>` + `canAccess`) с пометкой «Phase 2 → CASL `defineAbility`».
|
||||
Платформенный дом этой миграции — **здесь**. Правила трансляции:
|
||||
|
||||
- `Resource:action` (например `Order:create`) → `can('create', 'Order')`.
|
||||
- Квалификаторы охвата `:own` / `:own-KU` / `:to-self` → CASL `conditions`
|
||||
(`can('read', 'Order', { owner: username })` вместо ручной проверки в resolver'е).
|
||||
- `:all` (надмножество) → правило без condition; иерархия охвата (`:all` удовлетворяет `:own`)
|
||||
в CASL выражается естественно: правило без условия матчит любой экземпляр.
|
||||
- `manage` — тот же CASL-wildcard.
|
||||
- Развёртка грантов для фронта (`marketplace-grants.ts` `expandGrantsForRoles`) →
|
||||
`@casl/ability/extra` `permittedFieldsOf` / прямой обход `ability.rules` для `meta.requires`.
|
||||
|
||||
Marketplace-домен (ресурсы Order/Offer/KU…) остаётся в своём расширении и переключает источник
|
||||
policy на эту платформенную CASL без правки call-site guard'ов (interface сохраняется). Перенос —
|
||||
задача ветки marketplace, не Эпика 6 (так обе ветки мерджатся в dev без конфликтов файлов).
|
||||
@@ -0,0 +1,210 @@
|
||||
import { subject } from '@casl/ability';
|
||||
import {
|
||||
AccessRuleEffect,
|
||||
AccessRulePrincipalKind,
|
||||
type AccessRuleRecord,
|
||||
type IAccessRulesRepository,
|
||||
} from '~/domain/auth-v2/ports/access-rules.port';
|
||||
import type { ICapabilitySetsRepository } from '~/domain/auth-v2/ports/capability-sets.port';
|
||||
import { AbilityFactory } from './ability.factory';
|
||||
import { deserializeAbility, serializeAbility } from './ability.serialization';
|
||||
|
||||
/** Stub репозитория Layer 2 — для тестов Layer 1 (правил нет). */
|
||||
const emptyRepo: IAccessRulesRepository = {
|
||||
findForPrincipal: async () => [],
|
||||
findForCapabilitySets: async () => [],
|
||||
insert: async () => undefined,
|
||||
deleteExpired: async () => 0,
|
||||
};
|
||||
|
||||
/** Stub репозитория наборов — пайщику ничего не назначено. */
|
||||
const emptySets: ICapabilitySetsRepository = {
|
||||
listSets: async () => [],
|
||||
findSet: async () => null,
|
||||
listActiveSetKeys: async () => [],
|
||||
listAssignments: async () => [],
|
||||
assign: async () => undefined,
|
||||
revoke: async () => false,
|
||||
};
|
||||
|
||||
describe('AbilityFactory — Layer 1 static ability (Story 6.1)', () => {
|
||||
const factory = new AbilityFactory(emptyRepo, emptySets);
|
||||
const ownCert = subject('Certificate', { owner: 'ant' });
|
||||
const foreignCert = subject('Certificate', { owner: 'bob' });
|
||||
|
||||
describe('participant (role=user)', () => {
|
||||
const ability = factory.createForParticipant({ username: 'ant', role: 'user' });
|
||||
|
||||
it('управляет своими сущностями, но не чужими', () => {
|
||||
expect(ability.can('read', ownCert)).toBe(true);
|
||||
expect(ability.can('read', foreignCert)).toBe(false);
|
||||
expect(ability.can('update', subject('Session', { owner: 'ant' }))).toBe(true);
|
||||
expect(ability.can('manage', subject('RecoveryStrategy', { owner: 'ant' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('не имеет админ-прав и не участвует в critical actions', () => {
|
||||
expect(ability.can('read', 'Participant')).toBe(false);
|
||||
expect(ability.can('manage', 'VerificationRule')).toBe(false);
|
||||
expect(ability.can('confirm', 'CriticalAction')).toBe(false);
|
||||
expect(ability.can('create', 'CriticalAction')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('council member (role=member)', () => {
|
||||
const ability = factory.createForParticipant({ username: 'eve', role: 'member' });
|
||||
|
||||
it('наследует права пайщика над своими сущностями', () => {
|
||||
expect(ability.can('read', subject('Certificate', { owner: 'eve' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('read-only надзор + подтверждает critical action, но не инициирует/не модерирует', () => {
|
||||
expect(ability.can('read', 'Participant')).toBe(true);
|
||||
expect(ability.can('read', 'VerificationRule')).toBe(true);
|
||||
expect(ability.can('read', 'AuditEvent')).toBe(true);
|
||||
expect(ability.can('confirm', 'CriticalAction')).toBe(true);
|
||||
expect(ability.can('create', 'CriticalAction')).toBe(false);
|
||||
expect(ability.can('manage', 'VerificationRule')).toBe(false);
|
||||
expect(ability.can('update', 'Participant')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chairman (role=chairman)', () => {
|
||||
const ability = factory.createForParticipant({ username: 'chief', role: 'chairman' });
|
||||
|
||||
it('модерирует + инициирует critical action + наследует Member/User', () => {
|
||||
expect(ability.can('manage', 'VerificationRule')).toBe(true);
|
||||
expect(ability.can('manage', 'CoopSettings')).toBe(true);
|
||||
expect(ability.can('update', 'Participant')).toBe(true);
|
||||
expect(ability.can('create', 'Capability')).toBe(true);
|
||||
expect(ability.can('create', 'CriticalAction')).toBe(true);
|
||||
expect(ability.can('confirm', 'CriticalAction')).toBe(true);
|
||||
expect(ability.can('read', subject('Certificate', { owner: 'chief' }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('платформенный admin / неизвестная роль → пустая Ability', () => {
|
||||
it.each(['admin', 'superuser', undefined, null])('role=%s ничего не может', (role) => {
|
||||
const ability = factory.createForParticipant({ username: 'x', role: role as string });
|
||||
expect(ability.can('read', 'Participant')).toBe(false);
|
||||
expect(ability.can('read', subject('Certificate', { owner: 'x' }))).toBe(false);
|
||||
expect(ability.can('manage', 'VerificationRule')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('сериализация в Redis-session (pack → unpack) сохраняет решения', () => {
|
||||
it('round-trip сохраняет правила и условия владения', () => {
|
||||
const ability = factory.createForParticipant({ username: 'chief', role: 'chairman' });
|
||||
const restored = deserializeAbility(serializeAbility(ability));
|
||||
|
||||
expect(restored.can('manage', 'VerificationRule')).toBe(true);
|
||||
expect(restored.can('create', 'CriticalAction')).toBe(true);
|
||||
expect(restored.can('read', subject('Certificate', { owner: 'chief' }))).toBe(true);
|
||||
expect(restored.can('read', subject('Certificate', { owner: 'other' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbilityFactory — Layer 2 access_rules merge (Story 6.2)', () => {
|
||||
const factory = new AbilityFactory(emptyRepo, emptySets);
|
||||
|
||||
function rule(partial: Partial<AccessRuleRecord> & Pick<AccessRuleRecord, 'action' | 'resourceType'>): AccessRuleRecord {
|
||||
return {
|
||||
subjectType: AccessRulePrincipalKind.Role,
|
||||
subjectId: 'User',
|
||||
effect: AccessRuleEffect.Allow,
|
||||
conditions: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
it('allow-правило добавляет возможность сверх статической матрицы', () => {
|
||||
const ability = factory.createForParticipant(
|
||||
{ username: 'ant', role: 'user' },
|
||||
[rule({ action: 'vote', resourceType: 'CriticalAction' })],
|
||||
);
|
||||
expect(ability.can('vote', 'CriticalAction')).toBe(true);
|
||||
});
|
||||
|
||||
it('deny-правило перекрывает статический allow (precedence)', () => {
|
||||
const ability = factory.createForParticipant(
|
||||
{ username: 'chief', role: 'chairman' },
|
||||
[rule({ effect: AccessRuleEffect.Deny, action: 'manage', resourceType: 'VerificationRule' })],
|
||||
);
|
||||
expect(ability.can('manage', 'VerificationRule')).toBe(false);
|
||||
expect(ability.can('read', 'VerificationRule')).toBe(false);
|
||||
});
|
||||
|
||||
it('conditions сужают доступ до совпадающих экземпляров', () => {
|
||||
const ability = factory.createForParticipant(
|
||||
{ username: 'ant', role: 'user' },
|
||||
[rule({ action: 'vote', resourceType: 'CriticalAction', conditions: { branch: 'b1' } })],
|
||||
);
|
||||
expect(ability.can('vote', subject('CriticalAction', { branch: 'b1' }))).toBe(true);
|
||||
expect(ability.can('vote', subject('CriticalAction', { branch: 'b2' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('createForParticipantWithRules читает репозиторий по core-ролям+username и мерджит', async () => {
|
||||
const repo: IAccessRulesRepository = {
|
||||
findForPrincipal: jest.fn(async () => [rule({ action: 'vote', resourceType: 'CriticalAction' })]),
|
||||
findForCapabilitySets: async () => [],
|
||||
insert: async () => undefined,
|
||||
deleteExpired: async () => 0,
|
||||
};
|
||||
const f = new AbilityFactory(repo, emptySets);
|
||||
const ability = await f.createForParticipantWithRules({ username: 'eve', role: 'member' });
|
||||
|
||||
expect(repo.findForPrincipal).toHaveBeenCalledWith(['User', 'Member'], 'eve');
|
||||
expect(ability.can('vote', 'CriticalAction')).toBe(true);
|
||||
expect(ability.can('read', 'Participant')).toBe(true); // статика Member сохранена
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbilityFactory — назначаемые наборы возможностей merge (Story 6.11)', () => {
|
||||
function setRule(setKey: string, action: string, resourceType: string): AccessRuleRecord {
|
||||
return {
|
||||
subjectType: AccessRulePrincipalKind.CapabilitySet,
|
||||
subjectId: setKey,
|
||||
effect: AccessRuleEffect.Allow,
|
||||
action,
|
||||
resourceType,
|
||||
conditions: null,
|
||||
};
|
||||
}
|
||||
|
||||
it('правила назначенного набора добавляются к Ability пайщика поверх core-роли', async () => {
|
||||
const accessRules: IAccessRulesRepository = {
|
||||
findForPrincipal: async () => [],
|
||||
findForCapabilitySets: jest.fn(async (keys: string[]) =>
|
||||
keys.includes('cashier') ? [setRule('cashier', 'read', 'PaymentRegistry'), setRule('cashier', 'confirm', 'PaymentRegistry')] : [],
|
||||
),
|
||||
insert: async () => undefined,
|
||||
deleteExpired: async () => 0,
|
||||
};
|
||||
const sets: ICapabilitySetsRepository = {
|
||||
...emptySets,
|
||||
listActiveSetKeys: jest.fn(async () => ['cashier']),
|
||||
};
|
||||
const f = new AbilityFactory(accessRules, sets);
|
||||
const ability = await f.createForParticipantWithRules({ username: 'kate', role: 'user' });
|
||||
|
||||
expect(sets.listActiveSetKeys).toHaveBeenCalledWith('kate');
|
||||
expect(accessRules.findForCapabilitySets).toHaveBeenCalledWith(['cashier']);
|
||||
expect(ability.can('read', 'PaymentRegistry')).toBe(true);
|
||||
expect(ability.can('confirm', 'PaymentRegistry')).toBe(true);
|
||||
expect(ability.can('read', subject('Certificate', { owner: 'kate' }))).toBe(true); // core User сохранён
|
||||
});
|
||||
|
||||
it('без назначенных наборов набор-правила не запрашиваются (пустой setKeys → пусто)', async () => {
|
||||
const accessRules: IAccessRulesRepository = {
|
||||
findForPrincipal: async () => [],
|
||||
findForCapabilitySets: jest.fn(async () => []),
|
||||
insert: async () => undefined,
|
||||
deleteExpired: async () => 0,
|
||||
};
|
||||
const f = new AbilityFactory(accessRules, emptySets);
|
||||
const ability = await f.createForParticipantWithRules({ username: 'bob', role: 'user' });
|
||||
|
||||
expect(accessRules.findForCapabilitySets).toHaveBeenCalledWith([]);
|
||||
expect(ability.can('read', 'PaymentRegistry')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
|
||||
import {
|
||||
AccessRuleEffect,
|
||||
ACCESS_RULES_REPOSITORY,
|
||||
type AccessRuleRecord,
|
||||
type IAccessRulesRepository,
|
||||
} from '~/domain/auth-v2/ports/access-rules.port';
|
||||
import {
|
||||
CAPABILITY_SETS_REPOSITORY,
|
||||
type ICapabilitySetsRepository,
|
||||
} from '~/domain/auth-v2/ports/capability-sets.port';
|
||||
import type { AppAbility, CoopAction, CoopSubject } from './ability.types';
|
||||
import { mapUserRoleToCoreRoles } from './core-roles';
|
||||
|
||||
/** Пайщик, для которого собирается Ability (минимум из JWT-сессии). */
|
||||
export interface IAbilitySubjectUser {
|
||||
username: string;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CASL-авторизация (Эпик 6). Layer 1 (Story 6.1) — статическая матрица «роль→
|
||||
* возможности». Layer 2 (Story 6.2) — декларативные `access_rules` из coop_domain_db,
|
||||
* мерджатся поверх статики. Матрица аддитивна: Chairman наследует Member, Member — User.
|
||||
*
|
||||
* Ownership self-субъектов (свой Certificate/Session/RecoveryStrategy) вшит в условие
|
||||
* `{ owner: username }` прямо в Ability — реальный CASL (в отличие от marketplace
|
||||
* `canAccess`, где ownership проверял resolver). DB-зависимые политики — Layer 3 (Story 6.3).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AbilityFactory {
|
||||
constructor(
|
||||
@Inject(ACCESS_RULES_REPOSITORY)
|
||||
private readonly accessRules: IAccessRulesRepository,
|
||||
@Inject(CAPABILITY_SETS_REPOSITORY)
|
||||
private readonly capabilitySets: ICapabilitySetsRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Синхронная сборка: Layer 1 static + merge переданных `access_rules` (Layer 2).
|
||||
* Чистая (без IO) — `accessRules` инжектится вызывающим, что делает merge тестируемым.
|
||||
*/
|
||||
createForParticipant(user: IAbilitySubjectUser, accessRules: AccessRuleRecord[] = []): AppAbility {
|
||||
const coreRoles = mapUserRoleToCoreRoles(user.role);
|
||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
|
||||
const owner = { owner: user.username };
|
||||
|
||||
// --- Layer 1: статическая матрица ---
|
||||
// User — любой пайщик: полный контроль над собственными сущностями.
|
||||
if (coreRoles.includes('User')) {
|
||||
can('read', 'Certificate', owner);
|
||||
can(['read', 'update'], 'Session', owner);
|
||||
can('manage', 'RecoveryStrategy', owner); // вкл. настройки 2FA
|
||||
}
|
||||
|
||||
// Member — член совета: read-only надзор + роль второго подписанта critical-action.
|
||||
if (coreRoles.includes('Member')) {
|
||||
can('read', 'Participant');
|
||||
can('read', 'VerificationRule');
|
||||
can('read', 'CriticalAction');
|
||||
can('read', 'AuditEvent');
|
||||
can('confirm', 'CriticalAction'); // второй подписант (Story 6.8)
|
||||
}
|
||||
|
||||
// Chairman — председатель: write-модерация + инициация critical-action.
|
||||
if (coreRoles.includes('Chairman')) {
|
||||
can('manage', 'VerificationRule');
|
||||
can('manage', 'CoopSettings');
|
||||
can('update', 'Participant'); // назначение ролей (Story 6.6)
|
||||
can('create', 'Capability'); // выдача точечных capabilities (Story 6.7)
|
||||
can('manage', 'CapabilitySet'); // назначение наборов возможностей (Story 6.11)
|
||||
can('create', 'CriticalAction'); // инициатор; финал — только 2 подписи (Story 6.8)
|
||||
}
|
||||
|
||||
// --- Layer 2: access_rules. allow — первыми, deny — последними, чтобы deny
|
||||
// перекрывал любой предыдущий allow (CASL берёт последнее матчащее правило). ---
|
||||
const apply = (rule: AccessRuleRecord, kind: 'allow' | 'deny'): void => {
|
||||
const action = rule.action as CoopAction;
|
||||
const subject = rule.resourceType as CoopSubject;
|
||||
const fn = kind === 'allow' ? can : cannot;
|
||||
if (rule.conditions) {
|
||||
fn(action, subject, rule.conditions);
|
||||
}
|
||||
else {
|
||||
fn(action, subject);
|
||||
}
|
||||
};
|
||||
for (const rule of accessRules) {
|
||||
if (rule.effect === AccessRuleEffect.Allow) {
|
||||
apply(rule, 'allow');
|
||||
}
|
||||
}
|
||||
for (const rule of accessRules) {
|
||||
if (rule.effect === AccessRuleEffect.Deny) {
|
||||
apply(rule, 'deny');
|
||||
}
|
||||
}
|
||||
|
||||
return build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная сборка Ability пайщика: читает `access_rules` (Layer 2) из БД и мерджит
|
||||
* поверх Layer 1. Это «AbilityFactory читает access_rules» из AC. Свежие правила
|
||||
* видит при каждой сборке (= новый логин/пересборка); активные сессии инвалидируются
|
||||
* через Redis pub/sub (publisher Story 6.2, подписчик Story 6.4).
|
||||
*/
|
||||
async createForParticipantWithRules(user: IAbilitySubjectUser): Promise<AppAbility> {
|
||||
const coreRoles = mapUserRoleToCoreRoles(user.role);
|
||||
// Источники правил L2 объединяются в один список (allow-first/deny-last делает
|
||||
// createForParticipant): (1) core-роли + персональные гранты пайщика; (2) правила
|
||||
// назначенных пайщику наборов возможностей (Story 6.11). Движок — один (CASL).
|
||||
const setKeys = await this.capabilitySets.listActiveSetKeys(user.username);
|
||||
const [principalRules, setRules] = await Promise.all([
|
||||
this.accessRules.findForPrincipal(coreRoles, user.username),
|
||||
this.accessRules.findForCapabilitySets(setKeys),
|
||||
]);
|
||||
return this.createForParticipant(user, [...principalRules, ...setRules]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createMongoAbility } from '@casl/ability';
|
||||
import type { AppAbility } from './ability.types';
|
||||
|
||||
/**
|
||||
* Сериализация Ability для Redis-session (Story 6.1). `ability.rules` —
|
||||
* JSON-сериализуемый массив правил; `serializeAbility` кладёт его в строку для
|
||||
* session-стора, `deserializeAbility` восстанавливает Ability. Связывание со
|
||||
* session-стором и чтение Ability на каждом запросе — Story 6.4 (`AuthorizationGuard`).
|
||||
*
|
||||
* Redis (а не JWT-claim): права активной сессии должны отзываться при правке
|
||||
* `access_rules` через Redis pub/sub (Story 6.2), чего JWT-claim не позволяет.
|
||||
*
|
||||
* Позиционная упаковка (`@casl/ability/extra` packRules) для компактности — позже:
|
||||
* subpath требует `moduleResolution: bundler/node16`, а raw-rules JSON корректен и сейчас.
|
||||
*/
|
||||
export function serializeAbility(ability: AppAbility): string {
|
||||
return JSON.stringify(ability.rules);
|
||||
}
|
||||
|
||||
export function deserializeAbility(packed: string): AppAbility {
|
||||
return createMongoAbility(JSON.parse(packed));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MongoAbility } from '@casl/ability';
|
||||
|
||||
/**
|
||||
* Словарь CASL-авторизации auth-v2 (Story 6.1). Реальный `@casl/ability`-фундамент,
|
||||
* в который по Phase-2-пометке мигрирует marketplace-access-matrix (ветка
|
||||
* `marketplace2`): `Resource:action` → `can(action, Subject)`, квалификаторы
|
||||
* `:own`/`:own-KU` → CASL `conditions`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Действия. База CRUD + `manage` (CASL-wildcard «всё над субъектом») + доменные
|
||||
* глаголы: `confirm` — второй подписант critical-action (Story 6.8); `vote` —
|
||||
* голос в решении (Story 6.3, same-coop policy).
|
||||
*/
|
||||
export type CoopAction = 'manage' | 'create' | 'read' | 'update' | 'delete' | 'confirm' | 'vote';
|
||||
|
||||
/** Субъекты (ресурсы) домена auth-v2. `all` — CASL-wildcard для платформенного админа. */
|
||||
export type CoopSubject =
|
||||
| 'Participant'
|
||||
| 'Session'
|
||||
| 'Certificate'
|
||||
| 'VerificationRule'
|
||||
| 'CoopSettings'
|
||||
| 'RecoveryStrategy'
|
||||
| 'CriticalAction'
|
||||
| 'Role'
|
||||
| 'Capability'
|
||||
/** Именованный набор возможностей (Story 6.11) — управляет председатель. */
|
||||
| 'CapabilitySet'
|
||||
| 'AuditEvent'
|
||||
// --- Субъекты доступа к рабочим столам/страницам (grant-строки для desktop
|
||||
// meta.requires). Имена — предложение на согласование при разводке desktop-gating;
|
||||
// на границе access_rules это свободные строки, переименовываются точечно.
|
||||
/** Стол бухгалтера (доступ набора `accountant`). */
|
||||
| 'AccountingDesk'
|
||||
/** Реестр платежей / будущий стол кассира (доступ набора `cashier`). */
|
||||
| 'PaymentRegistry'
|
||||
| 'all';
|
||||
|
||||
/** Тип Ability пайщика — собирается `AbilityFactory.createForParticipant`. */
|
||||
export type AppAbility = MongoAbility<[CoopAction, CoopSubject]>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import type { IAccessRulesRepository } from '~/domain/auth-v2/ports/access-rules.port';
|
||||
import { AccessRulesCleanupService } from './access-rules-cleanup.service';
|
||||
|
||||
function makeService(deleteExpired = jest.fn(async () => 0)) {
|
||||
const repo: IAccessRulesRepository = {
|
||||
findForPrincipal: jest.fn(async () => []),
|
||||
findForCapabilitySets: jest.fn(async () => []),
|
||||
insert: jest.fn(async () => undefined),
|
||||
deleteExpired,
|
||||
};
|
||||
return { service: new AccessRulesCleanupService(repo), deleteExpired };
|
||||
}
|
||||
|
||||
describe('AccessRulesCleanupService — уборка истёкших access_rules (Story 6.7)', () => {
|
||||
it('purgeExpired зовёт deleteExpired с текущим моментом (Date)', async () => {
|
||||
const { service, deleteExpired } = makeService(jest.fn(async () => 3));
|
||||
|
||||
await service.purgeExpired();
|
||||
|
||||
expect(deleteExpired).toHaveBeenCalledTimes(1);
|
||||
expect(deleteExpired).toHaveBeenCalledWith(expect.any(Date));
|
||||
});
|
||||
|
||||
it('нечего удалять (0) → проходит без ошибок', async () => {
|
||||
const { service, deleteExpired } = makeService(jest.fn(async () => 0));
|
||||
await expect(service.purgeExpired()).resolves.toBeUndefined();
|
||||
expect(deleteExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('сбой репозитория пробрасывается (виден в логах планировщика, не глотается)', async () => {
|
||||
const { service } = makeService(jest.fn(async () => { throw new Error('db down'); }));
|
||||
await expect(service.purgeExpired()).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
ACCESS_RULES_REPOSITORY,
|
||||
type IAccessRulesRepository,
|
||||
} from '~/domain/auth-v2/ports/access-rules.port';
|
||||
|
||||
/**
|
||||
* Фоновая уборка истёкших точечных прав `access_rules` (Story 6.7, TTL capability).
|
||||
*
|
||||
* Истёкшие правила УЖЕ инертны: read-path (`findForPrincipal`/`findForCapabilitySets`)
|
||||
* исключает их по `expires_at <= now()`, доступа они не дают. Этот cron лишь удаляет
|
||||
* мёртвые строки, чтобы таблица не росла бесконечно (гигиена + скорость выборки прав).
|
||||
* Удаление безопасно по построению — трогает только строки с непустым `expires_at` в
|
||||
* прошлом; бессрочные и ещё действующие не затрагиваются. Без отзыва сессий и без
|
||||
* аудита: ничьи фактические права не меняются (в отличие от явной revoke в 6.7-UI).
|
||||
*
|
||||
* Прецедент cron'а в auth-v2 — `CriticalActionsService.expireStale` (Story 6.8).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccessRulesCleanupService {
|
||||
private readonly logger = new Logger(AccessRulesCleanupService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ACCESS_RULES_REPOSITORY)
|
||||
private readonly accessRules: IAccessRulesRepository,
|
||||
) {}
|
||||
|
||||
/** Cron: ежедневно в полночь удаляет истёкшие capability-правила. */
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async purgeExpired(): Promise<void> {
|
||||
const removed = await this.accessRules.deleteExpired(new Date());
|
||||
if (removed) this.logger.log(`access_rules: удалено истёкших правил=${removed}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Внутренняя причина отказа авторизации (для лога/диагностики). Наружу НЕ уходит:
|
||||
* клиенту отдаётся обобщённый `authorization_denied` без указания, какой именно слой
|
||||
* отказал (AC Story 6.4 — «не раскрывает специфику для security»). Статусы — enum.
|
||||
*/
|
||||
export enum AuthorizationDenialReason {
|
||||
/** В запросе нет авторизованного пайщика (JWT-guard не проставил user). */
|
||||
NoSubjectUser = 'no_subject_user',
|
||||
/** Ability (Layer 1+2) не разрешает пару action+subject. */
|
||||
InsufficientAbility = 'insufficient_ability',
|
||||
/** Политика Layer 3 вернула false. */
|
||||
PolicyDenied = 'policy_denied',
|
||||
}
|
||||
|
||||
/** Публичный код отказа — единый, без раскрытия слоя/правила. */
|
||||
export const AUTHORIZATION_DENIED_CODE = 'authorization_denied';
|
||||
|
||||
/**
|
||||
* 403 с обобщённым кодом. Точную причину (reason) логирует вызывающий — она не
|
||||
* попадает в ответ. Формат тела совместим с auth-v2 ({ error, error_description }).
|
||||
*/
|
||||
export function authorizationDenied(): ForbiddenException {
|
||||
return new ForbiddenException({
|
||||
error: AUTHORIZATION_DENIED_CODE,
|
||||
error_description: 'Недостаточно прав для выполнения операции',
|
||||
});
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import config from '~/config/config';
|
||||
import { AuthorizationGuard } from './authorization.guard';
|
||||
import { PolicyService } from './policy.service';
|
||||
import type { CheckAbilityRequirement } from './check-ability.decorator';
|
||||
|
||||
function makeGuard(requirement: CheckAbilityRequirement | undefined): {
|
||||
guard: AuthorizationGuard;
|
||||
ensure: jest.Mock;
|
||||
} {
|
||||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(requirement) } as unknown as Reflector;
|
||||
const ensure = jest.fn().mockResolvedValue(undefined);
|
||||
const guard = new AuthorizationGuard(reflector, { ensure } as unknown as PolicyService);
|
||||
return { guard, ensure };
|
||||
}
|
||||
|
||||
/** Фейковый GraphQL ExecutionContext: getArgs() отдаёт [root, args, ctx, info]. */
|
||||
function gqlContext(args: Record<string, unknown>, reqObj: unknown): ExecutionContext {
|
||||
const gqlArgs = [{}, args, { req: reqObj }, {}];
|
||||
return {
|
||||
getType: () => 'graphql',
|
||||
getHandler: () => () => undefined,
|
||||
getClass: () => class {},
|
||||
getArgs: () => gqlArgs,
|
||||
getArgByIndex: (i: number) => gqlArgs[i],
|
||||
switchToHttp: () => ({ getRequest: () => ({}) }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function httpContext(reqObj: Record<string, unknown>): ExecutionContext {
|
||||
return {
|
||||
getType: () => 'http',
|
||||
getHandler: () => () => undefined,
|
||||
getClass: () => class {},
|
||||
switchToHttp: () => ({ getRequest: () => reqObj }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('AuthorizationGuard — единый guard 4 слоёв (Story 6.4)', () => {
|
||||
it('нет @CheckAbility → пропускает (не наш endpoint)', async () => {
|
||||
const { guard, ensure } = makeGuard(undefined);
|
||||
await expect(guard.canActivate(gqlContext({}, { user: {} }))).resolves.toBe(true);
|
||||
expect(ensure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('server-secret → служебный обход без проверки', async () => {
|
||||
const { guard, ensure } = makeGuard({ action: 'update', subject: 'Participant' });
|
||||
const ctx = gqlContext({}, { user: { username: 'x', role: 'user' }, headers: { 'server-secret': config.server_secret } });
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
expect(ensure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GraphQL: извлекает user + args.data и делегирует PolicyService.ensure', async () => {
|
||||
const { guard, ensure } = makeGuard({ action: 'update', subject: 'Participant' });
|
||||
const user = { username: 'chief', role: 'chairman' };
|
||||
await guard.canActivate(gqlContext({ data: { id: 'p1' } }, { user, headers: {} }));
|
||||
expect(ensure).toHaveBeenCalledWith({ action: 'update', subject: 'Participant' }, user, { id: 'p1' });
|
||||
});
|
||||
|
||||
it('HTTP REST: извлекает user + params/query/body и делегирует ensure', async () => {
|
||||
const { guard, ensure } = makeGuard({ action: 'read', subject: 'Certificate' });
|
||||
const user = { username: 'ant', role: 'user' };
|
||||
const ctx = httpContext({ user, headers: {}, params: { id: 'c1' }, query: {}, body: { owner: 'ant' } });
|
||||
await guard.canActivate(ctx);
|
||||
expect(ensure).toHaveBeenCalledWith({ action: 'read', subject: 'Certificate' }, user, { id: 'c1', owner: 'ant' });
|
||||
});
|
||||
|
||||
it('отказ PolicyService пробрасывается (canActivate отклоняется)', async () => {
|
||||
const { guard, ensure } = makeGuard({ action: 'manage', subject: 'CoopSettings' });
|
||||
ensure.mockRejectedValueOnce(new Error('forbidden'));
|
||||
await expect(guard.canActivate(gqlContext({}, { user: { username: 'ant', role: 'user' }, headers: {} }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import config from '~/config/config';
|
||||
import type { IAbilitySubjectUser } from './ability.factory';
|
||||
import { CHECK_ABILITY, type CheckAbilityRequirement } from './check-ability.decorator';
|
||||
import { PolicyService } from './policy.service';
|
||||
|
||||
/**
|
||||
* Единый guard всех 4 слоёв авторизации (Story 6.4). Читает `@CheckAbility` с
|
||||
* метода/класса; если требования нет — пропускает (endpoint не под CASL). Покрывает
|
||||
* и GraphQL-резолверы, и HTTP REST: извлекает user и атрибуты ресурса из обоих видов
|
||||
* контекста, затем делегирует `PolicyService.ensure` (Layer 1+2 Ability → Layer 3
|
||||
* политика → Layer 4 императивно — общий вычислитель). Отказ = `403` с обобщённым
|
||||
* кодом (специфика — только в лог).
|
||||
*
|
||||
* Применяется точечно на резолверах auth-v2 (миграция с `@AuthRoles` — Story 6.5),
|
||||
* НЕ глобально как APP_GUARD: legacy `auth/` остаётся на `@AuthRoles`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthorizationGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly policyService: PolicyService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requirement = this.reflector.getAllAndOverride<CheckAbilityRequirement | undefined>(CHECK_ABILITY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requirement) return true; // endpoint не объявил требование CASL — не наше дело
|
||||
|
||||
const { user, resource, serverSecret } = this.extract(context);
|
||||
|
||||
// Серверный секрет — служебный обход (как RolesGuard); межсервисные вызовы.
|
||||
if (serverSecret && serverSecret === config.server_secret) return true;
|
||||
|
||||
await this.policyService.ensure(requirement, user, resource);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Достаёт user + атрибуты ресурса + server-secret из GraphQL или HTTP контекста. */
|
||||
private extract(context: ExecutionContext): {
|
||||
user: IAbilitySubjectUser | undefined;
|
||||
resource: Record<string, unknown> | undefined;
|
||||
serverSecret: string | undefined;
|
||||
} {
|
||||
if (context.getType<GqlContextType>() === 'graphql') {
|
||||
const gql = GqlExecutionContext.create(context);
|
||||
const req = gql.getContext().req;
|
||||
const args = gql.getArgs<Record<string, unknown>>();
|
||||
const resource = (args?.data ?? args?.filter ?? args) as Record<string, unknown> | undefined;
|
||||
return { user: req?.user, resource, serverSecret: req?.headers?.['server-secret'] };
|
||||
}
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const resource = { ...(req?.params ?? {}), ...(req?.query ?? {}), ...(req?.body ?? {}) };
|
||||
return { user: req?.user, resource, serverSecret: req?.headers?.['server-secret'] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscoveryModule } from '@nestjs/core';
|
||||
import { AuthV2InfrastructureModule } from '~/infrastructure/auth-v2/auth-v2-infrastructure.module';
|
||||
import { AbilityFactory } from './ability.factory';
|
||||
import { PolicyRegistry } from './policy.registry';
|
||||
import { PolicyService } from './policy.service';
|
||||
import { AuthorizationGuard } from './authorization.guard';
|
||||
import { AccessRulesCleanupService } from './access-rules-cleanup.service';
|
||||
import { SameCoopVotingPolicy } from './policies/same-coop-voting.policy';
|
||||
|
||||
/**
|
||||
* CASL-авторизация auth-v2 (Эпик 6). Story 6.1 — `AbilityFactory` (Layer 1);
|
||||
* Story 6.2 — Layer 2 (`access_rules`); Story 6.3 — Layer 3 (`PolicyRegistry` +
|
||||
* политики `@PolicyHandler`); Story 6.4 — `PolicyService` (общий вычислитель L1–L4)
|
||||
* + единый `AuthorizationGuard` (REST + GraphQL). Резолверы переходят на
|
||||
* `@CheckAbility`+guard в Story 6.5.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DiscoveryModule, AuthV2InfrastructureModule],
|
||||
providers: [AbilityFactory, PolicyRegistry, PolicyService, AuthorizationGuard, AccessRulesCleanupService, SameCoopVotingPolicy],
|
||||
exports: [AbilityFactory, PolicyRegistry, PolicyService, AuthorizationGuard],
|
||||
})
|
||||
export class AuthorizationModule {}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { CapabilitySetService } from './capability-set.service';
|
||||
import { AuthorizationResolver } from './authorization.resolver';
|
||||
|
||||
const CURRENT_USER = { id: 'u1', username: 'chairman1', role: 'chairman' };
|
||||
|
||||
function build() {
|
||||
const service = {
|
||||
listSets: jest.fn(async () => [
|
||||
{
|
||||
setKey: 'accountant',
|
||||
title: 'Бухгалтер',
|
||||
description: 'Стол бухгалтера',
|
||||
builtin: true,
|
||||
coopname: null,
|
||||
grants: [{ action: 'read', resource: 'AccountingDesk' }],
|
||||
},
|
||||
]),
|
||||
listForParticipant: jest.fn(async () => [
|
||||
{
|
||||
username: 'payer1',
|
||||
setKey: 'cashier',
|
||||
grantedBy: 'chairman1',
|
||||
grantedAt: '2026-06-13T00:00:00Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
]),
|
||||
getMyAccess: jest.fn(async () => ({
|
||||
sets: ['accountant'],
|
||||
grants: [{ action: 'read', resource: 'AccountingDesk' }],
|
||||
})),
|
||||
assign: jest.fn(async () => undefined),
|
||||
revoke: jest.fn(async () => undefined),
|
||||
};
|
||||
const resolver = new AuthorizationResolver(service as unknown as CapabilitySetService);
|
||||
return { resolver, service };
|
||||
}
|
||||
|
||||
describe('AuthorizationResolver', () => {
|
||||
it('getCapabilitySets маппит в snake_case вместе с грантами', async () => {
|
||||
const { resolver } = build();
|
||||
expect(await resolver.getCapabilitySets()).toEqual([
|
||||
{
|
||||
set_key: 'accountant',
|
||||
title: 'Бухгалтер',
|
||||
description: 'Стол бухгалтера',
|
||||
builtin: true,
|
||||
coopname: null,
|
||||
grants: [{ action: 'read', resource: 'AccountingDesk' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('getParticipantCapabilitySets маппит назначение в snake_case', async () => {
|
||||
const { resolver, service } = build();
|
||||
const out = await resolver.getParticipantCapabilitySets('payer1');
|
||||
expect(service.listForParticipant).toHaveBeenCalledWith('payer1');
|
||||
expect(out).toEqual([
|
||||
{
|
||||
username: 'payer1',
|
||||
set_key: 'cashier',
|
||||
granted_by: 'chairman1',
|
||||
granted_at: '2026-06-13T00:00:00Z',
|
||||
expires_at: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('getMyAccess передаёт username/role текущего пайщика и маппит гранты', async () => {
|
||||
const { resolver, service } = build();
|
||||
const out = await resolver.getMyAccess(CURRENT_USER);
|
||||
expect(service.getMyAccess).toHaveBeenCalledWith({ username: 'chairman1', role: 'chairman' });
|
||||
expect(out).toEqual({ sets: ['accountant'], grants: [{ action: 'read', resource: 'AccountingDesk' }] });
|
||||
});
|
||||
|
||||
it('assignCapabilitySet ставит grantedBy = текущий пользователь и маппит set_key', async () => {
|
||||
const { resolver, service } = build();
|
||||
const ok = await resolver.assignCapabilitySet(
|
||||
{ username: 'payer1', set_key: 'cashier', expires_at: null },
|
||||
CURRENT_USER,
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
expect(service.assign).toHaveBeenCalledWith({
|
||||
username: 'payer1',
|
||||
setKey: 'cashier',
|
||||
grantedBy: 'chairman1',
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('revokeCapabilitySet зовёт revoke с revokedBy = текущий пользователь', async () => {
|
||||
const { resolver, service } = build();
|
||||
const ok = await resolver.revokeCapabilitySet({ username: 'payer1', set_key: 'cashier' }, CURRENT_USER);
|
||||
expect(ok).toBe(true);
|
||||
expect(service.revoke).toHaveBeenCalledWith('payer1', 'cashier', 'chairman1');
|
||||
});
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import { CapabilitySetService } from './capability-set.service';
|
||||
import { AuthorizationGuard } from './authorization.guard';
|
||||
import { CheckAbility } from './check-ability.decorator';
|
||||
import {
|
||||
AssignCapabilitySetInputDTO,
|
||||
CapabilitySetAssignmentDTO,
|
||||
CapabilitySetDTO,
|
||||
ParticipantAccessDTO,
|
||||
RevokeCapabilitySetInputDTO,
|
||||
} from './dto/capability-set.dto';
|
||||
|
||||
interface ICurrentUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL-фасад назначаемых наборов возможностей и эффективного доступа (Story 6.11).
|
||||
* Заменяет REST-контроллеры `coop/capability-sets` и `coop/access` — фронт ходит через
|
||||
* @coopenomics/sdk (Zeus), нового способа взаимодействия с бэкендом наружу не появляется.
|
||||
*
|
||||
* Авторизация — тот же субстрат Эпика 6: `@CheckAbility` + `AuthorizationGuard` (уже
|
||||
* GraphQL-aware). Председатель управляет наборами (`manage CapabilitySet`), читает каталог
|
||||
* (`read`); `getMyAccess` — собственный доступ текущего пайщика (без CASL-требования).
|
||||
*/
|
||||
@Resolver()
|
||||
export class AuthorizationResolver {
|
||||
constructor(private readonly service: CapabilitySetService) {}
|
||||
|
||||
@Query(() => [CapabilitySetDTO], {
|
||||
name: 'getCapabilitySets',
|
||||
description: 'Каталог наборов возможностей с правами, которые они открывают',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, AuthorizationGuard)
|
||||
@CheckAbility('read', 'CapabilitySet')
|
||||
async getCapabilitySets(): Promise<CapabilitySetDTO[]> {
|
||||
const sets = await this.service.listSets();
|
||||
return sets.map((s) => ({
|
||||
set_key: s.setKey,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
builtin: s.builtin,
|
||||
coopname: s.coopname,
|
||||
grants: s.grants.map((g) => ({ action: g.action, resource: g.resource })),
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => [CapabilitySetAssignmentDTO], {
|
||||
name: 'getParticipantCapabilitySets',
|
||||
description: 'Активные наборы возможностей, назначенные пайщику',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, AuthorizationGuard)
|
||||
@CheckAbility('read', 'CapabilitySet')
|
||||
async getParticipantCapabilitySets(
|
||||
@Args('username', { type: () => String }) username: string,
|
||||
): Promise<CapabilitySetAssignmentDTO[]> {
|
||||
const assignments = await this.service.listForParticipant(username);
|
||||
return assignments.map((a) => ({
|
||||
username: a.username,
|
||||
set_key: a.setKey,
|
||||
granted_by: a.grantedBy,
|
||||
granted_at: a.grantedAt,
|
||||
expires_at: a.expiresAt,
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => ParticipantAccessDTO, {
|
||||
name: 'getMyAccess',
|
||||
description: 'Эффективный доступ текущего пайщика (основание гейтинга столов и страниц)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async getMyAccess(@CurrentUser() user: ICurrentUser): Promise<ParticipantAccessDTO> {
|
||||
const access = await this.service.getMyAccess({ username: user.username, role: user.role });
|
||||
return {
|
||||
sets: access.sets,
|
||||
grants: access.grants.map((g) => ({ action: g.action, resource: g.resource })),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'assignCapabilitySet',
|
||||
description: 'Назначить пайщику набор возможностей (управляет председатель)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, AuthorizationGuard)
|
||||
@CheckAbility('manage', 'CapabilitySet')
|
||||
async assignCapabilitySet(
|
||||
@Args('data', { type: () => AssignCapabilitySetInputDTO }) data: AssignCapabilitySetInputDTO,
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
): Promise<boolean> {
|
||||
await this.service.assign({
|
||||
username: data.username,
|
||||
setKey: data.set_key,
|
||||
grantedBy: user.username,
|
||||
expiresAt: data.expires_at ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'revokeCapabilitySet',
|
||||
description: 'Отозвать у пайщика набор возможностей (управляет председатель)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, AuthorizationGuard)
|
||||
@CheckAbility('manage', 'CapabilitySet')
|
||||
async revokeCapabilitySet(
|
||||
@Args('data', { type: () => RevokeCapabilitySetInputDTO }) data: RevokeCapabilitySetInputDTO,
|
||||
@CurrentUser() user: ICurrentUser,
|
||||
): Promise<boolean> {
|
||||
await this.service.revoke(data.username, data.set_key, user.username);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
AccessRuleEffect,
|
||||
AccessRulePrincipalKind,
|
||||
type AccessRuleRecord,
|
||||
type IAccessRulesInvalidationPublisher,
|
||||
type IAccessRulesRepository,
|
||||
} from '~/domain/auth-v2/ports/access-rules.port';
|
||||
import type { CapabilitySet, ICapabilitySetsRepository } from '~/domain/auth-v2/ports/capability-sets.port';
|
||||
import type { AuditService } from '../audit/audit.service';
|
||||
import type { AbilityFactory } from './ability.factory';
|
||||
import { CapabilitySetService } from './capability-set.service';
|
||||
|
||||
const ACCOUNTANT: CapabilitySet = {
|
||||
setKey: 'accountant',
|
||||
title: 'Бухгалтер',
|
||||
description: 'Стол бухгалтера',
|
||||
builtin: true,
|
||||
coopname: null,
|
||||
};
|
||||
|
||||
function setRule(setKey: string, action: string, resource: string): AccessRuleRecord {
|
||||
return {
|
||||
subjectType: AccessRulePrincipalKind.CapabilitySet,
|
||||
subjectId: setKey,
|
||||
effect: AccessRuleEffect.Allow,
|
||||
action,
|
||||
resourceType: resource,
|
||||
conditions: null,
|
||||
};
|
||||
}
|
||||
|
||||
function build() {
|
||||
const repo = {
|
||||
listSets: jest.fn(async (): Promise<CapabilitySet[]> => [ACCOUNTANT]),
|
||||
findSet: jest.fn(async (key: string): Promise<CapabilitySet | null> => (key === 'accountant' ? ACCOUNTANT : null)),
|
||||
listActiveSetKeys: jest.fn(async (_username: string): Promise<string[]> => ['accountant']),
|
||||
listAssignments: jest.fn(async (_username: string) => []),
|
||||
assign: jest.fn(async (): Promise<void> => undefined),
|
||||
revoke: jest.fn(async (_username: string, _setKey: string): Promise<boolean> => true),
|
||||
} satisfies ICapabilitySetsRepository;
|
||||
const accessRules = {
|
||||
findForPrincipal: jest.fn(async (): Promise<AccessRuleRecord[]> => []),
|
||||
findForCapabilitySets: jest.fn(async (keys: string[]): Promise<AccessRuleRecord[]> =>
|
||||
keys.includes('accountant') ? [setRule('accountant', 'read', 'AccountingDesk')] : [],
|
||||
),
|
||||
insert: jest.fn(async (): Promise<void> => undefined),
|
||||
deleteExpired: jest.fn(async (): Promise<number> => 0),
|
||||
} satisfies IAccessRulesRepository;
|
||||
const invalidation = { publish: jest.fn(async (): Promise<void> => undefined) } satisfies IAccessRulesInvalidationPublisher;
|
||||
const ability = { rules: [{ action: 'read', subject: 'AccountingDesk', inverted: false }] };
|
||||
const abilityFactory = {
|
||||
createForParticipantWithRules: jest.fn(async () => ability),
|
||||
} as unknown as AbilityFactory;
|
||||
const audit = { record: jest.fn(async (): Promise<void> => undefined) } as unknown as AuditService & { record: jest.Mock };
|
||||
const service = new CapabilitySetService(repo, accessRules, invalidation, abilityFactory, audit);
|
||||
return { service, repo, accessRules, invalidation, abilityFactory, audit };
|
||||
}
|
||||
|
||||
describe('CapabilitySetService — назначаемые наборы (Story 6.11)', () => {
|
||||
it('assign: валидирует набор, пишет назначение, инвалидирует сессии, аудирует', async () => {
|
||||
const { service, repo, invalidation, audit } = build();
|
||||
await service.assign({ username: 'kate', setKey: 'accountant', grantedBy: 'chief', expiresAt: null });
|
||||
|
||||
expect(repo.findSet).toHaveBeenCalledWith('accountant');
|
||||
expect(repo.assign).toHaveBeenCalledWith({ username: 'kate', setKey: 'accountant', grantedBy: 'chief', expiresAt: null });
|
||||
expect(invalidation.publish).toHaveBeenCalledWith({ subjectType: AccessRulePrincipalKind.Participant, subjectId: 'kate' });
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'CapabilitySetAssigned', subjectId: 'kate', actor: 'chief', result: 'success' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('assign: несуществующий набор → NotFound, без записи/инвалидации/аудита', async () => {
|
||||
const { service, repo, invalidation, audit } = build();
|
||||
await expect(service.assign({ username: 'kate', setKey: 'ghost', grantedBy: 'chief' })).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(repo.assign).not.toHaveBeenCalled();
|
||||
expect(invalidation.publish).not.toHaveBeenCalled();
|
||||
expect(audit.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revoke: снимает набор, инвалидирует, аудирует', async () => {
|
||||
const { service, repo, invalidation, audit } = build();
|
||||
await service.revoke('kate', 'accountant', 'chief');
|
||||
|
||||
expect(repo.revoke).toHaveBeenCalledWith('kate', 'accountant');
|
||||
expect(invalidation.publish).toHaveBeenCalledWith({ subjectType: AccessRulePrincipalKind.Participant, subjectId: 'kate' });
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'CapabilitySetRevoked', subjectId: 'kate', actor: 'chief', result: 'success', context: { set_key: 'accountant' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('revoke: нет активного назначения → NotFound, без инвалидации/аудита', async () => {
|
||||
const { service, repo, invalidation, audit } = build();
|
||||
repo.revoke.mockResolvedValueOnce(false);
|
||||
await expect(service.revoke('kate', 'accountant', 'chief')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(invalidation.publish).not.toHaveBeenCalled();
|
||||
expect(audit.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('listSets отдаёт каталог наборов, обогащённый их грантами', async () => {
|
||||
const { service } = build();
|
||||
const sets = await service.listSets();
|
||||
expect(sets).toHaveLength(1);
|
||||
expect(sets[0]).toMatchObject({ setKey: 'accountant', title: 'Бухгалтер' });
|
||||
expect(sets[0].grants).toEqual([{ action: 'read', resource: 'AccountingDesk' }]);
|
||||
});
|
||||
|
||||
it('getMyAccess отдаёт активные наборы + плоские гранты из Ability (allow, без дублей)', async () => {
|
||||
const { service, abilityFactory } = build();
|
||||
const access = await service.getMyAccess({ username: 'kate', role: 'user' });
|
||||
expect(abilityFactory.createForParticipantWithRules).toHaveBeenCalledWith({ username: 'kate', role: 'user' });
|
||||
expect(access.sets).toEqual(['accountant']);
|
||||
expect(access.grants).toEqual([{ action: 'read', resource: 'AccountingDesk' }]);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
+31
@@ -0,0 +1,31 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { CoopAction, CoopSubject } from './ability.types';
|
||||
|
||||
/** Ключ метаданных, который читает `AuthorizationGuard` (Story 6.4). */
|
||||
export const CHECK_ABILITY = 'coopid:check-ability';
|
||||
|
||||
/**
|
||||
* Требование авторизации на endpoint: пара `action`+`subject` (проверяется по Ability,
|
||||
* Layer 1+2) и опциональная `policy` (Layer 3, исполняется `PolicyRegistry`). Guard
|
||||
* объединяющий все слои — Story 6.4; здесь — декларация требования и его тип.
|
||||
*/
|
||||
export interface CheckAbilityRequirement {
|
||||
action: CoopAction;
|
||||
subject: CoopSubject;
|
||||
/** Имя политики Layer 3 (`@PolicyHandler(name)`); если задано — guard её исполнит. */
|
||||
policy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Объявляет требование авторизации (Story 6.5 заменяет им `@AuthRoles` в auth-v2).
|
||||
*
|
||||
* @example `@CheckAbility('vote', 'CriticalAction', { policy: 'same-coop-voting' })`
|
||||
*/
|
||||
export function CheckAbility(
|
||||
action: CoopAction,
|
||||
subject: CoopSubject,
|
||||
options?: { policy?: string },
|
||||
): MethodDecorator & ClassDecorator {
|
||||
const requirement: CheckAbilityRequirement = { action, subject, policy: options?.policy };
|
||||
return SetMetadata(CHECK_ABILITY, requirement);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user