Compare commits

...

22 Commits

Author SHA1 Message Date
coopops a288e8888b [105-12][@ant] docs: runbook восстановления кооператива из S3-бэкапа со smoke-тестом
Typecheck / desktop (pull_request) Failing after 8m17s
Typecheck / controller (pull_request) Successful in 13m3s
Story 9.9. docs/operations/disaster-recovery.md — пошаговое восстановление
после потери сервера из бэкапа Story 9.8 за RTO до 2 часов: новый сервер с
Docker, скачать архив из S3, распаковать физический basebackup в PGDATA нового
PG-тома (не pg_restore — бэкап физический), docker compose up, и сквозной
smoke-test контура CoopID (вход пайщика → выпуск participant_certificate →
проверка цепочки доверия coop_chain). Документированы RTO и шесть частых
граблей, главная — пароли ролей лежат в самом бэкапе, init-скрипт на непустом
томе не выполняется, нужен ALTER ROLE под текущие секреты. Завершает Подход 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:15:52 +00:00
coopops f4701d07c7 [105-12][@ant] feat: ежедневный pg_basebackup кооп-БД в S3 со связкой retention и аудитом
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Story 9.8. Скрипт infra/coopid/scripts/coopid-backup.sh делает pg_basebackup
всего PG-кластера (authentik_db + coop_domain_db одним gzip-tar) и заливает в
s3://{bucket}/{coopname}/{YYYY-MM-DD}.tar.gz с SSE-S3, чтобы восстановить
кооператив за любой день. Retention 30 дней — через S3 lifecycle rule (режим
--setup), не ручным удалением в cron. Каждый прогон пишет audit_events
coopid.backup.created (s3_key, size_bytes, duration_seconds) прямым INSERT —
бэкап отрабатывает даже при лежащем coopback. Режимы --setup и --dry-run.
README: раздел Бэкапы с env, crontab 03:00 UTC и требованиями к PG. Cron
ставит прод-плейбук. shellcheck зелёный; S3-путь и lifecycle 30d проверены
против MinIO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:09:31 +00:00
coopops d31aa50768 [105-12][@ant] feat: двухключевой rate-limiting контура входа CoopID — защита от брутфорса по аккаунту и по IP
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Story 9.1. RedisThrottlerStorage (атомарный Lua INCR/PEXPIRE/block) в
infrastructure (ioredis запрещён в application/domain — порт-токен в domain).
AuthRateLimitGuard проверяет два независимых счётчика per-IP (50/15мин) и
per-account (5/15мин), превышение любого → 429 too_many_attempts в OAuth2-формате
через контурный AuthV2ExceptionFilter. Навешано на session/bind, verify/timestamp
(ip), vault-retrieve (account по subject_id + ip — цель harvest-энумерации).
Изолировано от глобального ThrottlerModule (не задевает прочие роуты). Пресет
magic-link (3/час) готов под Story 3.1; escalating lockout — Story 3.12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:57:26 +00:00
coopops 9ece08eed9 [105-5][@ant] feat: SDK verifyDocumentOffline — backward-валидность подписей через окна активности ключей после ротации
Typecheck / desktop (pull_request) Failing after 8m17s
Typecheck / controller (pull_request) Successful in 13m7s
2026-06-10 16:18:13 +00:00
coopops 0a6ede48ef [105-5][@ant] feat: SDK signDocument — локальная подпись документов в compact JWS ES256K без обращения к серверу
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-10 16:08:54 +00:00
coopops 2f15111f99 [105-4][@ant] feat: единый AuthV2ExceptionFilter и SDK-каталог человеческих сообщений — понятные причины сбоя входа и доставка OAuth2-контракта по HTTP
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 15:58:21 +00:00
coopops c6ca8a2fa5 [105-4][@ant] feat: RP-initiated logout — отзыв refresh_token и затирание keystore при выходе пайщика
Typecheck / desktop (pull_request) Failing after 8m21s
Typecheck / controller (pull_request) Successful in 13m8s
Эндпоинт POST /coop/logout (контур auth-v2): отзывает refresh/access токены
через TokenApplicationService, гасит session_binding cookie и пишет audit_events.
Без guard и идемпотентен — выйти можно и с истёкшим access-токеном. SDK logout()
дёргает сервер best-effort и в любом случае затирает локальный keystore и PIN-ключ
(finally), чтобы расшифрованный ключ не остался в памяти браузера при сбое сети.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:22:21 +00:00
coopops 3ed1c5ee95 [105-4][@ant] feat: карточка удостоверения пайщика в ЛК — пайщик видит серийник, срок, статус, цепочку доверия и подтверждения своего CoopID
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
ProfilePage (пункт меню «Удостоверение») расширена карточкой: серийный номер (jti, copyable mono), срок действия, статус-чип (Активно/Истекает/Истекло по exp), визуальная цепочка подписей АНО→Восход→Восток→Вы, список verification_types человеческим языком и кнопка «Скачать как QR» (Vision-stub, мягкое уведомление). Данные — GET /coop/certificate через существующий sendGET (Bearer), payload декодируется локально для отображения (подпись — offline-verify Story 4.4). Best-effort: сбой/отсутствие удостоверения показывает EmptyState, профиль работает. Только канон-компоненты и токены, eslint чист, HMR без рестарта контейнера.
2026-06-10 15:07:55 +00:00
coopops d63ace86e4 [105-4][@ant] feat: SDK-чтение participant_certificate и серийник jti — фундамент личного кабинета удостоверения, чтобы пайщик видел claims без серверного раунда
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
CertificateService добавляет серийный номер удостоверения (jti=UUID), который требует страница ЛК (Story 1.9). SDK @coopenomics/auth: модуль certificate/ с decodeParticipantCertificate (claims без проверки подписи — для отображения), certificateStatus (active/expiring/expired по exp), VERIFICATION_TYPE_LABELS/verificationTypeLabel (человекочитаемые типы верификации, forward-совместимо); getParticipantCertificate(apiUrl, accessToken) тянет GET /coop/certificate с Bearer по образцу fetchVaultBlob. 13 SDK + 6 controller тестов зелёные, dist пересобран, tsc чист.
2026-06-10 15:00:22 +00:00
coopops e67001a82a [105-4][@ant] feat: выпуск participant_certificate — compact JWS ES256K с цепью доверия coop_chain даёт пайщику самопроверяемое удостоверение для внешних сервисов
Typecheck / desktop (pull_request) Failing after 8m20s
Typecheck / controller (pull_request) Has been cancelled
CertificateService подписывает claims (iss/sub=UUID/iat/exp/coopname/coop_chain[ano,voskhod,vostok]/verification_types/identification/claim_schema_version) ключом permission cert аккаунта vostok через jose+Node KeyObject из PEM (Docker Secret coop_cert_key); coop_chain читается из COOPOS через новый BlockchainPort.getCertPublicKey (single-key, нормализация PUB_K1), кэшируется; size-guard 5КБ под Vision/MIFARE. Эндпоинт GET /coop/certificate под HttpJwtAuthGuard; сертификат также встроен в ответ /coop/verify/timestamp best-effort (завершает контракт 1.7, не ломает логин при сбое). config: COOP_CERT_KEY[_FILE]+геттер. 16 unit зелёных, coopback поднялся чисто. verifyOffline/backward-валидность -> 4.4/2.5.
2026-06-10 14:44:12 +00:00
coopops c4febaa749 [105-4][@ant] feat: эндпоинт /coop/verify/timestamp — второй этап входа CoopID доказывает владение ключом, проверяя подпись метки против COOPOS и single-use jti, защищая от угона пароля
Typecheck / desktop (pull_request) Failing after 8m26s
Typecheck / controller (pull_request) Has been cancelled
Пайплайн VerifyTimestampService: верификация session_binding_token (HS256), атомарный GETDEL jti до криптопроверки (anti-replay), окно свежести 60s против head_block_time, recover pubkey по каноническому сообщению (зеркало SDK signTimestamp) и сверка с активным ключом аккаунта; успех -> выпуск access/refresh через TokenApplicationService, audit success/failure без секретов. Crypto recover вынесена за BlockchainPort.recoverPublicKey ради гексагонального инварианта auth-v2 (запрет wharfkit в application). id_token/certificate -> Story 1.8.
2026-06-10 14:22:32 +00:00
coopops f573d0e5a3 [105-5][@ant] feat: signTimestamp в SDK для второго этапа auth — локальная COOPOS-native recoverable подпись канонической метки {ts,jti,sub} ключом из keystore, без сети, с экспортом канонизатора чтобы verify-эндпоинт собрал те же байты для восстановления pubkey
Typecheck / desktop (pull_request) Failing after 8m56s
Typecheck / controller (pull_request) Successful in 13m46s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:16:18 +00:00
coopops fafb991561 [105-5][@ant] feat: клиентская расшифровка vault и локальный keystore в SDK — unlockWallet тянет blob через новый GET, расшифровывает паролём на клиенте и кладёт ключ только в RAM-keystore, getWallet отдаёт несериализуемую обёртку без приватного ключа, опциональный PIN-слой для быстрой разблокировки на устройстве
Typecheck / desktop (pull_request) Failing after 8m55s
Typecheck / controller (pull_request) Successful in 13m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:26:12 +00:00
coopops 49ecba1149 [105-4][@ant] docs: предупредить про обязательный coopid-gen-secrets перед docker compose up — иначе валидация падает на отсутствующих file-секретах и не поднимается весь стек на любом чекауте
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:11:14 +00:00
coopops aa482316ee [105-4][@ant] refactor: свернуть CoopID-overlay'и в единый docker-compose и перенести authentik_db+coop_domain_db в существующий postgres — чтобы весь стек поднимался одной командой docker compose up -d, как того требует прод-деплой через плейбук, без отдельного контейнера postgres
Typecheck / desktop (pull_request) Failing after 9m11s
Typecheck / controller (pull_request) Successful in 14m5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:36:49 +00:00
coopops 33548c2200 [105-5][@ant] feat: vault-шифрование участника — клиентский Argon2id+AES-256-GCM в SDK (AAD субъекта) + POST /coop/vault с type-driven запретом серверной расшифровки ключа, гарантируя что сервер хранит только зашифрованный блоб
Typecheck / desktop (pull_request) Failing after 8m54s
Typecheck / controller (pull_request) Successful in 13m43s
2026-06-10 08:13:28 +00:00
coopops 7b6fdd823e [105-4][@ant] feat: session_binding_token-мост (вариант B) — controller выпускает HS256-токен после authentik-password через защищённый bind-эндпоинт с jti в Redis, связывая два этапа входа и отсекая replay перед timestamp-проверкой
Typecheck / desktop (pull_request) Failing after 8m44s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:55:36 +00:00
coopops 0065314b95 [105-4][@ant] feat: парольная политика CoopID (zxcvbn≥3, ≥12 символов, RU-сообщение) blueprint'ом authentik + аудит WeakPasswordRejected в coop_domain_db через защищённый webhook — усиливаем вход и фиксируем отказы по слабым паролям в неизменяемом журнале
Typecheck / desktop (pull_request) Failing after 19m48s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:32:41 +00:00
coopops f8e835b4a8 [105-4][@ant] feat: схемы coop_domain_db миграцией V2.4.0 (vaults, append-only audit_events с партициями, chain cache, mapping, access_rules) — фундамент хранения для vault-шифрования, аудита и CASL ещё до их сервисов
Typecheck / desktop (pull_request) Failing after 9m14s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 07:10:52 +00:00
coopops 4ac9e17296 [105-4][@ant] feat: COOPOS permission cert (ES256K) на ano/voskhod/vostok миграцией 052 + BLOCKCHAIN_RPC_LIST и readCertPublicKey — закладываем цепочку доверия для подписи удостоверений пайщика ключом кооператива
Typecheck / desktop (pull_request) Failing after 8m48s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 06:59:56 +00:00
coopops 75f5a4bfd5 [105-4][@ant] feat: workspace-пакет @coopenomics/auth (скелет SDK) + ESLint-инвариант ports&adapters — фиксируем публичную поверхность auth flow для браузера/Node/desktop и запрещаем application/domain контроллера импортировать wharfkit/oidc-client-ts/ioredis напрямую
Typecheck / desktop (pull_request) Failing after 8m50s
Typecheck / controller (pull_request) Successful in 13m45s
2026-06-10 06:19:10 +00:00
coopops 86dede3d35 [105-4][@ant] feat: Docker Compose overlay-стек CoopID (authentik 2026.2 + postgres:16 + caddy + crowdsec) — инфраструктурный seed Эпика 1: per-coop OIDC Provider с двумя изолированными БД и секретами через Docker Secrets вместо открытых .env
Typecheck / desktop (pull_request) Failing after 8m58s
Typecheck / controller (pull_request) Has been cancelled
2026-06-10 06:02:23 +00:00
94 changed files with 5669 additions and 17 deletions
+11
View File
@@ -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
+3
View File
@@ -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/
+12
View File
@@ -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,
},
})
+13
View File
@@ -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',
},
},
)
+58
View File
@@ -0,0 +1,58 @@
{
"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",
"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",
"eslint": "^9.9.1",
"typescript": "^5.5.4",
"unbuild": "^2.0.0",
"vite": "^5.4.3",
"vitest": "^2.0.5"
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 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 interface CoopChainLink {
account: string
public_key: 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: string[]
identification: Record<string, unknown> | null
claim_schema_version: string
}
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: Array.isArray(raw.verification_types) ? (raw.verification_types as string[]) : [],
identification: (raw.identification as Record<string, unknown> | null) ?? null,
claim_schema_version: String(raw.claim_schema_version ?? ''),
}
}
/** Статус по сроку действия: 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'
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Цепочка доверия: офлайн-проверка 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 { AuthV2Error, AuthV2ErrorCode, notImplemented } 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 interface VerifyOfflineResult {
valid: boolean
/** Причина отказа, если valid=false (expired / revoked / chain broken) */
reason?: string
}
/** Офлайн-валидация удостоверения без обращения к сети. Story 4.4. */
export async function verifyOffline(_certificate: string): Promise<VerifyOfflineResult> {
notImplemented('verifyOffline')
}
/**
* Окно активности одного публичного ключа подписанта в истории его аккаунта.
* Блоки, не время: смена 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,8 @@
/**
* Embedded trust anchor цепочки доверия: публичный ключ `ano.cert` (ES256K).
* Якорь вшивается в пакет, чтобы verifyOffline() работал без сети.
*
* TODO(Story 1.3): подставить реальный JWK после создания permission `cert`
* на аккаунте `ano` в COOPOS.
*/
export const TRUST_ANCHOR_ANO_CERT_JWK: JsonWebKey | null = null
+179
View File
@@ -0,0 +1,179 @@
/**
* Зеркало 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',
NetworkError = 'network_error',
WalletLocked = 'wallet_locked',
ClientWalletMismatch = 'client_wallet_mismatch',
}
/**
* Ошибки 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.NetworkError]: {
message: 'Нет связи с кооперативом. Проверьте интернет.',
action: 'check_connection',
keepSession: true,
},
[AuthV2ErrorCode.WalletLocked]: {
message: 'Кошелёк заблокирован. Введите пароль для доступа к ключу.',
action: 'retry',
keepSession: true,
},
[AuthV2ErrorCode.ClientWalletMismatch]: {
message: 'Ключ в этом браузере не соответствует аккаунту. Войдите заново.',
action: 'retry',
keepSession: false,
},
}
/** Безопасный фолбэк для неожиданной (не-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 }
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Экспорт удостоверения. QR — Vision-stub: фиксирует публичную поверхность,
* реализация за пределами MVP.
*/
import { notImplemented } from '../errors'
/** Экспорт participant_certificate в QR (Vision). */
export async function exportToQR(_certificate: string): Promise<Uint8Array> {
notImplemented('exportToQR')
}
+37
View File
@@ -0,0 +1,37 @@
export type { CertificateStatus, CoopChainLink, ParticipantCertificateClaims } from './certificate'
export {
CERTIFICATE_EXPIRING_WINDOW_MS,
certificateStatus,
decodeParticipantCertificate,
VERIFICATION_TYPE_LABELS,
verificationTypeLabel,
} from './certificate'
export type { KeyValidityWindow, VerifyDocumentParams, VerifyDocumentReason, VerifyDocumentResult, 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_JWK } from './config/trust-anchor'
export { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from './errors'
export type { AuthV2ErrorAction, AuthV2ErrorView } from './errors'
export { exportToQR } from './export'
export type { LoginParams, LoginResult, LogoutParams } from './oidc'
export { getAccessToken, getParticipantCertificate, login, loginWithMagicLink, logout, recover } from './oidc'
export type { SignDocumentParams, TimestampSignature } from './signing'
export { canonicalTimestampMessage, signDocument, signTimestamp } from './signing'
export type { EncryptedVaultBlob, VaultSubject } from './vault'
export { ARGON2ID_PARAMS, decryptPrivateKey, deriveKey, encryptPrivateKey } from './vault'
export type { StorageAdapter } from './wallet'
export { clearPinProtected, fetchVaultBlob, getWallet, isWalletUnlocked, lockWallet, rotateKey, unlockWallet, unlockWithPin, Wallet } from './wallet'
+97
View File
@@ -0,0 +1,97 @@
/**
* OIDC-слой: вход через authentik (password + timestamp-signature),
* magic-link, recovery и работа с токенами (oidc-client-ts).
*/
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
import { clearPinProtected, lockWallet, type StorageAdapter } from '../wallet'
export interface LoginParams {
/** Issuer кооператива, например `https://coop.example/application/o/coopid/` */
issuer: string
email: string
password: string
}
export interface LoginResult {
accessToken: string
idToken: string
/** compact JWS ES256K, выпускается controller'ом */
participantCertificate: string
}
/** Двухэтапный вход: password (authentik) → timestamp-signature (controller). Story 1.7. */
export async function login(_params: LoginParams): Promise<LoginResult> {
notImplemented('login')
}
/** Вход по magic-link (восстановление доступа). Story 3.1. */
export async function loginWithMagicLink(_link: string): Promise<LoginResult> {
notImplemented('loginWithMagicLink')
}
/** Восстановление доступа (magic-link / offline-код, по стратегии кооператива). Эпик 3. */
export async function recover(_email: string): Promise<void> {
notImplemented('recover')
}
/** Текущий access_token (с автообновлением через refresh). Story 1.7. */
export async function getAccessToken(): Promise<string> {
notImplemented('getAccessToken')
}
/**
* Актуальное 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
/** Хранилище PIN-protected ключа — стирается, если вход по PIN был включён. */
pinStorage?: StorageAdapter
}
/**
* 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()
if (params.pinStorage)
await clearPinProtected(params.pinStorage)
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Подпись: документы (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'
}
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(),
}
}
+113
View File
@@ -0,0 +1,113 @@
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 привязывает шифртекст к субъекту: подмена subject_id ломает расшифровку. */
function aad(subject: VaultSubject): string {
return `${subject.subject_type}|${subject.subject_id}`
}
/** 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-storage (AAD=`pin|<account>`) — без дублирования крипто.
*/
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))
}
+8
View File
@@ -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'
+17
View File
@@ -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)
}
+15
View File
@@ -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
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Кошелёк (Story 2.2): разблокировка после логина, доступ к публичному «виду»
* ключа в памяти, запирание на logout. Приватный ключ живёт ТОЛЬКО в keystore
* (storage.ts) и наружу не выходит — `Wallet` его не содержит и не сериализует.
* Desktop-кошелёк переезжает на этот модуль (миграция — Эпик 7).
*/
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
import { decryptPrivateKey } from '../vault/encrypt'
import type { EncryptedVaultBlob } from '../vault/types'
import { loadPinProtected, savePinProtected, type StorageAdapter } from './pin'
import { currentView, isUnlocked, 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>
}
interface UnlockParams {
apiUrl: string
account: string
password: string
/** Опционально сохранить ключ под PIN для быстрой разблокировки на устройстве. */
persistPin?: { pin: string, storage: StorageAdapter }
}
/**
* Полная разблокировка: забрать 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 })
if (params.persistPin)
await savePinProtected(privateKey, params.persistPin.pin, params.account, params.persistPin.storage)
return new Wallet({ account: params.account, publicKey })
}
/**
* Быстрая разблокировка ранее сохранённым PIN (без обращения к серверу/паролю).
* `null` — на устройстве нет PIN-записи. Неверный PIN → `VaultDecryptionFailed`.
*/
export async function unlockWithPin(pin: string, storage: StorageAdapter): Promise<Wallet | null> {
const loaded = await loadPinProtected(pin, 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 })
}
/** Текущий разблокированный кошелёк. Бросает `WalletLocked`, если заперт. */
export async function getWallet(): Promise<Wallet> {
return new Wallet(currentView())
}
/** Заперт ли кошелёк (нет ключа в памяти). */
export function isWalletUnlocked(): boolean {
return isUnlocked()
}
/** Затирает ключ из памяти (logout). Идемпотентно. PIN-запись не трогает. */
export function lockWallet(): void {
wipeKeystore()
}
/** Ротация ключа пайщика (updateauth + перешифровка vault). Story 3.3. */
export async function rotateKey(): Promise<void> {
notImplemented('rotateKey')
}
export type { StorageAdapter } from './pin'
export { clearPinProtected } from './pin'
+63
View File
@@ -0,0 +1,63 @@
/**
* Опциональный PIN-слой (Story 2.2): обёртка приватного ключа коротким PIN
* поверх того же Argon2id+AES-256-GCM, что и vault. Хранится в подключаемом
* `StorageAdapter` (в браузере — localStorage/IndexedDB, в тестах/Node — память),
* чтобы крипто-логика не зависела от среды и тестировалась без браузера.
*
* Модель угроз: PIN слабее пароля, но Argon2id держит перебор медленным, а AAD
* `pin|<account>` привязывает обёртку к аккаунту. Защита от компрометации самого
* устройства — вне scope этого слоя.
*/
import { decryptWithPassword, encryptWithPassword } from '../vault/encrypt'
import type { EncryptedVaultBlob } from '../vault/types'
/** Минимальный async key-value стор. Браузер: обёртка над localStorage/IndexedDB. */
export interface StorageAdapter {
get: (key: string) => Promise<string | null>
set: (key: string, value: string) => Promise<void>
remove: (key: string) => Promise<void>
}
const STORAGE_KEY = 'coopid.wallet.pin-vault'
function aad(account: string): string {
return `pin|${account}`
}
interface PinRecord {
account: string
blob: EncryptedVaultBlob
}
/** Сохраняет ключ под PIN в 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 по PIN. `null` — записи нет. Неверный PIN →
* `VaultDecryptionFailed` (из ядра). Возвращает `{account, privateKey}`.
*/
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-запись (например, при logout с забыванием устройства). */
export async function clearPinProtected(storage: StorageAdapter): Promise<void> {
await storage.remove(STORAGE_KEY)
}
+55
View File
@@ -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
}
}
+90
View File
@@ -0,0 +1,90 @@
import { SignJWT } from 'jose'
import { describe, expect, it } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import {
CERTIFICATE_EXPIRING_WINDOW_MS,
certificateStatus,
decodeParticipantCertificate,
verificationTypeLabel,
} from '../src/certificate'
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: ['coop_baseline'],
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(['coop_baseline'])
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('не-JWT строка → AuthV2Error(chain_verification_failed)', () => {
const err = (() => { try { decodeParticipantCertificate('garbage'); return null } catch (e) { return 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')
})
})
+29
View File
@@ -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)
})
})
+49
View File
@@ -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)
})
})
+54
View File
@@ -0,0 +1,54 @@
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',
'exportToQR',
] as const
// Методы, ещё не реализованные (бросают not_implemented). По мере реализации
// историй метод уходит отсюда: getWallet — 2.2, signTimestamp — 2.4 (реализованы).
const IMPLEMENTED = new Set(['getWallet', 'signTimestamp', 'getParticipantCertificate', 'logout', 'signDocument'])
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('trust anchor и список нод доступны (placeholder до Stories 1.3/9.5)', () => {
expect(api.TRUST_ANCHOR_ANO_CERT_JWK).toBeNull()
expect(api.COOPOS_PUBLIC_NODES).toEqual([])
})
})
+65
View File
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const lockWallet = vi.fn()
const clearPinProtected = vi.fn().mockResolvedValue(undefined)
// keystore-затирание мокаем на уровне модуля wallet — logout обязан вызвать его
// независимо от исхода сетевого запроса.
vi.mock('../src/wallet', () => ({
lockWallet,
clearPinProtected,
}))
const { logout } = await import('../src/oidc')
describe('logout (Story 1.10)', () => {
beforeEach(() => {
lockWallet.mockClear()
clearPinProtected.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)
})
it('стирает PIN-protected ключ, если pinStorage передан', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const storage = { get: vi.fn(), set: vi.fn(), remove: vi.fn() }
await logout({ apiUrl: 'https://coop.example', refreshToken: 'r1', pinStorage: storage })
expect(clearPinProtected).toHaveBeenCalledWith(storage)
})
it('не трогает PIN-хранилище, если pinStorage не передан', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
await logout({ apiUrl: 'https://coop.example', refreshToken: 'r1' })
expect(clearPinProtected).not.toHaveBeenCalled()
})
})
+155
View File
@@ -0,0 +1,155 @@
import { 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, 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('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)
})
})
+40
View File
@@ -0,0 +1,40 @@
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) ломает расшифровку', async () => {
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
const err = await decryptPrivateKey(blob, PW, { subject_type: 'participant', subject_id: 'petrov' }).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')
})
})
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
import { encryptPrivateKey } from '../src/vault'
import {
clearPinProtected,
getWallet,
isWalletUnlocked,
lockWallet,
type StorageAdapter,
unlockWallet,
unlockWithPin,
Wallet,
} from '../src/wallet'
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
const PW = 'correct-horse-battery-staple-12'
const ACCOUNT = 'ant'
const API = 'http://stub'
/** Память-адаптер для тестов PIN-хранилища (в браузере — localStorage). */
function memoryStorage(): StorageAdapter {
const map = new Map<string, string>()
return {
get: async k => map.get(k) ?? null,
set: async (k, v) => void map.set(k, v),
remove: async k => void map.delete(k),
}
}
/** Подменяет 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)
})
describe('wallet: опциональный PIN-storage', () => {
it('unlock с persistPin → unlockWithPin восстанавливает кошелёк', async () => {
await stubVaultFetch()
const storage = memoryStorage()
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW, persistPin: { pin: '4729', storage } })
lockWallet()
expect(isWalletUnlocked()).toBe(false)
const restored = await unlockWithPin('4729', storage)
expect(restored).not.toBeNull()
expect(restored!.account).toBe(ACCOUNT)
expect(isWalletUnlocked()).toBe(true)
}, 30000)
it('неверный PIN → VaultDecryptionFailed', async () => {
await stubVaultFetch()
const storage = memoryStorage()
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW, persistPin: { pin: '4729', storage } })
const err = await unlockWithPin('0000', storage).then(() => null, e => e)
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
}, 30000)
it('нет PIN-записи → unlockWithPin отдаёт null', async () => {
const storage = memoryStorage()
expect(await unlockWithPin('4729', storage)).toBeNull()
})
it('clearPinProtected удаляет запись', async () => {
await stubVaultFetch()
const storage = memoryStorage()
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW, persistPin: { pin: '4729', storage } })
await clearPinProtected(storage)
expect(await unlockWithPin('4729', storage)).toBeNull()
}, 30000)
})
+16
View File
@@ -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/**/*"]
}
+50 -3
View File
@@ -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,47 @@
"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."
}
]
}
]
}
}
]
}
@@ -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,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;
},
};
+1
View File
@@ -144,6 +144,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",
+2
View File
@@ -29,6 +29,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';
@@ -113,6 +114,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
}),
// Domain modules
AuthDomainModule,
AuthV2Module,
RegistrationDomainModule,
OnboardingDomainModule,
AgendaDomainModule,
@@ -0,0 +1,82 @@
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;
}
/** Ключи, которым нечего делать в 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)
VALUES ($1, $2, $3, $4, $5, $6)`,
[record.event, record.subjectId ?? null, record.actor ?? null, record.result, JSON.stringify(context), record.ip ?? 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,31 @@
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 { AuditService } from './audit/audit.service';
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 { CertificateService } from './certificate/certificate.service';
import { CertificateController } from './certificate/certificate.controller';
import { LogoutService } from './logout/logout.service';
import { LogoutController } from './logout/logout.controller';
import { AuthRateLimitGuard } from './rate-limit/auth-rate-limit.guard';
/**
* 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],
controllers: [AuthentikEventsController, SessionBindingController, VaultController, VerifyTimestampController, CertificateController, LogoutController],
providers: [AuditService, SessionBindingService, VaultService, VerifyTimestampService, CertificateService, LogoutService, AuthRateLimitGuard],
exports: [AuditService, SessionBindingService, VaultService, VerifyTimestampService, CertificateService, LogoutService],
})
export class AuthV2Module {}
@@ -0,0 +1,79 @@
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 } from './audit/audit.service';
/** Payload, который формирует webhook-mapping coopid-webhook-body в authentik. */
export interface AuthentikWebhookBody {
severity?: string;
event_action?: string;
event_user?: string | null;
passing?: boolean | null;
messages?: string[] | null;
created?: string | null;
}
/**
* Маппинг webhook-события authentik в audit-запись. Чистая функция (юнит-тесты).
* execution_logging включён ТОЛЬКО у парольной политики CoopID (см. blueprint),
* поэтому policy_execution + passing=false ⇒ отказ по слабому паролю.
*/
export function mapAuthentikEvent(body: AuthentikWebhookBody): AuditRecord | null {
if (body?.event_action !== 'policy_execution' || 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,
},
};
}
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,37 @@
import {
Controller,
Get,
Req,
UnauthorizedException,
UseFilters,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import { HttpJwtAuthGuard } from '~/application/auth/guards/http-jwt-auth.guard';
import { AuthV2ExceptionFilter } from '../exceptions/auth-v2-exception.filter';
import { CertificateService } from './certificate.service';
interface AuthedRequest extends Request {
user?: { id: string; username: string };
}
/**
* Выдача participant_certificate (CoopID, Story 1.8). Доступен после успешного
* логина: защищён платформенным access-token (HttpJwtAuthGuard → req.user).
* Сертификат выпускается для текущего пайщика (req.user.username).
*/
@Controller('coop/certificate')
@UseFilters(AuthV2ExceptionFilter)
export class CertificateController {
constructor(private readonly certificateService: CertificateService) {}
@Get()
@UseGuards(HttpJwtAuthGuard)
async getCertificate(@Req() req: AuthedRequest): Promise<{ participant_certificate: string }> {
const username = req.user?.username;
if (!username) throw new UnauthorizedException();
// AuthV2Error пробрасывается контурному AuthV2ExceptionFilter (Story 1.11).
const participant_certificate = await this.certificateService.issueForUsername(username);
return { participant_certificate };
}
}
@@ -0,0 +1,114 @@
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
import { decodeProtectedHeader, jwtVerify } from 'jose';
import config from '~/config/config';
import { AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
import { UserCertificateDomainService } from '~/domain/user/services/user-certificate-domain.service';
import { CertificateService } from './certificate.service';
const COOPNAME = config.coopname;
const VOSTOK_KEY = 'PUB_K1_vostokcertkey00000000000000000000000000000000000';
const CHAIN: Record<string, string> = {
ano: 'PUB_K1_anocertkey0000000000000000000000000000000000000000',
voskhod: 'PUB_K1_voskhodcertkey0000000000000000000000000000000000',
vostok: VOSTOK_KEY,
};
let pubKey: KeyObject;
let pemPriv: string;
beforeAll(() => {
const kp = generateKeyPairSync('ec', { namedCurve: 'secp256k1' });
pemPriv = kp.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
pubKey = kp.publicKey;
});
function makeService(opts: {
certKey?: string;
identification?: any;
certKeyFor?: (acc: string) => string | null;
} = {}) {
jest.spyOn(config.authV2, 'certKey', 'get').mockReturnValue(opts.certKey ?? pemPriv);
const blockchain = {
getCertPublicKey: jest.fn(async (acc: string) => (opts.certKeyFor ? opts.certKeyFor(acc) : CHAIN[acc])),
};
const user = { getUserByUsername: jest.fn().mockResolvedValue({ id: 'uuid-participant-1', username: 'ant' }) };
const account = {
getPrivateAccount: jest.fn().mockResolvedValue(
'identification' in opts
? opts.identification
: { username: 'ant', first_name: 'Иван', last_name: 'Петров', middle_name: 'Сергеевич' },
),
};
const certDomain = new UserCertificateDomainService();
const service = new CertificateService(blockchain as any, user as any, account as any, certDomain as any);
return { service, blockchain, user, account };
}
afterEach(() => jest.restoreAllMocks());
describe('CertificateService.issueForUsername', () => {
it('выпускает compact JWS ES256K, верифицируемый ключом подписи, со всеми обязательными claims', async () => {
const { service } = makeService();
const jws = await service.issueForUsername('ant');
expect(jws.split('.')).toHaveLength(3);
const header = decodeProtectedHeader(jws);
expect(header.alg).toBe('ES256K');
expect(header.kid).toBe(VOSTOK_KEY);
const { payload } = await jwtVerify(jws, pubKey, { algorithms: ['ES256K'] });
expect(payload.iss).toBe(`https://${COOPNAME}.coop`);
expect(payload.sub).toBe('uuid-participant-1');
expect(payload.jti).toMatch(/^[0-9a-f-]{36}$/); // серийный номер (UUID)
expect(payload.coopname).toBe(COOPNAME);
expect(payload.iat).toBeDefined();
expect(payload.exp).toBeGreaterThan(payload.iat as number);
expect(payload.claim_schema_version).toBe('1');
expect(payload.verification_types).toEqual(['coop_baseline']);
expect(payload.coop_chain).toEqual([
{ account: 'ano', public_key: CHAIN.ano },
{ account: 'voskhod', public_key: CHAIN.voskhod },
{ account: 'vostok', public_key: VOSTOK_KEY },
]);
expect(payload.identification).toMatchObject({ type: 'individual', username: 'ant', first_name: 'Иван' });
});
it('размер JWS ≤ 5 КБ (Vision/MIFARE)', async () => {
const { service } = makeService();
const jws = await service.issueForUsername('ant');
expect(Buffer.byteLength(jws, 'utf8')).toBeLessThanOrEqual(5 * 1024);
});
it('identification = null при отсутствии приватных данных аккаунта', async () => {
const { service } = makeService({ identification: null });
const jws = await service.issueForUsername('ant');
const { payload } = await jwtVerify(jws, pubKey, { algorithms: ['ES256K'] });
expect(payload.identification).toBeNull();
});
it('маппит organization identification из доменных данных', async () => {
const org = {
username: 'cooporg',
short_name: 'ООО Ромашка',
full_name: 'Общество с ограниченной ответственностью «Ромашка»',
details: { inn: '7700000000', ogrn: '1027700000000' },
represented_by: { first_name: 'Анна', last_name: 'Иванова', middle_name: 'П', position: 'Директор' },
};
const { service } = makeService({ identification: org });
const { payload } = await jwtVerify(await service.issueForUsername('cooporg'), pubKey, { algorithms: ['ES256K'] });
expect(payload.identification).toMatchObject({ type: 'organization', inn: '7700000000', ogrn: '1027700000000' });
});
it('нет cert-ключа в конфиге → ошибка конфигурации', async () => {
const { service } = makeService({ certKey: '' });
await expect(service.issueForUsername('ant')).rejects.toThrow(/COOP_CERT_KEY/);
});
it('разрыв cert-цепи (нет ключа звена) → ChainVerificationFailed', async () => {
const { service } = makeService({ certKeyFor: (acc) => (acc === 'voskhod' ? null : CHAIN[acc]) });
await expect(service.issueForUsername('ant')).rejects.toMatchObject({
code: AuthV2ErrorCode.ChainVerificationFailed,
});
});
});
@@ -0,0 +1,119 @@
import { createPrivateKey, randomUUID, type KeyObject } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { SignJWT } from 'jose';
import config from '~/config/config';
import { BLOCKCHAIN_PORT } from '~/domain/common/ports/blockchain.port';
import type { BlockchainPort } from '~/domain/common/ports/blockchain.port';
import { USER_DOMAIN_SERVICE } from '~/domain/user/services/user-domain.service';
import type { UserDomainService } from '~/domain/user/services/user-domain.service';
import { ACCOUNT_DOMAIN_SERVICE } from '~/domain/account/services/account-domain.service';
import type { AccountDomainService } from '~/domain/account/services/account-domain.service';
import { USER_CERTIFICATE_DOMAIN_SERVICE } from '~/domain/user/services/user-certificate-domain.service';
import type { UserCertificateDomainService } from '~/domain/user/services/user-certificate-domain.service';
import type { UserCertificateDomainInterface } from '~/domain/user/interfaces/user-certificate-domain.interface';
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
/** Звенья cert-цепи доверия CoopID (миграция 052): ano → voskhod → vostok. */
const CERT_CHAIN_ACCOUNTS = ['ano', 'voskhod', 'vostok'] as const;
/** Базовый уровень верификации — присутствует у всех (минимум). */
const VERIFICATION_BASELINE = ['coop_baseline'] as const;
const CLAIM_SCHEMA_VERSION = '1';
/** Срок жизни сертификата. Технический дефолт (24ч) — уточняется продуктовой политикой. */
const CERTIFICATE_TTL = '24h';
/** Лимит размера JWS — Vision/MIFARE DESFire EV3 (AC Story 1.8). */
const MAX_CERT_BYTES = 5 * 1024;
export interface CoopChainLink {
account: string;
public_key: string;
}
/**
* Выпуск participant_certificate (CoopID, Story 1.8): compact JWS alg=ES256K,
* подписанный ключом permission `cert` аккаунта vostok (PEM из Docker Secret
* `coop_cert_key`). Сертификат самодостаточен — несёт цепь доверия `coop_chain`
* [ano,voskhod,vostok] для offline-верификации от вшитого trust anchor (ano).
* Подпись ES256K через jose + Node KeyObject — wharfkit в application не нужен.
*/
@Injectable()
export class CertificateService {
private signingKey: KeyObject | null = null;
private coopChain: CoopChainLink[] | null = null;
constructor(
@Inject(BLOCKCHAIN_PORT) private readonly blockchainPort: BlockchainPort,
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService,
@Inject(ACCOUNT_DOMAIN_SERVICE) private readonly accountDomainService: AccountDomainService,
@Inject(USER_CERTIFICATE_DOMAIN_SERVICE) private readonly certDomainService: UserCertificateDomainService,
) {}
/**
* Выпустить сертификат для пайщика по имени блокчейн-аккаунта (username = sub
* второго этапа auth). `sub` сертификата — UUID пайщика (user.id), не аккаунт.
*/
async issueForUsername(username: string): Promise<string> {
const user = await this.userDomainService.getUserByUsername(username);
const identification = await this.resolveIdentification(username);
const coopname = config.coopname;
const coopChain = await this.getCoopChain();
const vostokKey = coopChain[coopChain.length - 1]?.public_key;
const jws = await new SignJWT({
coopname,
coop_chain: coopChain,
verification_types: [...VERIFICATION_BASELINE],
identification: identification ?? null,
claim_schema_version: CLAIM_SCHEMA_VERSION,
})
.setProtectedHeader({ alg: 'ES256K', typ: 'JWT', kid: vostokKey })
.setIssuer(`https://${coopname}.coop`)
.setSubject(user.id)
.setJti(randomUUID()) // серийный номер удостоверения (ЛК показывает его, Story 1.9)
.setIssuedAt()
.setExpirationTime(CERTIFICATE_TTL)
.sign(this.getSigningKey());
const size = Buffer.byteLength(jws, 'utf8');
if (size > MAX_CERT_BYTES)
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `participant_certificate превышает ${MAX_CERT_BYTES} байт (${size})`);
return jws;
}
private async resolveIdentification(username: string): Promise<UserCertificateDomainInterface | null> {
const account = await this.accountDomainService.getPrivateAccount(username);
return this.certDomainService.createCertificateFromUserData(account);
}
/** PEM cert-ключа → Node KeyObject (ES256K). Кэшируется на жизнь процесса. */
private getSigningKey(): KeyObject {
if (this.signingKey) return this.signingKey;
const pem = config.authV2.certKey;
if (!pem)
throw new Error('COOP_CERT_KEY(_FILE) не сконфигурирован: невозможно подписать participant_certificate');
this.signingKey = createPrivateKey(pem);
return this.signingKey;
}
/**
* Цепь cert-ключей [ano,voskhod,vostok] из COOPOS. Кэшируется (ключи cert
* меняются крайне редко; ротация — рестарт/инвалидция в будущих историях).
*/
private async getCoopChain(): Promise<CoopChainLink[]> {
if (this.coopChain) return this.coopChain;
const links: CoopChainLink[] = [];
for (const account of CERT_CHAIN_ACCOUNTS) {
let key: string | null;
try {
key = await this.blockchainPort.getCertPublicKey(account);
} catch {
throw new AuthV2Error(AuthV2ErrorCode.CooposDegraded, `COOPOS недоступен: cert-ключ ${account} не получен`);
}
if (!key)
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `cert-permission аккаунта ${account} отсутствует или не single-key`);
links.push({ account, public_key: key });
}
this.coopChain = links;
return links;
}
}
@@ -0,0 +1,53 @@
import type { ArgumentsHost } from '@nestjs/common';
import { HttpStatus } from '@nestjs/common';
import { AuthV2Error, AuthV2ErrorCode, VaultServerDecryptionForbiddenError } from '~/domain/auth-v2/errors/auth-v2.error';
import { AuthV2ExceptionFilter } from './auth-v2-exception.filter';
function mockHost(): { host: ArgumentsHost; res: { status: jest.Mock; json: jest.Mock } } {
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
const host = {
switchToHttp: () => ({ getResponse: () => res }),
} as unknown as ArgumentsHost;
return { host, res };
}
describe('AuthV2ExceptionFilter (Story 1.11)', () => {
const filter = new AuthV2ExceptionFilter();
it.each([
[AuthV2ErrorCode.CooposDegraded, HttpStatus.SERVICE_UNAVAILABLE],
[AuthV2ErrorCode.VaultServerDecryptionForbidden, HttpStatus.FORBIDDEN],
[AuthV2ErrorCode.InvalidCredentials, HttpStatus.BAD_REQUEST],
[AuthV2ErrorCode.VaultDecryptionFailed, HttpStatus.BAD_REQUEST],
[AuthV2ErrorCode.TimestampTooOld, HttpStatus.UNAUTHORIZED],
[AuthV2ErrorCode.SessionBindingReused, HttpStatus.UNAUTHORIZED],
[AuthV2ErrorCode.SessionBindingExpired, HttpStatus.UNAUTHORIZED],
[AuthV2ErrorCode.ChainVerificationFailed, HttpStatus.UNAUTHORIZED],
[AuthV2ErrorCode.TooManyAttempts, HttpStatus.TOO_MANY_REQUESTS],
])('маппит %s → HTTP %d', (code, status) => {
const { host, res } = mockHost();
filter.catch(new AuthV2Error(code, 'описание'), host);
expect(res.status).toHaveBeenCalledWith(status);
});
it('тело ответа — формат OAuth 2.0 { error, error_description }', () => {
const { host, res } = mockHost();
filter.catch(new AuthV2Error(AuthV2ErrorCode.TimestampTooOld, 'Метка времени вне окна'), host);
expect(res.json).toHaveBeenCalledWith({
error: AuthV2ErrorCode.TimestampTooOld,
error_description: 'Метка времени вне окна',
});
});
it('подкласс VaultServerDecryptionForbiddenError маппится в 403', () => {
const { host, res } = mockHost();
filter.catch(new VaultServerDecryptionForbiddenError(), host);
expect(res.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: AuthV2ErrorCode.VaultServerDecryptionForbidden }),
);
});
});
@@ -0,0 +1,46 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import type { Response } from 'express';
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
/**
* Единый маппинг типизированной ошибки контура auth-v2 (CoopID) в HTTP-статус.
* Тело ответа — формат OAuth 2.0 `{ error, error_description }` (см. AuthV2Error).
* Зеркало кодов держит SDK `@coopenomics/auth` (errors.ts) — клиент по `error`
* выбирает человеко-читаемое сообщение и actionable-подсказку.
*
* До Story 1.11 маппинг дублировался inline в каждом контроллере (`toHttp`).
* Здесь он сведён в один источник: контроллеры контура просто пробрасывают
* AuthV2Error, фильтр (`@Catch(AuthV2Error)`) единообразно переводит его в HTTP.
*
* Подключается через controller-scoped `@UseFilters` (НЕ APP_FILTER): в приложении
* есть глобальный catch-all `GraphQLExceptionFilter` (useGlobalFilters), который
* перехватывает AuthV2Error раньше любого APP_FILTER и переформатирует ответ в
* `{statusCode, message, error}` со статусом 500. Controller-scoped фильтр имеет
* приоритет над глобальным и пишет OAuth2-тело напрямую в response — глобальный
* фильтр для этой ошибки уже не вызывается (проверено живьём, Story 1.11).
*/
const STATUS_BY_CODE: Record<AuthV2ErrorCode, number> = {
// 503: блокчейн/инфраструктура временно недоступна — клиенту «повторить позже».
[AuthV2ErrorCode.CooposDegraded]: HttpStatus.SERVICE_UNAVAILABLE,
// 429: сработал rate-limit контура входа (Story 9.1) — слишком много попыток.
[AuthV2ErrorCode.TooManyAttempts]: HttpStatus.TOO_MANY_REQUESTS,
// 403: серверная расшифровка ключа запрещена инвариантом (не «не авторизован»).
[AuthV2ErrorCode.VaultServerDecryptionForbidden]: HttpStatus.FORBIDDEN,
// 400: некорректный ввод/данные клиента (AC Story 1.11 — invalid_credentials → 400).
[AuthV2ErrorCode.InvalidCredentials]: HttpStatus.BAD_REQUEST,
[AuthV2ErrorCode.VaultDecryptionFailed]: HttpStatus.BAD_REQUEST,
// 401: провал второго этапа аутентификации (владение ключом не доказано).
[AuthV2ErrorCode.TimestampTooOld]: HttpStatus.UNAUTHORIZED,
[AuthV2ErrorCode.SessionBindingReused]: HttpStatus.UNAUTHORIZED,
[AuthV2ErrorCode.SessionBindingExpired]: HttpStatus.UNAUTHORIZED,
[AuthV2ErrorCode.ChainVerificationFailed]: HttpStatus.UNAUTHORIZED,
};
@Catch(AuthV2Error)
export class AuthV2ExceptionFilter implements ExceptionFilter<AuthV2Error> {
catch(exception: AuthV2Error, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
const status = STATUS_BY_CODE[exception.code] ?? HttpStatus.UNAUTHORIZED;
res.status(status).json(exception.toResponse());
}
}
@@ -0,0 +1,33 @@
import { Body, Controller, HttpCode, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { LogoutService } from './logout.service';
const BINDING_COOKIE_NAME = 'coop_session_binding';
interface LogoutBody {
refresh_token?: string;
access_token?: string;
}
/**
* RP-initiated logout (Story 1.10): закрывает сессию пайщика в этом браузере —
* отзывает refresh/access токены, защитно гасит cookie session_binding и пишет
* audit_events. Без guard и идемпотентен: выйти можно и с истёкшим access-токеном,
* повторный вызов не ошибка. OIDC `end_session_endpoint` с `id_token_hint` — Story 5.1.
*/
@Controller('coop/logout')
export class LogoutController {
constructor(private readonly logoutService: LogoutService) {}
@Post()
@HttpCode(204)
async logout(@Body() body: LogoutBody, @Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
await this.logoutService.logout({
refreshToken: body?.refresh_token ?? null,
accessToken: body?.access_token ?? null,
ip: req.ip ?? null,
});
res.clearCookie(BINDING_COOKIE_NAME, { httpOnly: true, secure: true, sameSite: 'strict', path: '/' });
}
}
@@ -0,0 +1,73 @@
import { LogoutService } from './logout.service';
import { tokenTypes } from '~/types/token.types';
describe('LogoutService (Story 1.10)', () => {
function setup() {
const tokens = {
findOneAndDelete: jest.fn(),
};
const audit = {
record: jest.fn().mockResolvedValue(undefined),
};
const service = new LogoutService(tokens as never, audit as never);
return { service, tokens, audit };
}
it('отзывает refresh-токен и возвращает userId как субъект', async () => {
const { service, tokens } = setup();
tokens.findOneAndDelete.mockResolvedValueOnce({ userId: 'user-1', type: tokenTypes.REFRESH });
const res = await service.logout({ refreshToken: 'r1', ip: '1.2.3.4' });
expect(tokens.findOneAndDelete).toHaveBeenCalledWith('r1', tokenTypes.REFRESH);
expect(res.subjectId).toBe('user-1');
});
it('отзывает и access-токен, если передан', async () => {
const { service, tokens } = setup();
tokens.findOneAndDelete.mockResolvedValue({ userId: 'user-1' });
await service.logout({ refreshToken: 'r1', accessToken: 'a1' });
expect(tokens.findOneAndDelete).toHaveBeenCalledWith('r1', tokenTypes.REFRESH);
expect(tokens.findOneAndDelete).toHaveBeenCalledWith('a1', tokenTypes.ACCESS);
});
it('идемпотентен: токена нет в БД → subjectId null, без ошибки', async () => {
const { service, tokens } = setup();
tokens.findOneAndDelete.mockResolvedValue(null);
const res = await service.logout({ refreshToken: 'gone' });
expect(res.subjectId).toBeNull();
});
it('пустой вызов (нет токенов) не дёргает отзыв, всё равно пишет аудит', async () => {
const { service, tokens, audit } = setup();
const res = await service.logout({});
expect(tokens.findOneAndDelete).not.toHaveBeenCalled();
expect(res.subjectId).toBeNull();
expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ event: 'coopid.logout', result: 'success' }));
});
it('пишет audit coopid.logout с субъектом из отозванного токена', async () => {
const { service, audit, tokens } = setup();
tokens.findOneAndDelete.mockResolvedValueOnce({ userId: 'user-7' });
await service.logout({ refreshToken: 'r1', ip: '9.9.9.9' });
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ event: 'coopid.logout', subjectId: 'user-7', actor: 'user-7', result: 'success', ip: '9.9.9.9' }),
);
});
it('сбой аудита не валит logout (safeAudit проглатывает)', async () => {
const { service, audit, tokens } = setup();
tokens.findOneAndDelete.mockResolvedValueOnce({ userId: 'user-1' });
audit.record.mockRejectedValueOnce(new Error('coop-postgres down'));
await expect(service.logout({ refreshToken: 'r1' })).resolves.toEqual({ subjectId: 'user-1' });
});
});
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { TokenApplicationService } from '~/application/token/services/token-application.service';
import { tokenTypes } from '~/types/token.types';
import { AuditService } from '../audit/audit.service';
export interface LogoutInput {
refreshToken?: string | null;
accessToken?: string | null;
ip?: string | null;
}
export interface LogoutResult {
/** userId отозванного токена (для лога/контекста); null, если токен уже отсутствовал. */
subjectId: string | null;
}
/**
* RP-initiated logout (Story 1.10): отзыв refresh/access токенов текущей сессии.
* Толерантен — не требует валидного access-токена (выйти можно и с истёкшим);
* идемпотентен — повторный/пустой вызов не ошибка. Субъект для аудита берётся из
* `userId` отозванного refresh-токена. Стандартный OIDC end-session с `id_token_hint`
* придёт в Story 5.1; здесь — отзыв токенов платформенным механизмом, как legacy-logout.
*/
@Injectable()
export class LogoutService {
constructor(
private readonly tokens: TokenApplicationService,
private readonly audit: AuditService,
) {}
async logout(input: LogoutInput): Promise<LogoutResult> {
let subjectId: string | null = null;
if (input.refreshToken) {
const revoked = await this.tokens.findOneAndDelete(input.refreshToken, tokenTypes.REFRESH);
subjectId = revoked?.userId ?? null;
}
if (input.accessToken) {
await this.tokens.findOneAndDelete(input.accessToken, tokenTypes.ACCESS);
}
await this.safeAudit({ event: 'coopid.logout', subjectId, actor: subjectId, result: 'success', ip: input.ip });
return { subjectId };
}
/** Аудит не должен валить ответ logout (coop_domain_db недоступен → degraded-лог, не 500). */
private async safeAudit(record: Parameters<AuditService['record']>[0]): Promise<void> {
try {
await this.audit.record(record);
} catch {
// намеренно проглатываем: audit-инфраструктура отдельна от auth-критпути
}
}
}
@@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
import { AUTH_RATE_LIMIT_METADATA, type AuthRateLimitConfig } from './auth-rate-limit.types';
/**
* Навесить двухключевой rate-limit (Story 9.1) на endpoint контура auth-v2.
* Считывается `AuthRateLimitGuard`. Пример:
* `@AuthRateLimit({ ip: LOGIN_IP_RULE, account: { ...LOGIN_ACCOUNT_RULE, key: r => r.params.subject_id } })`
*/
export const AuthRateLimit = (config: AuthRateLimitConfig) => SetMetadata(AUTH_RATE_LIMIT_METADATA, config);
@@ -0,0 +1,93 @@
import type { ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { ThrottlerStorage } from '@nestjs/throttler';
import type { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface';
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
import { AuthRateLimitGuard } from './auth-rate-limit.guard';
import { LOGIN_ACCOUNT_RULE, LOGIN_IP_RULE, type AuthRateLimitConfig } from './auth-rate-limit.types';
function record(totalHits: number, isBlocked = false): ThrottlerStorageRecord {
return { totalHits, timeToExpire: 1000, isBlocked, timeToBlockExpire: isBlocked ? 1000 : 0 };
}
function makeContext(config: AuthRateLimitConfig | undefined, req: { ip?: string; params?: Record<string, string> }): ExecutionContext {
return {
getHandler: () => () => undefined,
getClass: () => class {},
switchToHttp: () => ({ getRequest: () => req }),
} as unknown as ExecutionContext;
}
function makeGuard(config: AuthRateLimitConfig | undefined, increment: jest.Mock) {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(config) } as unknown as Reflector;
const storage = { increment } as unknown as ThrottlerStorage;
return { guard: new AuthRateLimitGuard(reflector, storage), increment };
}
const TWO_KEY: AuthRateLimitConfig = {
ip: LOGIN_IP_RULE,
account: { ...LOGIN_ACCOUNT_RULE, key: (req) => req.params?.subject_id },
};
describe('AuthRateLimitGuard (Story 9.1)', () => {
it('без @AuthRateLimit-конфига пропускает и не трогает хранилище', async () => {
const { guard, increment } = makeGuard(undefined, jest.fn());
const ctx = makeContext(undefined, { ip: '1.2.3.4' });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(increment).not.toHaveBeenCalled();
});
it('оба ключа под лимитом → пропуск; инкрементированы и ip, и account', async () => {
const increment = jest.fn().mockResolvedValue(record(1));
const { guard } = makeGuard(TWO_KEY, increment);
const ctx = makeContext(TWO_KEY, { ip: '1.2.3.4', params: { subject_id: 'ant' } });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(increment).toHaveBeenCalledTimes(2);
expect(increment).toHaveBeenCalledWith('1.2.3.4', LOGIN_IP_RULE.ttl, LOGIN_IP_RULE.limit, LOGIN_IP_RULE.ttl, 'ip');
expect(increment).toHaveBeenCalledWith('ant', LOGIN_ACCOUNT_RULE.ttl, LOGIN_ACCOUNT_RULE.limit, LOGIN_ACCOUNT_RULE.ttl, 'account');
});
it('превышение IP-лимита → 429 TooManyAttempts', async () => {
const increment = jest.fn().mockResolvedValue(record(LOGIN_IP_RULE.limit + 1));
const { guard } = makeGuard({ ip: LOGIN_IP_RULE }, increment);
const ctx = makeContext({ ip: LOGIN_IP_RULE } as AuthRateLimitConfig, { ip: '1.2.3.4' });
await expect(guard.canActivate(ctx)).rejects.toMatchObject({
code: AuthV2ErrorCode.TooManyAttempts,
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(AuthV2Error);
});
it('превышение account-лимита → 429 (ip под лимитом)', async () => {
const increment = jest.fn().mockImplementation((_tracker, _ttl, _limit, _block, name) =>
Promise.resolve(name === 'account' ? record(LOGIN_ACCOUNT_RULE.limit + 1) : record(1)),
);
const { guard } = makeGuard(TWO_KEY, increment);
const ctx = makeContext(TWO_KEY, { ip: '1.2.3.4', params: { subject_id: 'ant' } });
await expect(guard.canActivate(ctx)).rejects.toMatchObject({ code: AuthV2ErrorCode.TooManyAttempts });
});
it('isBlocked (в окне блока) → 429 даже если totalHits в пределах', async () => {
const increment = jest.fn().mockResolvedValue(record(1, true));
const { guard } = makeGuard({ ip: LOGIN_IP_RULE }, increment);
const ctx = makeContext({ ip: LOGIN_IP_RULE } as AuthRateLimitConfig, { ip: '1.2.3.4' });
await expect(guard.canActivate(ctx)).rejects.toMatchObject({ code: AuthV2ErrorCode.TooManyAttempts });
});
it('account-id не извлекаем → работает только per-IP ключ', async () => {
const increment = jest.fn().mockResolvedValue(record(1));
const { guard } = makeGuard(TWO_KEY, increment);
// params без subject_id → account.key вернёт undefined
const ctx = makeContext(TWO_KEY, { ip: '1.2.3.4', params: {} });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(increment).toHaveBeenCalledTimes(1);
expect(increment).toHaveBeenCalledWith('1.2.3.4', LOGIN_IP_RULE.ttl, LOGIN_IP_RULE.limit, LOGIN_IP_RULE.ttl, 'ip');
});
it('нет req.ip → трекер "unknown" (не падает)', async () => {
const increment = jest.fn().mockResolvedValue(record(1));
const { guard } = makeGuard({ ip: LOGIN_IP_RULE }, increment);
const ctx = makeContext({ ip: LOGIN_IP_RULE } as AuthRateLimitConfig, {});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(increment).toHaveBeenCalledWith('unknown', LOGIN_IP_RULE.ttl, LOGIN_IP_RULE.limit, LOGIN_IP_RULE.ttl, 'ip');
});
});
@@ -0,0 +1,62 @@
import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { ThrottlerStorage } from '@nestjs/throttler';
import type { Request } from 'express';
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
import { RATE_LIMIT_STORAGE } from '~/domain/auth-v2/ports/rate-limit-storage.port';
import { AUTH_RATE_LIMIT_METADATA, type AuthRateLimitConfig, type RateLimitRule } from './auth-rate-limit.types';
/**
* Двухключевой rate-limit контура auth-v2 (CoopID, Story 9.1). На endpoint'ах с
* `@AuthRateLimit(...)` проверяет ДВА независимых счётчика: per-IP и (если
* идентификатор доступен) per-account. Превышение ЛЮБОГО → `AuthV2Error(TooManyAttempts)`,
* который контурный `AuthV2ExceptionFilter` (Story 1.11) отдаёт как `429` в формате OAuth2.
*
* Намеренно НЕ использует глобальный список throttlers `ThrottlerModule.forRoot`
* (он применился бы ко всем guarded-роутам приложения) — guard сам зовёт
* `storage.increment` для своих ключей. Хранилище — Redis (переживает рестарт,
* общее для нескольких инстансов). Блок выставляется на длину окна (`blockDuration = ttl`);
* нарастающий (escalating) lockout — Story 3.12.
*/
@Injectable()
export class AuthRateLimitGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
@Inject(RATE_LIMIT_STORAGE) private readonly storage: ThrottlerStorage,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const config = this.reflector.getAllAndOverride<AuthRateLimitConfig | undefined>(AUTH_RATE_LIMIT_METADATA, [
context.getHandler(),
context.getClass(),
]);
if (!config) return true;
const req = context.switchToHttp().getRequest<Request>();
const ip = req.ip ?? 'unknown';
// Собираем активные ключи: per-IP всегда; per-account — только если извлекаем.
const checks: Array<{ name: string; tracker: string; rule: RateLimitRule }> = [
{ name: 'ip', tracker: ip, rule: config.ip },
];
if (config.account) {
const accountId = config.account.key(req);
if (accountId) checks.push({ name: 'account', tracker: accountId, rule: config.account });
}
let exceeded = false;
// Инкрементируем оба счётчика (обе попытки должны учитываться), затем решаем.
for (const { name, tracker, rule } of checks) {
const record = await this.storage.increment(tracker, rule.ttl, rule.limit, rule.ttl, name);
if (record.isBlocked || record.totalHits > rule.limit) exceeded = true;
}
if (exceeded) {
throw new AuthV2Error(
AuthV2ErrorCode.TooManyAttempts,
'Слишком много попыток. Подождите и повторите позже.',
);
}
return true;
}
}
@@ -0,0 +1,43 @@
import type { Request } from 'express';
/** Окна (мс): см. Story 9.1 AC / NFR10. */
export const RATE_LIMIT_WINDOW_15M = 15 * 60 * 1000;
export const RATE_LIMIT_WINDOW_1H = 60 * 60 * 1000;
/** Одно правило лимита: не более `limit` обращений за окно `ttl` (мс). */
export interface RateLimitRule {
limit: number;
/** окно в миллисекундах */
ttl: number;
}
/**
* Извлечь идентификатор аккаунта для per-account ключа из запроса. Возвращает
* `null|undefined`, если идентификатор недоступен ДО хендлера (тогда account-ключ
* пропускается, работает только per-IP) — например, на session/bind, где username
* резолвится из cookie authentik уже внутри обработчика.
*/
export type AccountKeyExtractor = (req: Request) => string | null | undefined;
/** Конфиг двухключевого rate-limit для endpoint'а: всегда per-IP, опционально per-account. */
export interface AuthRateLimitConfig {
ip: RateLimitRule;
account?: RateLimitRule & { key: AccountKeyExtractor };
}
/** Ключ метаданных, под которым `@AuthRateLimit` кладёт конфиг для guard'а. */
export const AUTH_RATE_LIMIT_METADATA = 'auth-v2:rate-limit';
// --- Пресеты лимитов (Story 9.1 AC) ---
/** Per-IP для login-flow: 50 обращений / 15 мин. */
export const LOGIN_IP_RULE: RateLimitRule = { limit: 50, ttl: RATE_LIMIT_WINDOW_15M };
/** Per-account для login-flow: 5 обращений / 15 мин. */
export const LOGIN_ACCOUNT_RULE: RateLimitRule = { limit: 5, ttl: RATE_LIMIT_WINDOW_15M };
/**
* Magic-link (NFR10): 3 / час по обоим ключам. Endpoint magic-link — Story 3.1
* (ещё нет); пресет готов и навешивается на него при появлении.
*/
export const MAGIC_LINK_RULE: RateLimitRule = { limit: 3, ttl: RATE_LIMIT_WINDOW_1H };
@@ -0,0 +1,62 @@
import {
Controller,
HttpCode,
Inject,
Post,
Req,
Res,
ServiceUnavailableException,
UnauthorizedException,
UseFilters,
UseGuards,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { AUTHN_SESSION_PORT } from '~/domain/auth-v2/ports/authn-session.port';
import type { IAuthnSessionPort } from '~/domain/auth-v2/ports/authn-session.port';
import { AuthV2ExceptionFilter } from '../exceptions/auth-v2-exception.filter';
import { AuthRateLimit } from '../rate-limit/auth-rate-limit.decorator';
import { AuthRateLimitGuard } from '../rate-limit/auth-rate-limit.guard';
import { LOGIN_IP_RULE } from '../rate-limit/auth-rate-limit.types';
import { SessionBindingService } from './session-binding.service';
/**
* Этап-мост двухэтапной аутентификации (CoopID, Story 1.6, вариант B).
* Вызывается фронтом сразу после прохождения authentik-password на общем домене:
* сессионная cookie authentik доезжает сюда, controller выпускает
* session_binding_token в HTTP-only secure cookie.
*/
@Controller('coop/session')
export class SessionBindingController {
constructor(
@Inject(AUTHN_SESSION_PORT) private readonly authnSession: IAuthnSessionPort,
private readonly binding: SessionBindingService,
) {}
@Post('bind')
@HttpCode(204)
@UseFilters(AuthV2ExceptionFilter)
@UseGuards(AuthRateLimitGuard)
// per-IP only: username резолвится из cookie authentik уже внутри хендлера,
// в guard'е (до обработчика) аккаунт неизвестен — per-account ключ не навешиваем.
@AuthRateLimit({ ip: LOGIN_IP_RULE })
async bind(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
const cookieHeader = req.headers.cookie ?? '';
let username: string | null;
try {
username = await this.authnSession.resolveUsername(cookieHeader);
} catch {
throw new ServiceUnavailableException('authentik unavailable');
}
if (!username) throw new UnauthorizedException();
const issued = await this.binding.issue(username);
res.cookie(issued.cookieName, issued.token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: issued.maxAgeSec * 1000,
path: '/',
});
}
}
@@ -0,0 +1,47 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { SignJWT } from 'jose';
import config from '~/config/config';
import { REDIS_PORT } from '~/domain/common/ports/redis.port';
import type { RedisPort } from '~/domain/common/ports/redis.port';
export const SESSION_BINDING_TTL_SEC = 120;
/** Redis-ключ держим чуть дольше exp токена, чтобы 1.7 мог отвергнуть свежеистёкший по jti, а не «не найти». */
const REDIS_TTL_SEC = SESSION_BINDING_TTL_SEC + 5;
const COOKIE_NAME = 'coop_session_binding';
export interface IssuedBinding {
token: string;
jti: string;
cookieName: string;
maxAgeSec: number;
}
/**
* Выпуск session_binding_token (CoopID, вариант B): мост между этапом password
* (authentik) и этапом timestamp-signature (controller, Story 1.7).
* HS256 + shared secret (Docker Secret); jti кладётся в Redis single-use —
* потребляется в 1.7 (защита от replay).
*/
@Injectable()
export class SessionBindingService {
constructor(@Inject(REDIS_PORT) private readonly redis: RedisPort) {}
async issue(sub: string): Promise<IssuedBinding> {
const secret = config.authV2.sessionBindingSecret;
if (!secret) throw new Error('AUTH_V2_SESSION_BINDING_SECRET не задан');
const jti = randomUUID();
const token = await new SignJWT({ stage_completed: 'password' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(sub)
.setJti(jti)
.setIssuedAt()
.setExpirationTime(`${SESSION_BINDING_TTL_SEC}s`)
.sign(new TextEncoder().encode(secret));
await this.redis.setSingleUse(`coopid:binding:${jti}`, sub, REDIS_TTL_SEC);
return { token, jti, cookieName: COOKIE_NAME, maxAgeSec: SESSION_BINDING_TTL_SEC };
}
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, HttpCode, NotFoundException, Param, Post, UseFilters, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import type { EncryptedVaultBlob, VaultSubjectType } from '~/domain/auth-v2/vault/vault.types';
import { AuthV2ExceptionFilter } from '../exceptions/auth-v2-exception.filter';
import { AuthRateLimit } from '../rate-limit/auth-rate-limit.decorator';
import { AuthRateLimitGuard } from '../rate-limit/auth-rate-limit.guard';
import { LOGIN_ACCOUNT_RULE, LOGIN_IP_RULE } from '../rate-limit/auth-rate-limit.types';
import { VaultService } from './vault.service';
interface StoreVaultDto extends EncryptedVaultBlob {
subject_type: VaultSubjectType;
subject_id: string;
}
/**
* REST-приём зашифрованного блоба от клиента (Story 2.1). Тело уже зашифровано
* на клиенте — сервер только сохраняет. Авторизация bearer/grant — Эпик 6 (CASL);
* на этапе 2.1 эндпоинт принимает blob как есть (write-only, без чтения секретов).
*/
@Controller('coop/vault')
export class VaultController {
constructor(private readonly vault: VaultService) {}
@Post()
@HttpCode(201)
async store(@Body() dto: StoreVaultDto): Promise<void> {
const { subject_type, subject_id, ...blob } = dto;
await this.vault.store({ subject_type, subject_id }, blob);
}
/**
* Отдаёт ТОЛЬКО зашифрованный blob пайщика (Story 2.2): SDK расшифровывает его
* клиентским паролём (type-ban серверной расшифровки из 2.1 в силе — retrieve
* не несёт секрета). Blob криптографически бесполезен без пароля, поэтому на
* этом шаге эндпоинт не гейтится сессией; session-гейтинг придёт с verify-flow
* (Stories 1.7+). 404 — vault'а у субъекта нет.
*/
@Get('participant/:subject_id')
@UseFilters(AuthV2ExceptionFilter)
@UseGuards(AuthRateLimitGuard)
@AuthRateLimit({
ip: LOGIN_IP_RULE,
// per-account ключ — subject_id из URL (цель harvest-энумерации зашифрованных блобов).
account: { ...LOGIN_ACCOUNT_RULE, key: (req: Request) => req.params.subject_id },
})
async retrieveParticipant(@Param('subject_id') subjectId: string): Promise<EncryptedVaultBlob> {
const blob = await this.vault.retrieve({ subject_type: 'participant', subject_id: subjectId });
if (!blob)
throw new NotFoundException('vault не найден');
return blob;
}
}
@@ -0,0 +1,34 @@
import { Inject, Injectable } from '@nestjs/common';
import { VaultServerDecryptionForbiddenError } from '~/domain/auth-v2/errors/auth-v2.error';
import { VAULT_REPOSITORY } from '~/domain/auth-v2/vault/vault-repository.port';
import type { IVaultRepository } from '~/domain/auth-v2/vault/vault-repository.port';
import type { EncryptedVaultBlob, RetrieveVaultInput, VaultSubject } from '~/domain/auth-v2/vault/vault.types';
/**
* Серверная сторона vault (CoopID, Story 2.1). Хранит и отдаёт ТОЛЬКО
* зашифрованный блоб. Расшифровка ключа пайщика на сервере невозможна:
* `RetrieveVaultInput` для participant не несёт секрета (type-ban), а
* `assertNoServerDecrypt` страхует в рантайме.
*/
@Injectable()
export class VaultService {
constructor(@Inject(VAULT_REPOSITORY) private readonly repo: IVaultRepository) {}
async store(subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void> {
await this.repo.upsert(subject, blob);
}
/** Возвращает зашифрованный блоб субъекта (расшифровка — только на клиенте). */
async retrieve(input: RetrieveVaultInput): Promise<EncryptedVaultBlob | null> {
return this.repo.find({ subject_type: input.subject_type, subject_id: input.subject_id });
}
/**
* Намеренно нереализуемый метод-маркер: любая попытка серверной расшифровки
* ключа участника обязана пройти сюда и упасть. Существует, чтобы инвариант
* был виден в коде и тестах, а не только в отсутствии метода.
*/
assertNoServerDecrypt(): never {
throw new VaultServerDecryptionForbiddenError();
}
}
@@ -0,0 +1,71 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
Post,
Req,
UseFilters,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import { AuthV2ExceptionFilter } from '../exceptions/auth-v2-exception.filter';
import { AuthRateLimit } from '../rate-limit/auth-rate-limit.decorator';
import { AuthRateLimitGuard } from '../rate-limit/auth-rate-limit.guard';
import { LOGIN_IP_RULE } from '../rate-limit/auth-rate-limit.types';
import { VerifyTimestampService } from './verify-timestamp.service';
import type { VerifyTimestampResult } from './verify-timestamp.service';
const BINDING_COOKIE_NAME = 'coop_session_binding';
interface VerifyTimestampBody {
signature?: string;
timestamp?: string;
binding_token?: string;
}
/**
* Второй этап двухэтапной аутентификации CoopID (Story 1.7). Принимает подпись
* канонической метки времени (SDK `signTimestamp`, Story 2.4), доказывает владение
* приватным ключом пайщика против COOPOS и завершает вход.
*
* `binding_token` берётся из body (контракт AC) либо, как fallback, из httpOnly
* cookie `coop_session_binding`, которую ставит этап-мост (Story 1.6) — seam между
* cookie-хранением и client-side декодированием claims разрешается на стыке 1.6/2.x.
*/
@Controller('coop/verify')
@UseFilters(AuthV2ExceptionFilter)
export class VerifyTimestampController {
constructor(private readonly verifyService: VerifyTimestampService) {}
@Post('timestamp')
@HttpCode(200)
@UseGuards(AuthRateLimitGuard)
// per-IP: аккаунт зашит в подписанном binding_token, до хендлера не извлекаем.
@AuthRateLimit({ ip: LOGIN_IP_RULE })
async verifyTimestamp(@Body() body: VerifyTimestampBody, @Req() req: Request): Promise<VerifyTimestampResult> {
const signature = body?.signature;
const timestamp = body?.timestamp;
const bindingToken = body?.binding_token ?? this.readBindingCookie(req);
if (!signature || !timestamp || !bindingToken)
throw new BadRequestException('Требуются signature, timestamp и binding_token');
// AuthV2Error из сервиса пробрасывается контурному AuthV2ExceptionFilter
// (Story 1.11) — единый маппинг код→HTTP/OAuth2.
return this.verifyService.verify({
signature,
timestamp,
bindingToken,
ip: req.ip ?? null,
});
}
private readBindingCookie(req: Request): string | undefined {
const fromParser = (req as Request & { cookies?: Record<string, string> }).cookies?.[BINDING_COOKIE_NAME];
if (fromParser) return fromParser;
const header = req.headers.cookie ?? '';
const match = header.split(';').map((c) => c.trim()).find((c) => c.startsWith(`${BINDING_COOKIE_NAME}=`));
return match ? decodeURIComponent(match.slice(BINDING_COOKIE_NAME.length + 1)) : undefined;
}
}
@@ -0,0 +1,187 @@
import { Bytes, PrivateKey, Signature } from '@wharfkit/antelope';
import { SignJWT } from 'jose';
import config from '~/config/config';
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
import { canonicalTimestampMessage, VerifyTimestampService } from './verify-timestamp.service';
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3';
const ACCOUNT = 'ant';
const SECRET = 'test-binding-secret-padding-0000000000';
const PUB = PrivateKey.from(KEY).toPublic().toString();
const TS = '2026-06-10T12:00:00.000Z';
async function makeToken(sub = ACCOUNT, jti = 'jti-1'): Promise<string> {
return new SignJWT({ stage_completed: 'password' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(sub)
.setJti(jti)
.setIssuedAt()
.setExpirationTime('2m')
.sign(new TextEncoder().encode(SECRET));
}
/** Подпись метки клиентским ключом — зеркало SDK signTimestamp (Story 2.4). */
function signCanonical(ts: string, jti: string, sub: string): string {
const message = canonicalTimestampMessage({ ts, binding_token_jti: jti, sub });
return PrivateKey.from(KEY).signMessage(new TextEncoder().encode(message)).toString();
}
function makeService(overrides: {
consume?: jest.Mock;
headBlockTime?: string;
getInfoThrows?: boolean;
hasActiveKey?: boolean;
getAccountThrows?: boolean;
}) {
const redis = {
consumeSingleUse: overrides.consume ?? jest.fn().mockResolvedValue(ACCOUNT),
publish: jest.fn(),
subscribe: jest.fn(),
setSingleUse: jest.fn(),
};
const blockchain = {
getInfo: overrides.getInfoThrows
? jest.fn().mockRejectedValue(new Error('down'))
: jest.fn().mockResolvedValue({ head_block_time: overrides.headBlockTime ?? TS }),
getAccount: overrides.getAccountThrows
? jest.fn().mockRejectedValue(new Error('down'))
: jest.fn().mockResolvedValue({ account_name: ACCOUNT }),
hasActiveKey: jest.fn().mockReturnValue(overrides.hasActiveKey ?? true),
// настоящий recover — зеркало BlockchainService.recoverPublicKey
recoverPublicKey: jest.fn((message: string, signature: string) =>
Signature.from(signature).recoverMessage(Bytes.fromString(message, 'utf8')).toString(),
),
};
const user = { getUserByUsername: jest.fn().mockResolvedValue({ id: 'user-uuid-1' }) };
const tokens = {
generateAuthTokens: jest.fn().mockResolvedValue({
access: { token: 'access-jwt', expires: new Date() },
refresh: { token: 'refresh-jwt', expires: new Date() },
}),
};
const audit = { record: jest.fn().mockResolvedValue(undefined) };
const certificate = { issueForUsername: jest.fn().mockResolvedValue('cert-jws') };
const service = new VerifyTimestampService(
redis as any,
blockchain as any,
user as any,
tokens as any,
audit as any,
certificate as any,
);
return { service, redis, blockchain, user, tokens, audit, certificate };
}
beforeEach(() => {
jest.spyOn(config.authV2, 'sessionBindingSecret', 'get').mockReturnValue(SECRET);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('canonicalTimestampMessage: зеркало SDK (golden-вектор)', () => {
it('фиксированный алфавитный порядок ключей, байт-идентично SDK-тесту', () => {
expect(canonicalTimestampMessage({ ts: 't', binding_token_jti: 'j', sub: 's' })).toBe(
'{"binding_token_jti":"j","sub":"s","ts":"t"}',
);
});
});
describe('VerifyTimestampService.verify', () => {
it('happy path: верная подпись → токены + audit success', async () => {
const { service, redis, tokens, audit } = makeService({ hasActiveKey: true });
const token = await makeToken(ACCOUNT, 'jti-1');
const signature = signCanonical(TS, 'jti-1', ACCOUNT);
const res = await service.verify({ signature, timestamp: TS, bindingToken: token });
expect(res).toEqual({ access_token: 'access-jwt', refresh_token: 'refresh-jwt', participant_certificate: 'cert-jws' });
expect(redis.consumeSingleUse).toHaveBeenCalledWith('coopid:binding:jti-1');
expect(tokens.generateAuthTokens).toHaveBeenCalledWith('user-uuid-1');
expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ result: 'success' }));
});
it('сбой выпуска сертификата best-effort: токены выдаются без participant_certificate', async () => {
const { service, certificate } = makeService({ hasActiveKey: true });
certificate.issueForUsername.mockRejectedValue(new Error('cert key missing'));
const token = await makeToken(ACCOUNT, 'jti-nocert');
const signature = signCanonical(TS, 'jti-nocert', ACCOUNT);
const res = await service.verify({ signature, timestamp: TS, bindingToken: token });
expect(res.access_token).toBe('access-jwt');
expect(res.participant_certificate).toBeUndefined();
});
it('recover round-trip: восстановленный pubkey передан в hasActiveKey', async () => {
const { service, blockchain } = makeService({ hasActiveKey: true });
const token = await makeToken(ACCOUNT, 'jti-rt');
const signature = signCanonical(TS, 'jti-rt', ACCOUNT);
await service.verify({ signature, timestamp: TS, bindingToken: token });
expect(blockchain.hasActiveKey).toHaveBeenCalledWith(expect.anything(), PUB);
});
it('повторный jti (нет в Redis) → SessionBindingReused + audit failure', async () => {
const { service, audit } = makeService({ consume: jest.fn().mockResolvedValue(null) });
const token = await makeToken(ACCOUNT, 'jti-used');
const signature = signCanonical(TS, 'jti-used', ACCOUNT);
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toMatchObject({
code: AuthV2ErrorCode.SessionBindingReused,
});
expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ result: 'failure' }));
});
it('метка вне окна 60s → TimestampTooOld', async () => {
const { service } = makeService({ headBlockTime: '2026-06-10T12:05:00.000Z' });
const token = await makeToken(ACCOUNT, 'jti-stale');
const signature = signCanonical(TS, 'jti-stale', ACCOUNT);
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toMatchObject({
code: AuthV2ErrorCode.TimestampTooOld,
});
});
it('ключ не на аккаунте → ChainVerificationFailed', async () => {
const { service } = makeService({ hasActiveKey: false });
const token = await makeToken(ACCOUNT, 'jti-badkey');
const signature = signCanonical(TS, 'jti-badkey', ACCOUNT);
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toMatchObject({
code: AuthV2ErrorCode.ChainVerificationFailed,
});
});
it('битый binding_token → SessionBindingExpired', async () => {
const { service } = makeService({});
const signature = signCanonical(TS, 'jti-x', ACCOUNT);
await expect(service.verify({ signature, timestamp: TS, bindingToken: 'not-a-jwt' })).rejects.toMatchObject({
code: AuthV2ErrorCode.SessionBindingExpired,
});
});
it('подпись чужого сообщения не восстанавливает ключ аккаунта (integrity)', async () => {
const { service, blockchain } = makeService({ hasActiveKey: true });
const token = await makeToken(ACCOUNT, 'jti-int');
// подпись над ДРУГИМ ts, чем тот, что отправлен в verify
const signature = signCanonical('2026-01-01T00:00:00.000Z', 'jti-int', ACCOUNT);
blockchain.hasActiveKey.mockImplementation((_acc: unknown, key: string) => key === PUB);
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toBeInstanceOf(AuthV2Error);
});
it('COOPOS недоступен (getInfo бросает) → CooposDegraded', async () => {
const { service } = makeService({ getInfoThrows: true });
const token = await makeToken(ACCOUNT, 'jti-down');
const signature = signCanonical(TS, 'jti-down', ACCOUNT);
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toMatchObject({
code: AuthV2ErrorCode.CooposDegraded,
});
});
});
@@ -0,0 +1,150 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { jwtVerify } from 'jose';
import config from '~/config/config';
import { REDIS_PORT } from '~/domain/common/ports/redis.port';
import type { RedisPort } from '~/domain/common/ports/redis.port';
import { BLOCKCHAIN_PORT } from '~/domain/common/ports/blockchain.port';
import type { BlockchainPort } from '~/domain/common/ports/blockchain.port';
import { USER_DOMAIN_SERVICE } from '~/domain/user/services/user-domain.service';
import type { UserDomainService } from '~/domain/user/services/user-domain.service';
import { TokenApplicationService } from '~/application/token/services/token-application.service';
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
import { AuditService } from '../audit/audit.service';
import { CertificateService } from '../certificate/certificate.service';
/** Окно свежести метки времени против head_block_time, сек (epic AC Story 1.7). */
const TIMESTAMP_WINDOW_SEC = 60;
/** Префикс Redis-ключа single-use jti (пишется в Story 1.6 при выпуске binding-токена). */
const BINDING_JTI_PREFIX = 'coopid:binding:';
export interface VerifyTimestampInput {
signature: string;
timestamp: string;
bindingToken: string;
ip?: string | null;
}
export interface VerifyTimestampResult {
access_token: string;
refresh_token: string;
/** participant_certificate (Story 1.8). Best-effort: при сбое выпуска вход не
* ломается — клиент дозапросит через GET /coop/certificate (там ошибка явная). */
participant_certificate?: string;
}
/**
* Каноническое сообщение второго этапа auth — **зеркало**
* `@coopenomics/auth.canonicalTimestampMessage` (Story 2.4). Ключи в фиксированном
* алфавитном порядке: клиент (SDK) и сервер обязаны собрать байт-в-байт идентично,
* иначе `recoverMessage` восстановит чужой pubkey. Зеркалим (а не импортируем ESM
* SDK в CJS-контроллер) по той же конвенции, что `auth-v2.error.ts` зеркалит enum;
* байт-идентичность держит общий golden-вектор в тестах обеих сторон.
*/
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 });
}
/**
* Второй этап двухэтапной аутентификации CoopID (Story 1.7): доказательство
* владения приватным ключом. Проверяет подпись канонической метки времени против
* публичного ключа аккаунта в COOPOS и завершает вход выпуском токенов.
* `id_token`/`participant_certificate` — Story 1.8.
*/
@Injectable()
export class VerifyTimestampService {
private readonly logger = new Logger(VerifyTimestampService.name);
constructor(
@Inject(REDIS_PORT) private readonly redis: RedisPort,
@Inject(BLOCKCHAIN_PORT) private readonly blockchainPort: BlockchainPort,
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService,
private readonly tokens: TokenApplicationService,
private readonly audit: AuditService,
private readonly certificate: CertificateService,
) {}
async verify(input: VerifyTimestampInput): Promise<VerifyTimestampResult> {
// 1. binding_token: подпись (HS256, shared secret 1.6) + exp + sub/jti.
const secret = config.authV2.sessionBindingSecret;
if (!secret) throw new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'AUTH_V2_SESSION_BINDING_SECRET не сконфигурирован');
let sub: string;
let jti: string;
try {
const { payload } = await jwtVerify(input.bindingToken, new TextEncoder().encode(secret));
if (!payload.sub || !payload.jti) throw new Error('нет sub/jti');
sub = payload.sub;
jti = payload.jti;
} catch {
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'session_binding_token недействителен или истёк');
}
// 2. single-use jti: потребляем ДО криптопроверки — токен «сгорает» при первой же
// попытке, нельзя перебирать подписи на одном binding-токене (anti-replay).
const consumed = await this.redis.consumeSingleUse(`${BINDING_JTI_PREFIX}${jti}`);
if (consumed === null) {
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, result: 'failure', context: { reason: 'binding_reused' }, ip: input.ip });
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingReused, 'session_binding_token уже использован');
}
// 3. окно свежести метки против времени блокчейна (±60s).
let headBlockTime: string;
try {
headBlockTime = (await this.blockchainPort.getInfo()).head_block_time;
} catch {
throw new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'COOPOS недоступен: не удалось получить время блокчейна');
}
const skewSec = Math.abs(new Date(headBlockTime).getTime() - new Date(input.timestamp).getTime()) / 1000;
if (!Number.isFinite(skewSec) || skewSec > TIMESTAMP_WINDOW_SEC) {
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, result: 'failure', context: { reason: 'timestamp_window' }, ip: input.ip });
throw new AuthV2Error(AuthV2ErrorCode.TimestampTooOld, 'Метка времени вне допустимого окна свежести');
}
// 4. восстановить pubkey из подписи по тому же каноническому сообщению, что подписал клиент.
let recoveredKey: string;
try {
const message = canonicalTimestampMessage({ ts: input.timestamp, binding_token_jti: jti, sub });
recoveredKey = this.blockchainPort.recoverPublicKey(message, input.signature);
} catch {
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, result: 'failure', context: { reason: 'signature_malformed' }, ip: input.ip });
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Не удалось восстановить ключ из подписи');
}
// 5. сверить восстановленный ключ с активными ключами аккаунта в COOPOS.
let account: Awaited<ReturnType<BlockchainPort['getAccount']>>;
try {
account = await this.blockchainPort.getAccount(sub);
} catch {
throw new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'COOPOS недоступен: не удалось получить аккаунт');
}
if (!account || !this.blockchainPort.hasActiveKey(account, recoveredKey)) {
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, result: 'failure', context: { reason: 'key_mismatch' }, ip: input.ip });
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Подпись не соответствует ключу аккаунта');
}
// успех → выпуск токенов платформенным механизмом (id_token/certificate — Story 1.8).
const user = await this.userDomainService.getUserByUsername(sub);
const pair = await this.tokens.generateAuthTokens(user.id);
// participant_certificate (Story 1.8) — best-effort: сбой выпуска не валит логин.
let participant_certificate: string | undefined;
try {
participant_certificate = await this.certificate.issueForUsername(sub);
} catch (e) {
this.logger.warn(`participant_certificate не выпущен на verify для ${sub}: ${e instanceof Error ? e.message : e}`);
}
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, actor: sub, result: 'success', ip: input.ip });
return { access_token: pair.access.token, refresh_token: pair.refresh.token, participant_certificate };
}
/** Аудит не должен валить успешный вход (coop_domain_db недоступен → degraded-лог, не 500). */
private async safeAudit(record: Parameters<AuditService['record']>[0]): Promise<void> {
try {
await this.audit.record(record);
} catch {
// намеренно проглатываем: audit-инфраструктура отдельна от auth-критпути
}
}
}
@@ -1,6 +1,7 @@
import dotenv from 'dotenv';
import { z } from 'zod';
import path from 'path';
import fs from 'fs';
dotenv.config({ path: path.join(__dirname, '../../.env') });
@@ -119,7 +120,35 @@ const envVarsSchema = z.object({
.transform((val) => parseInt(val, 10)),
REDIS_PASSWORD: z.string(),
BLOCKCHAIN_RPC: z.string().min(1, { message: 'Не должно быть пустым' }),
// CSV-список RPC-endpoint'ов COOPOS (CoopID, Story 1.3): на prod ≥2 для
// failover (механика — Story 9.4). Не задан — используется [BLOCKCHAIN_RPC].
BLOCKCHAIN_RPC_LIST: z
.string()
.optional()
.transform(v => (v ? [...new Set(v.split(',').map(s => s.trim()).filter(Boolean))] : []))
.pipe(z.array(z.string().url({ message: 'Каждый элемент BLOCKCHAIN_RPC_LIST — валидный URL' }))),
CHAIN_ID: z.string().min(1, { message: 'Не должно быть пустым' }),
// coop_domain_db (CoopID, Story 1.4): отдельная БД в общем сервисе postgres.
// Пароль — значением или файлом (*_FILE приоритетнее; путь /run/secrets/... в контейнере).
// Дефолт-порт 5532 = host-маппинг существующего postgres (PG_HOST_PORT) для запуска вне контейнера.
COOP_DOMAIN_DB_HOST: z.string().default('127.0.0.1'),
COOP_DOMAIN_DB_PORT: z.coerce.number().default(5532),
COOP_DOMAIN_DB_USERNAME: z.string().default('coop_app_user'),
COOP_DOMAIN_DB_DATABASE: z.string().default('coop_domain_db'),
COOP_DOMAIN_DB_PASSWORD: z.string().optional(),
COOP_DOMAIN_DB_PASSWORD_FILE: z.string().optional(),
// auth-v2 (CoopID): shared-токен вебхука authentik→controller (Story 1.5).
AUTH_V2_WEBHOOK_TOKEN: z.string().optional(),
AUTH_V2_WEBHOOK_TOKEN_FILE: z.string().optional(),
// auth-v2 (CoopID, Story 1.6): секрет подписи session_binding_token (HS256) + адрес authentik.
AUTH_V2_SESSION_BINDING_SECRET: z.string().optional(),
AUTH_V2_SESSION_BINDING_SECRET_FILE: z.string().optional(),
AUTHENTIK_INTERNAL_URL: z.string().default('http://authentik-server:9000'),
// auth-v2 (CoopID, Story 1.8): PEM приватного ключа (ES256K/secp256k1) permission `cert`
// аккаунта vostok — им подписывается participant_certificate. Тот же ключ дериватится
// в vostok.cert миграцией 052 (on-chain цепь обязана совпасть с ключом подписи).
COOP_CERT_KEY: z.string().optional(),
COOP_CERT_KEY_FILE: z.string().optional(),
/** Задержка (мс) перед get_table_rows после мутации; 0 — отключить */
POST_TRANSACT_CHAIN_READ_DELAY_MS: z
.string()
@@ -219,8 +248,69 @@ export default {
port: envVars.data.PORT,
server_secret: envVars.data.SERVER_SECRET,
timezone: envVars.data.TIMEZONE,
coopDomainDb: {
host: envVars.data.COOP_DOMAIN_DB_HOST,
port: envVars.data.COOP_DOMAIN_DB_PORT,
username: envVars.data.COOP_DOMAIN_DB_USERNAME,
database: envVars.data.COOP_DOMAIN_DB_DATABASE,
// Ленивый геттер: отсутствие секрет-файла бьёт только по потребителям
// coop_domain_db (миграция V2.4.0+), а не по импорту config всем coopback'ом.
get password(): string {
const file = envVars.data!.COOP_DOMAIN_DB_PASSWORD_FILE;
if (file) {
try {
return fs.readFileSync(file, 'utf-8').trim();
} catch (e) {
throw new Error(`COOP_DOMAIN_DB_PASSWORD_FILE недоступен (${file}): ${e instanceof Error ? e.message : e}`);
}
}
return envVars.data!.COOP_DOMAIN_DB_PASSWORD ?? '';
},
},
authV2: {
authentikInternalUrl: envVars.data.AUTHENTIK_INTERNAL_URL,
get webhookToken(): string {
const file = envVars.data!.AUTH_V2_WEBHOOK_TOKEN_FILE;
if (file) {
try {
return fs.readFileSync(file, 'utf-8').trim();
} catch {
return '';
}
}
return envVars.data!.AUTH_V2_WEBHOOK_TOKEN ?? '';
},
get sessionBindingSecret(): string {
const file = envVars.data!.AUTH_V2_SESSION_BINDING_SECRET_FILE;
if (file) {
try {
return fs.readFileSync(file, 'utf-8').trim();
} catch {
return '';
}
}
return envVars.data!.AUTH_V2_SESSION_BINDING_SECRET ?? '';
},
// PEM cert-ключа: многострочный, .trim() убирает только хвостовой перевод строки
// (createPrivateKey терпим к нему). '' при отсутствии — потребитель (CertificateService)
// отдаёт явную ошибку конфигурации, не падая на импорте config.
get certKey(): string {
const file = envVars.data!.COOP_CERT_KEY_FILE;
if (file) {
try {
return fs.readFileSync(file, 'utf-8').trim();
} catch {
return '';
}
}
return envVars.data!.COOP_CERT_KEY ?? '';
},
},
blockchain: {
url: envVars.data.BLOCKCHAIN_RPC,
rpcList: envVars.data.BLOCKCHAIN_RPC_LIST.length
? envVars.data.BLOCKCHAIN_RPC_LIST
: [envVars.data.BLOCKCHAIN_RPC],
id: envVars.data.CHAIN_ID,
root_symbol: envVars.data.ROOT_SYMBOL,
root_govern_symbol: envVars.data.ROOT_GOVERN_SYMBOL,
@@ -0,0 +1,40 @@
/** Зеркало enum'а ошибок SDK @coopenomics/auth (источник контракта един). */
export enum AuthV2ErrorCode {
InvalidCredentials = 'invalid_credentials',
VaultDecryptionFailed = 'vault_decryption_failed',
VaultServerDecryptionForbidden = 'vault_server_decryption_forbidden',
TimestampTooOld = 'timestamp_too_old',
SessionBindingReused = 'session_binding_reused',
SessionBindingExpired = 'session_binding_expired',
ChainVerificationFailed = 'chain_verification_failed',
CooposDegraded = 'coopos_degraded',
TooManyAttempts = 'too_many_attempts',
}
/** Ошибки auth-v2 в формате OAuth 2.0 ({ error, error_description }). */
export class AuthV2Error extends Error {
constructor(
readonly code: AuthV2ErrorCode,
description: string,
) {
super(description);
this.name = 'AuthV2Error';
}
toResponse(): { error: AuthV2ErrorCode; error_description: string } {
return { error: this.code, error_description: this.message };
}
}
/**
* Инвариант CoopID: сервер никогда не расшифровывает ключ пайщика.
* Бросается в рантайме, если код пытается это сделать (страховка к type-ban).
*/
export class VaultServerDecryptionForbiddenError extends AuthV2Error {
constructor() {
super(
AuthV2ErrorCode.VaultServerDecryptionForbidden,
'Серверная расшифровка ключа участника запрещена: расшифровать может только владелец пароля на клиенте',
);
}
}
@@ -0,0 +1,14 @@
export const AUTHN_SESSION_PORT = Symbol('AuthnSessionPort');
/**
* Порт проверки сессии внешнего IdP (authentik) — первый этап аутентификации.
* Реализация ходит к authentik по сети; application/domain знают только интерфейс.
*/
export interface IAuthnSessionPort {
/**
* Возвращает username аутентифицированного пользователя по его сессии IdP,
* либо null если сессия отсутствует/недействительна.
* @throws при сетевой/инфраструктурной недоступности IdP (≠ невалидная сессия).
*/
resolveUsername(sessionCookie: string): Promise<string | null>;
}
@@ -0,0 +1,8 @@
/**
* Порт хранилища счётчиков rate-limit контура auth-v2 (CoopID, Story 9.1).
* Реализация (infrastructure) — поверх Redis: счётчик переживает рестарт и общий
* для нескольких инстансов controller'а. application/guard знают только токен;
* сам контракт — `ThrottlerStorage` из `@nestjs/throttler` (адаптер его implements),
* чтобы переиспользовать проверенную семантику increment/TTL/block.
*/
export const RATE_LIMIT_STORAGE = Symbol('RateLimitStorage');
@@ -0,0 +1,10 @@
import type { EncryptedVaultBlob, VaultSubject } from './vault.types';
export const VAULT_REPOSITORY = Symbol('VaultRepository');
export interface IVaultRepository {
/** Сохранить/обновить блоб по субъекту (upsert по subject_type+subject_id). */
upsert(subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void>;
/** Зашифрованный блоб субъекта или null. */
find(subject: VaultSubject): Promise<EncryptedVaultBlob | null>;
}
@@ -0,0 +1,25 @@
export type VaultSubjectType = 'participant' | 'coop' | 'council_action';
export interface VaultSubject {
subject_type: VaultSubjectType;
subject_id: string;
}
/** Зашифрованный блоб — всё, что сервер хранит и отдаёт (base64url-строки). */
export interface EncryptedVaultBlob {
cipher_version: string;
kdf_version: string;
salt: string;
nonce: string;
ciphertext: string;
auth_tag: string;
}
/**
* Type-driven decryption ban: для `participant` входной тип НЕ содержит
* никакого секрета разблокировки — серверная расшифровка невозможна по типам.
* coop/council_action разблокируются service-account ключом (вне scope 2.1).
*/
export type RetrieveVaultInput =
| { subject_type: 'participant'; subject_id: string }
| { subject_type: 'coop' | 'council_action'; subject_id: string };
@@ -33,6 +33,21 @@ export interface BlockchainPort {
// Authentication related methods
hasActiveKey(account: any, publicKey: string): boolean;
/**
* Восстановить публичный ключ из COOPOS-native recoverable подписи (`SIG_K1_…`)
* над utf8-сообщением. Зеркало клиентского `signMessage` (SDK signTimestamp,
* Story 2.4): сервер собирает то же каноническое сообщение и восстанавливает
* pubkey для сверки с аккаунтом. Крипто инкапсулировано в инфраструктуре —
* application/auth-v2 не импортирует wharfkit напрямую (гексагональный инвариант).
*/
recoverPublicKey(message: string, signature: string): string;
/**
* Публичный ключ permission `cert` аккаунта (нормализованный `PUB_K1_…`) — звено
* cert-цепи доверия CoopID (ano→voskhod→vostok). Требует строго single-key
* (threshold=1, один ключ, без accounts/waits). null — если cert-permission нет
* или он не single-key. Используется при сборке claim `coop_chain` сертификата.
*/
getCertPublicKey(accountName: string): Promise<string | null>;
getCooperative(coopname: string): Promise<any>;
changeKey(data: RegistratorContract.Actions.ChangeKey.IChangeKey): Promise<void>;
@@ -1,6 +1,10 @@
export interface RedisPort {
publish(channel: string, message: any): Promise<void>;
subscribe(channel: string, handler: (message: any) => void): void;
/** SET key value NX EX ttl — true, если ключ записан (не существовал). */
setSingleUse(key: string, value: string, ttlSec: number): Promise<boolean>;
/** Атомарно прочитать и удалить ключ (GETDEL) — single-use consume. */
consumeSingleUse(key: string): Promise<string | null>;
}
export const REDIS_PORT = Symbol('RedisPort');
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AUTHN_SESSION_PORT } from '~/domain/auth-v2/ports/authn-session.port';
import { RATE_LIMIT_STORAGE } from '~/domain/auth-v2/ports/rate-limit-storage.port';
import { VAULT_REPOSITORY } from '~/domain/auth-v2/vault/vault-repository.port';
import { RedisModule } from '~/infrastructure/redis/redis.module';
import { AuthentikSessionAdapter } from './authentik-session.adapter';
import { PostgresVaultRepository } from './postgres-vault.repository';
import { RedisThrottlerStorage } from './redis-throttler.storage';
/** Инфраструктурные адаптеры auth-v2 (CoopID): порт сессии IdP + vault-репозиторий + rate-limit storage. */
@Module({
imports: [RedisModule],
providers: [
{ provide: AUTHN_SESSION_PORT, useClass: AuthentikSessionAdapter },
{ provide: VAULT_REPOSITORY, useClass: PostgresVaultRepository },
{ provide: RATE_LIMIT_STORAGE, useClass: RedisThrottlerStorage },
],
exports: [AUTHN_SESSION_PORT, VAULT_REPOSITORY, RATE_LIMIT_STORAGE],
})
export class AuthV2InfrastructureModule {}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import config from '~/config/config';
import { IAuthnSessionPort } from '~/domain/auth-v2/ports/authn-session.port';
/**
* Адаптер проверки сессии authentik: GET /api/v3/core/users/me/ с проброшенной
* сессионной cookie пайщика. 200 → username; 401/403 → null (невалидная сессия);
* сеть/5xx → throw (отличаем недоступность IdP от «не залогинен»).
*/
@Injectable()
export class AuthentikSessionAdapter implements IAuthnSessionPort {
async resolveUsername(sessionCookie: string): Promise<string | null> {
if (!sessionCookie) return null;
const res = await fetch(`${config.authV2.authentikInternalUrl}/api/v3/core/users/me/`, {
headers: { cookie: sessionCookie, accept: 'application/json' },
});
if (res.status === 401 || res.status === 403) return null;
if (!res.ok) throw new Error(`authentik me-endpoint вернул ${res.status}`);
const data = (await res.json()) as { user?: { username?: string } };
return data?.user?.username ?? null;
}
}
@@ -0,0 +1,101 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { DataSource } from 'typeorm';
import config from '~/config/config';
import { IVaultRepository } from '~/domain/auth-v2/vault/vault-repository.port';
import type { EncryptedVaultBlob, VaultSubject } from '~/domain/auth-v2/vault/vault.types';
/**
* Хранилище vault-блобов в coop_domain_db (таблица `vaults`, создана миграцией
* V2.4.0). Свой DataSource, как AuditService: недоступность coop-postgres бьёт
* только по vault-операциям, не по запуску coopback.
*/
@Injectable()
export class PostgresVaultRepository implements IVaultRepository, OnModuleDestroy {
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 upsert(subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void> {
const ds = await this.getDataSource();
await ds.query(
`INSERT INTO vaults (subject_type, subject_id, cipher_version, kdf_version, salt, nonce, ciphertext, auth_tag, updated_at)
VALUES ($1,$2,$3,$4, decode($5,'base64'), decode($6,'base64'), decode($7,'base64'), decode($8,'base64'), now())
ON CONFLICT (subject_type, subject_id) DO UPDATE SET
cipher_version = EXCLUDED.cipher_version,
kdf_version = EXCLUDED.kdf_version,
salt = EXCLUDED.salt,
nonce = EXCLUDED.nonce,
ciphertext = EXCLUDED.ciphertext,
auth_tag = EXCLUDED.auth_tag,
updated_at = now()`,
[
subject.subject_type,
subject.subject_id,
blob.cipher_version,
blob.kdf_version,
b64UrlToB64(blob.salt),
b64UrlToB64(blob.nonce),
b64UrlToB64(blob.ciphertext),
b64UrlToB64(blob.auth_tag),
],
);
}
async find(subject: VaultSubject): Promise<EncryptedVaultBlob | null> {
const ds = await this.getDataSource();
const rows: any[] = await ds.query(
`SELECT cipher_version, kdf_version,
encode(salt,'base64') AS salt, encode(nonce,'base64') AS nonce,
encode(ciphertext,'base64') AS ciphertext, encode(auth_tag,'base64') AS auth_tag
FROM vaults WHERE subject_type=$1 AND subject_id=$2`,
[subject.subject_type, subject.subject_id],
);
if (!rows.length) return null;
const r = rows[0];
return {
cipher_version: r.cipher_version,
kdf_version: r.kdf_version,
salt: b64ToB64Url(r.salt),
nonce: b64ToB64Url(r.nonce),
ciphertext: b64ToB64Url(r.ciphertext),
auth_tag: b64ToB64Url(r.auth_tag),
};
}
async onModuleDestroy(): Promise<void> {
if (this.ds?.isInitialized) await this.ds.destroy();
}
}
// Postgres decode(...,'base64') требует стандартный алфавит И padding;
// SDK шлёт base64url без padding — конвертируем и добиваем '=' до кратности 4.
function b64UrlToB64(s: string): string {
const std = s.replace(/-/g, '+').replace(/_/g, '/');
const pad = std.length % 4;
return pad ? std + '='.repeat(4 - pad) : std;
}
function b64ToB64Url(s: string): string {
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
@@ -0,0 +1,42 @@
import type Redis from 'ioredis';
import { RedisThrottlerStorage } from './redis-throttler.storage';
function makeStorage(evalImpl: jest.Mock) {
const publisher = { eval: evalImpl } as unknown as Redis;
return new RedisThrottlerStorage({ publisher });
}
describe('RedisThrottlerStorage (Story 9.1)', () => {
it('зовёт Lua eval с префиксованными hit/block ключами и аргументами ttl/limit/block', async () => {
const evalImpl = jest.fn().mockResolvedValue([1, 900000, 0, 0]);
const storage = makeStorage(evalImpl);
await storage.increment('1.2.3.4', 900000, 50, 900000, 'ip');
expect(evalImpl).toHaveBeenCalledTimes(1);
const [script, numKeys, hitKey, blockKey, ttl, limit, block] = evalImpl.mock.calls[0];
expect(typeof script).toBe('string');
expect(numKeys).toBe(2);
expect(hitKey).toBe('coopid:rl:ip:1.2.3.4');
expect(blockKey).toBe('coopid:rl:ip:1.2.3.4:block');
expect([ttl, limit, block]).toEqual([900000, 50, 900000]);
});
it('маппит ответ Lua [totalHits, timeToExpire, isBlocked, timeToBlockExpire] в record', async () => {
const storage = makeStorage(jest.fn().mockResolvedValue([6, 800000, 1, 700000]));
const rec = await storage.increment('ant', 900000, 5, 900000, 'account');
expect(rec).toEqual({
totalHits: 6,
timeToExpire: 800000,
isBlocked: true,
timeToBlockExpire: 700000,
});
});
it('isBlocked=false при флаге 0', async () => {
const storage = makeStorage(jest.fn().mockResolvedValue([3, 900000, 0, 0]));
const rec = await storage.increment('ant', 900000, 5, 900000, 'account');
expect(rec.isBlocked).toBe(false);
expect(rec.totalHits).toBe(3);
});
});
@@ -0,0 +1,76 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ThrottlerStorage } from '@nestjs/throttler';
import type { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface';
import type Redis from 'ioredis';
import { REDIS_PROVIDER } from '~/infrastructure/redis/redis.provider';
/**
* Redis-хранилище счётчиков rate-limit (Story 9.1) — реализация `ThrottlerStorage`
* из `@nestjs/throttler` поверх ioredis (`REDIS_PROVIDER.publisher`). Готового
* redis-storage-пакета в проекте нет; добавлять зависимость ради одной истории
* избыточно — реализуем интерфейс сами.
*
* Инкремент атомарен (Lua): INCR счётчика → если он первый, сразу ставим PEXPIRE
* (без гонки «INCR без TTL» → вечный ключ). При превышении лимита ставится отдельный
* block-ключ с PX(blockDuration) — повторные запросы в окне блока сразу `isBlocked`.
*
* Префикс ключей `coopid:rl:` изолирует счётчики контура от прочих данных Redis.
*/
@Injectable()
export class RedisThrottlerStorage implements ThrottlerStorage {
// KEYS[1] — счётчик попыток, KEYS[2] — block-ключ.
// ARGV: [1] ttl(ms), [2] limit, [3] blockDuration(ms).
private static readonly INCREMENT_LUA = `
local totalHits = redis.call('INCR', KEYS[1])
local timeToExpire = redis.call('PTTL', KEYS[1])
if timeToExpire <= 0 then
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[1]))
timeToExpire = tonumber(ARGV[1])
end
local blocked = redis.call('GET', KEYS[2])
local timeToBlockExpire = 0
if blocked == false then
if totalHits > tonumber(ARGV[2]) then
redis.call('SET', KEYS[2], 1, 'PX', tonumber(ARGV[3]))
blocked = 1
timeToBlockExpire = tonumber(ARGV[3])
end
else
timeToBlockExpire = redis.call('PTTL', KEYS[2])
end
return { totalHits, timeToExpire, blocked ~= false and 1 or 0, timeToBlockExpire }
`;
constructor(
@Inject(REDIS_PROVIDER)
private readonly redis: { publisher: Redis },
) {}
async increment(
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<ThrottlerStorageRecord> {
const hitKey = `coopid:rl:${throttlerName}:${key}`;
const blockKey = `${hitKey}:block`;
const raw = (await this.redis.publisher.eval(
RedisThrottlerStorage.INCREMENT_LUA,
2,
hitKey,
blockKey,
ttl,
limit,
blockDuration,
)) as [number, number, number, number];
const [totalHits, timeToExpire, isBlocked, timeToBlockExpire] = raw;
return {
totalHits: Number(totalHits),
timeToExpire: Number(timeToExpire),
isBlocked: Number(isBlocked) === 1,
timeToBlockExpire: Number(timeToBlockExpire),
};
}
}
@@ -1,6 +1,6 @@
// infrastructure/blockchain/blockchain.service.ts
import { Injectable } from '@nestjs/common';
import { Action, API, APIClient, Name, PrivateKey, PublicKey } from '@wharfkit/antelope';
import { Action, API, APIClient, Bytes, Name, PrivateKey, PublicKey, Signature } from '@wharfkit/antelope';
import { ContractKit, Table } from '@wharfkit/contract';
import { Session, TransactResult } from '@wharfkit/session';
import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey';
@@ -169,6 +169,28 @@ export class BlockchainService implements BlockchainPort {
}
// Authentication related methods
public async getCertPublicKey(accountName: string): Promise<string | null> {
const account = await this.getAccount(accountName);
if (!account) return null;
const accountJson = JSON.parse(JSON.stringify(account));
const certPerm = accountJson.permissions?.find((p: any) => p.perm_name === 'cert');
const auth = certPerm?.required_auth;
// строго single-key cert-permission: один ключ, без делегирования (accounts/waits)
if (!auth || auth.threshold !== 1 || auth.keys?.length !== 1 || auth.accounts?.length || auth.waits?.length)
return null;
try {
return PublicKey.from(auth.keys[0].key).toString();
} catch {
return auth.keys[0].key ?? null;
}
}
public recoverPublicKey(message: string, signature: string): string {
// recoverMessage хэширует utf8-байты (Checksum256) и восстанавливает pubkey —
// байт-в-байт зеркало клиентского PrivateKey.signMessage (SDK signTimestamp).
return Signature.from(signature).recoverMessage(Bytes.fromString(message, 'utf8')).toString();
}
public hasActiveKey(account: BlockchainAccountInterface, publicKey: string): boolean {
// Преобразуем объект аккаунта в обычный JSON для работы со строками (Wharfkit возвращает свои объекты)
const accountJson = JSON.parse(JSON.stringify(account));
@@ -31,6 +31,16 @@ export class RedisService implements OnModuleDestroy, RedisPort {
await this.redisClient.publisher.publish(channel, JSON.stringify(message));
}
async setSingleUse(key: string, value: string, ttlSec: number): Promise<boolean> {
const res = await this.redisClient.publisher.set(key, value, 'EX', ttlSec, 'NX');
return res === 'OK';
}
async consumeSingleUse(key: string): Promise<string | null> {
// GETDEL (Redis ≥6.2) — атомарное чтение+удаление, гарантирует single-use.
return this.redisClient.publisher.getdel(key);
}
subscribe(channel: string, handler: (message: any) => void) {
if (this.redisClient.subscriber.status !== 'ready') {
this.logger.error('Клиент Subscriber Redis не готов');
@@ -0,0 +1,49 @@
import { assertContextHasNoSecrets } from '~/application/auth-v2/audit/audit.service';
import { mapAuthentikEvent } from '~/application/auth-v2/authentik-events.controller';
describe('auth-v2: audit blacklist', () => {
it('пропускает чистый context', () => {
expect(() => assertContextHasNoSecrets({ messages: ['weak'], created: 'x' })).not.toThrow();
});
it.each(['password', 'user_password', 'private_key', 'access_token', 'server_secret', 'signature'])(
'бросает на ключ %s',
(key) => {
expect(() => assertContextHasNoSecrets({ [key]: 'value' })).toThrow(/секрет/);
},
);
it('бросает на секрет во вложенном объекте', () => {
expect(() => assertContextHasNoSecrets({ outer: { inner: { Token: 'x' } } })).toThrow(/outer\.inner\.Token/);
});
});
describe('auth-v2: маппинг webhook authentik', () => {
it('policy_execution + passing=false → WeakPasswordRejected', () => {
const record = mapAuthentikEvent({
event_action: 'policy_execution',
passing: false,
event_user: 'ant',
messages: ['Пароль слишком слабый. Попробуйте длиннее или с большей вариативностью'],
created: '2026-06-10T00:00:00Z',
});
expect(record).toMatchObject({
event: 'WeakPasswordRejected',
subjectId: 'ant',
result: 'failure',
});
expect(record!.context).not.toHaveProperty('password');
});
it('passing=true → null (успешная проверка не аудируется как отказ)', () => {
expect(mapAuthentikEvent({ event_action: 'policy_execution', passing: true })).toBeNull();
});
it('чужое событие → null', () => {
expect(mapAuthentikEvent({ event_action: 'login', passing: false })).toBeNull();
});
it('пустое тело → null без падения', () => {
expect(mapAuthentikEvent({} as never)).toBeNull();
});
});
@@ -0,0 +1,47 @@
import { ServiceUnavailableException, UnauthorizedException } from '@nestjs/common';
import { SessionBindingController } from '~/application/auth-v2/session-binding/session-binding.controller';
import type { IAuthnSessionPort } from '~/domain/auth-v2/ports/authn-session.port';
import type { SessionBindingService } from '~/application/auth-v2/session-binding/session-binding.service';
function makeCtl(port: Partial<IAuthnSessionPort>, binding: Partial<SessionBindingService>) {
return new SessionBindingController(port as IAuthnSessionPort, binding as SessionBindingService);
}
const req = (cookie?: string) => ({ headers: { cookie } }) as any;
const res = () => {
const calls: any[] = [];
return { calls, cookie: (...a: any[]) => calls.push(a) } as any;
};
describe('SessionBindingController.bind', () => {
it('валидная сессия → выпускает токен и ставит HTTP-only secure cookie', async () => {
const issued = { token: 'jwt.value', jti: 'j1', cookieName: 'coop_session_binding', maxAgeSec: 120 };
const ctl = makeCtl(
{ resolveUsername: jest.fn(async () => 'ant') },
{ issue: jest.fn(async () => issued) as any },
);
const r = res();
await ctl.bind(req('authentik_session=abc'), r);
expect(r.calls).toHaveLength(1);
const [name, token, opts] = r.calls[0];
expect(name).toBe('coop_session_binding');
expect(token).toBe('jwt.value');
expect(opts).toMatchObject({ httpOnly: true, secure: true, sameSite: 'strict', maxAge: 120000, path: '/' });
});
it('нет сессии → 401, cookie не ставится', async () => {
const issue = jest.fn();
const ctl = makeCtl({ resolveUsername: jest.fn(async () => null) }, { issue } as any);
const r = res();
await expect(ctl.bind(req(), r)).rejects.toBeInstanceOf(UnauthorizedException);
expect(r.calls).toHaveLength(0);
expect(issue).not.toHaveBeenCalled();
});
it('authentik недоступен (throw порта) → 503', async () => {
const ctl = makeCtl(
{ resolveUsername: jest.fn(async () => { throw new Error('ECONNREFUSED'); }) },
{ issue: jest.fn() } as any,
);
await expect(ctl.bind(req('authentik_session=abc'), res())).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});
@@ -0,0 +1,60 @@
import { jwtVerify } from 'jose';
import { SessionBindingService, SESSION_BINDING_TTL_SEC } from '~/application/auth-v2/session-binding/session-binding.service';
import type { RedisPort } from '~/domain/common/ports/redis.port';
jest.mock('~/config/config', () => ({
__esModule: true,
default: { authV2: { sessionBindingSecret: 'test-binding-secret-min-32-bytes-len!!' } },
}));
function makeRedisMock() {
const store: Record<string, { value: string; ttl: number }> = {};
const redis: RedisPort = {
publish: jest.fn(),
subscribe: jest.fn(),
setSingleUse: jest.fn(async (key, value, ttl) => {
if (store[key]) return false;
store[key] = { value, ttl };
return true;
}),
consumeSingleUse: jest.fn(async (key) => {
const v = store[key]?.value ?? null;
delete store[key];
return v;
}),
};
return { redis, store };
}
describe('SessionBindingService.issue', () => {
const secret = new TextEncoder().encode('test-binding-secret-min-32-bytes-len!!');
it('выпускает JWT HS256 с контрактными claims', async () => {
const { redis } = makeRedisMock();
const svc = new SessionBindingService(redis);
const { token, jti } = await svc.issue('ant');
const { payload, protectedHeader } = await jwtVerify(token, secret);
expect(protectedHeader.alg).toBe('HS256');
expect(payload.sub).toBe('ant');
expect(payload.jti).toBe(jti);
expect(payload.stage_completed).toBe('password');
expect(payload.exp! - payload.iat!).toBe(SESSION_BINDING_TTL_SEC);
});
it('пишет jti→sub в Redis с TTL чуть больше exp', async () => {
const { redis, store } = makeRedisMock();
const svc = new SessionBindingService(redis);
const { jti } = await svc.issue('petrov');
expect(store[`coopid:binding:${jti}`].value).toBe('petrov');
expect(store[`coopid:binding:${jti}`].ttl).toBeGreaterThan(SESSION_BINDING_TTL_SEC);
});
it('каждый вызов — новый jti', async () => {
const { redis } = makeRedisMock();
const svc = new SessionBindingService(redis);
const a = await svc.issue('x');
const b = await svc.issue('x');
expect(a.jti).not.toBe(b.jti);
});
});
@@ -0,0 +1,49 @@
import { VaultService } from '~/application/auth-v2/vault/vault.service';
import { VaultServerDecryptionForbiddenError } from '~/domain/auth-v2/errors/auth-v2.error';
import type { IVaultRepository } from '~/domain/auth-v2/vault/vault-repository.port';
import type { EncryptedVaultBlob } from '~/domain/auth-v2/vault/vault.types';
const BLOB: EncryptedVaultBlob = {
cipher_version: 'aes-256-gcm-v1',
kdf_version: 'argon2id-v1',
salt: 'c2FsdA',
nonce: 'bm9uY2U',
ciphertext: 'Y2lwaGVy',
auth_tag: 'dGFn',
};
function makeRepo() {
const store = new Map<string, EncryptedVaultBlob>();
const repo: IVaultRepository = {
upsert: jest.fn(async (s, b) => void store.set(`${s.subject_type}:${s.subject_id}`, b)),
find: jest.fn(async (s) => store.get(`${s.subject_type}:${s.subject_id}`) ?? null),
};
return { repo, store };
}
describe('VaultService', () => {
it('store → repo.upsert по субъекту', async () => {
const { repo, store } = makeRepo();
const svc = new VaultService(repo);
await svc.store({ subject_type: 'participant', subject_id: 'ant' }, BLOB);
expect(store.get('participant:ant')).toEqual(BLOB);
});
it('retrieve возвращает зашифрованный blob, не расшифровывает', async () => {
const { repo } = makeRepo();
const svc = new VaultService(repo);
await svc.store({ subject_type: 'participant', subject_id: 'ant' }, BLOB);
const got = await svc.retrieve({ subject_type: 'participant', subject_id: 'ant' });
expect(got).toEqual(BLOB);
});
it('retrieve несуществующего → null', async () => {
const { repo } = makeRepo();
expect(await new VaultService(repo).retrieve({ subject_type: 'participant', subject_id: 'nobody' })).toBeNull();
});
it('серверная расшифровка участника запрещена в рантайме', () => {
const { repo } = makeRepo();
expect(() => new VaultService(repo).assertNoServerDecrypt()).toThrow(VaultServerDecryptionForbiddenError);
});
});
@@ -0,0 +1,50 @@
import { sendGET } from 'src/shared/api/axios';
export interface CoopChainLink {
account: string;
public_key: string;
}
/** Claims удостоверения пайщика (payload participant_certificate, Story 1.8). */
export interface ParticipantCertificate {
jti: string;
sub: string;
iat: number;
exp: number;
coopname: string;
coop_chain: CoopChainLink[];
verification_types: string[];
identification: Record<string, unknown> | null;
}
/**
* Локальный декод payload JWS для ОТОБРАЖЕНИЯ в ЛК (подпись здесь не проверяется —
* это делает offline-верификация, Story 4.4). Каноническое чтение claims —
* `@coopenomics/auth.decodeParticipantCertificate`; используем его, когда desktop
* подключит auth-SDK (Эпик 7). UTF-8-декод обязателен — в identification кириллица.
*/
function decodePayload(jws: string): ParticipantCertificate {
const segment = jws.split('.')[1];
if (!segment) throw new Error('Некорректный формат удостоверения');
const b64 = segment.replace(/-/g, '+').replace(/_/g, '/');
const bin = atob(b64);
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
const raw = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;
return {
jti: String(raw.jti ?? ''),
sub: String(raw.sub ?? ''),
iat: Number(raw.iat ?? 0),
exp: Number(raw.exp ?? 0),
coopname: String(raw.coopname ?? ''),
coop_chain: Array.isArray(raw.coop_chain) ? (raw.coop_chain as CoopChainLink[]) : [],
verification_types: Array.isArray(raw.verification_types) ? (raw.verification_types as string[]) : [],
identification: (raw.identification as Record<string, unknown> | null) ?? null,
};
}
/** Запросить и декодировать актуальное удостоверение пайщика (GET /coop/certificate). */
export async function fetchParticipantCertificate(): Promise<ParticipantCertificate | null> {
const res = (await sendGET('/coop/certificate')) as { participant_certificate?: string };
if (!res?.participant_certificate) return null;
return decodePayload(res.participant_certificate);
}
@@ -3,6 +3,46 @@
//- Шапка-удостоверение: ФИО/наименование пайщика + роль в кооперативе.
IdentityPanel(:identity='identity')
//- Криптографическое удостоверение пайщика (CoopID): серийник, срок,
//- статус, цепочка подписей и собранные подтверждения.
BaseCard(title='Удостоверение пайщика')
template(v-if='certificate')
.cert__head
BaseChip(:variant='certStatus.variant') {{ certStatus.label }}
DataRow(label='Серийный номер', :value='certificate.jti', copyable, mono)
DataRow(label='Действует до', :value='formatExp(certificate.exp)')
.cert__block
.cert__block-label Цепочка подписей
.cert__chain
template(v-for='(step, i) in chainSteps', :key='i')
BaseChip(:variant='step.variant') {{ step.label }}
q-icon.cert__chain-arrow(
v-if='i < chainSteps.length - 1',
name='arrow_forward',
size='16px'
)
.cert__block(v-if='verificationLabels.length')
.cert__block-label Подтверждения
.cert__verifications
BaseChip(
v-for='(label, i) in verificationLabels',
:key='i',
variant='info'
) {{ label }}
.cert__actions
BaseButton(variant='secondary', @click='onDownloadQr')
template(#icon-left)
q-icon(name='qr_code', size='18px')
| Скачать как QR
EmptyState(
v-else,
title='Удостоверение недоступно',
body='Войдите в кооператив, чтобы получить криптографическое удостоверение пайщика.'
)
//- Учётная запись: имя аккаунта и публичный ключ — копируемые,
//- моноширинные (это технические идентификаторы блокчейн-аккаунта).
BaseCard(title='Учётная запись')
@@ -117,16 +157,86 @@ import type {
IIndividualData,
IOrganizationData,
} from 'src/shared/lib/types/user/IUserData';
import { computed } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { Notify } from 'quasar';
import { useDisplayName } from 'src/shared/lib/composables/useDisplayName';
import { IdentityPanel } from 'src/shared/ui/domain/IdentityPanel';
import type { Identity } from 'src/shared/ui/domain/IdentityPanel';
import { DataRow } from 'src/shared/ui/domain/DataRow';
import { BaseCard } from 'src/shared/ui/base/BaseCard';
import { BaseChip } from 'src/shared/ui/base/BaseChip';
import type { BaseChipVariant } from 'src/shared/ui/base/BaseChip';
import { BaseButton } from 'src/shared/ui/base/BaseButton';
import { EmptyState } from 'src/shared/ui/base/EmptyState';
import { fetchParticipantCertificate } from '../api';
import type { ParticipantCertificate } from '../api';
const session = useSessionStore();
// ── Криптографическое удостоверение пайщика (CoopID, Story 1.9) ──
const certificate = ref<ParticipantCertificate | null>(null);
onMounted(async () => {
// Best-effort: отсутствие удостоверения (старый контур входа / сбой) не должно
// ломать страницу профиля — карточка просто покажет EmptyState.
try {
certificate.value = await fetchParticipantCertificate();
} catch {
certificate.value = null;
}
});
// Статус по сроку действия (зеркало @coopenomics/auth.certificateStatus).
const EXPIRING_WINDOW_MS = 60 * 60 * 1000;
const certStatus = computed<{ label: string; variant: BaseChipVariant }>(() => {
const exp = (certificate.value?.exp ?? 0) * 1000;
const now = Date.now();
if (!exp || now >= exp) return { label: 'Истекло', variant: 'neg' };
if (exp - now <= EXPIRING_WINDOW_MS) return { label: 'Истекает', variant: 'warn' };
return { label: 'Активно', variant: 'pos' };
});
// Человекочитаемые имена звеньев цепи подписей + сам пайщик в конце.
const CHAIN_LABELS: Record<string, string> = {
ano: 'АНО',
voskhod: 'Восход',
vostok: 'Восток',
};
const chainSteps = computed<{ label: string; variant: BaseChipVariant }[]>(() => {
const links = (certificate.value?.coop_chain ?? []).map((l) => ({
label: CHAIN_LABELS[l.account] ?? l.account,
variant: 'neutral' as BaseChipVariant,
}));
return [...links, { label: 'Вы', variant: 'accent' as BaseChipVariant }];
});
// Описания типов верификации (зеркало @coopenomics/auth.verificationTypeLabel).
const VERIFICATION_LABELS: Record<string, string> = {
coop_baseline: 'Базовое подтверждение кооперативом',
};
const verificationLabels = computed(() =>
(certificate.value?.verification_types ?? []).map((t) => VERIFICATION_LABELS[t] ?? t),
);
const formatExp = (exp: number): string => {
if (!exp) return '';
return new Date(exp * 1000).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Экспорт в QR — функция Vision (SDK exportToQR пока stub); сообщаем мягко.
const onDownloadQr = () => {
Notify.create({
type: 'info',
message: 'Экспорт удостоверения в QR станет доступен в составе приложения Vision.',
});
};
const userType = computed(() => {
return session.privateAccount?.type;
});
@@ -265,6 +375,36 @@ const getRepresentativeName = (representative: any) => {
padding: var(--p-6, 24px);
}
.cert__head {
margin-bottom: var(--p-3, 12px);
}
.cert__block {
margin-top: var(--p-4, 16px);
}
.cert__block-label {
margin-bottom: var(--p-2, 8px);
color: var(--p-ink-3);
font-size: var(--p-fs-sm, 13px);
}
.cert__chain,
.cert__verifications {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--p-2, 8px);
}
.cert__chain-arrow {
color: var(--p-ink-3);
}
.cert__actions {
margin-top: var(--p-4, 16px);
}
@media (max-width: 768px) {
.profile-page {
padding: var(--p-4, 16px);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"local": "049_migrate_phase2_progwallets.ts",
"local": "052_coopid_cert_permissions.ts",
"test": "051_edit_selected_draft.ts",
"prod": "051_edit_selected_draft.ts"
}
@@ -0,0 +1,156 @@
/**
* CoopID Story 1.3: permission `cert` (ES256K/K1, parent `active`) на аккаунтах
* цепочки доверия `ano → voskhod → vostok`. Идемпотентна: существующий аккаунт —
* skip; существующий `cert` с тем же ключом — skip, с другим — updateauth.
*
* Первая миграция на WharfKit (архитектура CoopID переводит блокчейн-IO на
* WharfKit; eosjs-хелпер ../src/eos остаётся для старых миграций).
*
* Ключи `cert` резолвятся в порядке:
* 1. env `<ACCOUNT>_CERT_PUBLIC_KEY` (обязательно для testnet/mainnet);
* 2. для `vostok` — деривация из PEM `infra/coopid/secrets/coop_cert_key`
* (цепочка на chain обязана совпасть с ключом подписи controller'а);
* 3. дев-fallback — стандартный ключ дев-сети (только NODE_ENV=local).
*/
import { createPrivateKey } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import * as path from 'node:path'
import { APIClient, Bytes, KeyType, PrivateKey, PublicKey } from '@wharfkit/antelope'
import { Session } from '@wharfkit/session'
import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey'
import type { Migration } from '../src/migration_interface'
import { getDirname } from '../src/utils/dirname'
const CERT_ACCOUNTS = ['ano', 'voskhod', 'vostok'] as const
const DEV_DEFAULT_PUBLIC_KEY = 'EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV'
function pemToPublicKey(pemPath: string): string {
const pem = readFileSync(pemPath, 'utf-8')
const jwk = createPrivateKey(pem).export({ format: 'jwk' }) as { crv?: string, d?: string }
if (jwk.crv !== 'secp256k1' || !jwk.d)
throw new Error(`${pemPath}: ожидался приватный ключ secp256k1 (ES256K), получено crv=${jwk.crv}`)
// JWK может опускать ведущие нули скаляра — выравниваем до 32 байт,
// иначе получится валидный по форме, но ЧУЖОЙ публичный ключ.
const d = Buffer.from(jwk.d, 'base64url')
if (d.length > 32)
throw new Error(`${pemPath}: скаляр d длиннее 32 байт (${d.length})`)
const raw = Buffer.concat([Buffer.alloc(32 - d.length), d])
return new PrivateKey(KeyType.K1, Bytes.from(raw)).toPublic().toString()
}
function resolveCertKey(account: string, repoRoot: string): string {
const fromEnv = process.env[`${account.toUpperCase()}_CERT_PUBLIC_KEY`]
if (fromEnv)
return fromEnv
if (account === 'vostok') {
const pemPath = path.join(repoRoot, 'infra/coopid/secrets/coop_cert_key')
if (existsSync(pemPath))
return pemToPublicKey(pemPath)
}
const env = process.env.NODE_ENV || 'local'
if (env !== 'local') {
throw new Error(
`Ключ cert для ${account} не задан: для окружения ${env} обязателен env ${account.toUpperCase()}_CERT_PUBLIC_KEY `
+ `(для vostok — либо PEM infra/coopid/secrets/coop_cert_key). Дев-fallback разрешён только в local.`,
)
}
return DEV_DEFAULT_PUBLIC_KEY
}
export class InitialMigration implements Migration {
async run(): Promise<void> {
const endpoint = process.env.EOSIO_ENDPOINT
const privateKey = process.env.EOSIO_PRIVATE_KEY
if (!endpoint || !privateKey)
throw new Error('EOSIO_ENDPOINT и EOSIO_PRIVATE_KEY обязательны (см. local.env)')
const repoRoot = path.join(getDirname(import.meta.url), '../../..')
// Ключи резолвим ДО любых транзакций — fail-fast при отсутствии PEM/env.
const certKeys = Object.fromEntries(
CERT_ACCOUNTS.map(account => [account, resolveCertKey(account, repoRoot)]),
)
const client = new APIClient({ url: endpoint })
const info = await client.v1.chain.get_info()
const chain = { id: String(info.chain_id), url: endpoint }
const walletPlugin = new WalletPluginPrivateKey(privateKey)
const sessionFor = (actor: string, permission = 'active') =>
new Session({ chain, actor, permission, walletPlugin })
for (const account of CERT_ACCOUNTS) {
const exists = await client.v1.chain
.get_account(account)
.then(() => true, () => false)
if (!exists) {
// Создание аккаунтов — ТОЛЬКО dev: на testnet/mainnet аккаунты заводит
// registrator (реестр, referer, eosio.prods), а owner/active на
// общеизвестном дев-ключе означали бы немедленный захват аккаунта.
if ((process.env.NODE_ENV || 'local') !== 'local')
throw new Error(`Аккаунт ${account} не существует: вне local-сети миграция аккаунты не создаёт — заведите через registrator и повторите.`)
await sessionFor('eosio').transact({
actions: [{
account: 'eosio',
name: 'newaccount',
authorization: [{ actor: 'eosio', permission: 'active' }],
data: {
creator: 'eosio',
name: account,
owner: { threshold: 1, keys: [{ key: DEV_DEFAULT_PUBLIC_KEY, weight: 1 }], accounts: [], waits: [] },
active: { threshold: 1, keys: [{ key: DEV_DEFAULT_PUBLIC_KEY, weight: 1 }], accounts: [], waits: [] },
},
}],
})
console.log(`[052] создан аккаунт ${account} (дев-ключи)`)
}
const acc = await client.v1.chain.get_account(account)
const certPerm = acc.permissions.find(p => String(p.perm_name) === 'cert')
const desiredKey = certKeys[account]
const currentKey = certPerm?.required_auth.keys[0]?.key?.toString()
// Skip только при ТОЧНОМ совпадении всей структуры single-key auth:
// дрейф (threshold>1, второй ключ, accounts/waits) чиним updateauth'ом.
const isCleanSingleKey = certPerm
&& Number(certPerm.required_auth.threshold) === 1
&& certPerm.required_auth.keys.length === 1
&& certPerm.required_auth.accounts.length === 0
&& certPerm.required_auth.waits.length === 0
if (isCleanSingleKey && currentKey && samePublicKey(currentKey, desiredKey)) {
console.log(`[052] ${account}@cert уже на месте (${desiredKey}) — skip`)
continue
}
await sessionFor(account).transact({
actions: [{
account: 'eosio',
name: 'updateauth',
authorization: [{ actor: account, permission: 'active' }],
data: {
account,
permission: 'cert',
parent: 'active',
auth: { threshold: 1, keys: [{ key: desiredKey, weight: 1 }], accounts: [], waits: [] },
},
}],
})
console.log(
certPerm
? `[052] ${account}@cert обновлён: ${currentKey}${desiredKey}`
: `[052] ${account}@cert создан (parent active): ${desiredKey}`,
)
}
}
}
/** Сравнение ключей с учётом legacy (EOS...) и нового (PUB_K1_...) форматов. */
function samePublicKey(a: string, b: string): boolean {
try {
return PublicKey.from(a).equals(PublicKey.from(b))
}
catch {
return a === b
}
}
+3
View File
@@ -15,6 +15,9 @@
"license": "ISC",
"dependencies": {
"@coopenomics/factory": "workspace:*",
"@wharfkit/antelope": "^1.1.1",
"@wharfkit/session": "^1.4.0",
"@wharfkit/wallet-plugin-privatekey": "^1.1.0",
"cooptypes": "workspace:*",
"dotenv": "^16.4.5",
"eosjs": "^22.1.0",
+139
View File
@@ -46,8 +46,23 @@ services:
POSTGRES_DB: voskhod
volumes:
- postgres_data:/var/lib/postgresql
# init-скрипт CoopID выполняется ТОЛЬКО при пустом data dir: на свежей
# машине создаёт authentik_db + coop_domain_db с ролевой изоляцией. На уже
# инициализированном томе БД заводятся вручную (см. infra/coopid/README.md).
- ./infra/coopid/postgres/init:/docker-entrypoint-initdb.d:ro
secrets:
# пароли прикладных ролей CoopID — init-скрипт читает их из /run/secrets.
- coop_pg_authentik_password
- coop_pg_app_password
ports:
- "127.0.0.1:${PG_HOST_PORT:-5532}:5432"
healthcheck:
# authentik стартует только после готовности обеих БД CoopID; pg_isready
# их бы не проверил и замаскировал недоинициализированный том.
test: ["CMD-SHELL", "psql -U postgres -d authentik_db -c 'select 1' >/dev/null 2>&1 && psql -U postgres -d coop_domain_db -c 'select 1' >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 12
coopback:
build:
@@ -69,6 +84,14 @@ services:
- "0.0.0.0:${COOPBACK_HOST_PORT:-2998}:2998"
working_dir: /app
command: pnpm --filter @coopenomics/controller run dev
# CoopID: ключ подписи participant_certificate (Stories 1.71.8), vault-ключ
# кооператива, пароль роли coop_domain_db, токены вебхука и session_binding.
secrets:
- coop_cert_key
- coop_vault_key
- coop_pg_app_password
- authentik_webhook_token
- auth_v2_session_binding_secret
cooparser:
build:
@@ -124,7 +147,123 @@ services:
timeout: 3s
retries: 10
# ── CoopID: per-coop OIDC (authentik) ──────────────────────────────────────
# Данные authentik живут отдельной БД authentik_db в общем сервисе postgres
# (роль authentik_user, ролевая изоляция от coop_domain_db). Redis — общий
# monoredis, DB-индекс 1 (controller сидит в индексе 0).
authentik-server:
image: ghcr.io/goauthentik/server:2026.2
restart: unless-stopped
command: server
environment: &authentik-env
AUTHENTIK_SECRET_KEY: file:///run/secrets/authentik_secret_key
AUTHENTIK_POSTGRESQL__HOST: postgres
AUTHENTIK_POSTGRESQL__PORT: 5432
AUTHENTIK_POSTGRESQL__NAME: authentik_db
AUTHENTIK_POSTGRESQL__USER: authentik_user
AUTHENTIK_POSTGRESQL__PASSWORD: file:///run/secrets/coop_pg_authentik_password
AUTHENTIK_REDIS__HOST: monoredis
AUTHENTIK_REDIS__DB: ${AUTHENTIK_REDIS_DB:-1}
AUTHENTIK_BOOTSTRAP_PASSWORD: file:///run/secrets/authentik_bootstrap_password
AUTHENTIK_BOOTSTRAP_TOKEN: file:///run/secrets/authentik_bootstrap_token
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
# Подставляется blueprint'ом coopid-password-policy в headers-mapping вебхука.
COOPID_WEBHOOK_TOKEN: ${COOPID_WEBHOOK_TOKEN:-}
volumes: &authentik-volumes
- ./infra/coopid/authentik/blueprints:/blueprints/custom:ro
secrets: &authentik-secrets
- authentik_secret_key
- authentik_bootstrap_password
- authentik_bootstrap_token
- coop_pg_authentik_password
ports:
- "127.0.0.1:${AUTHENTIK_HOST_PORT:-9008}:9000"
depends_on:
postgres:
condition: service_healthy
monoredis:
condition: service_started
authentik-worker:
image: ghcr.io/goauthentik/server:2026.2
restart: unless-stopped
command: worker
environment: *authentik-env
volumes: *authentik-volumes
secrets: *authentik-secrets
depends_on:
postgres:
condition: service_healthy
monoredis:
condition: service_started
# ── CoopID edge: внешний TLS 1.3 (caddy) + разбор access-логов (crowdsec) ───
caddy:
image: caddy:2.11
restart: unless-stopped
environment:
# localhost → self-signed (внутренний CA caddy); реальный домен → Let's Encrypt.
COOPID_DOMAIN: ${COOPID_DOMAIN:-localhost}
ports:
# Прод с реальным доменом: CADDY_BIND=0.0.0.0, порты 80/443 — иначе ACME
# HTTP-01 challenge недостижим снаружи и Let's Encrypt не выдаст сертификат.
- "${CADDY_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8088}:80"
- "${CADDY_BIND:-127.0.0.1}:${CADDY_HTTPS_PORT:-8443}:443"
volumes:
- ./infra/coopid/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- caddy_logs:/var/log/caddy
depends_on:
- authentik-server
- coopback
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
restart: unless-stopped
environment:
COLLECTIONS: "crowdsecurity/caddy crowdsecurity/http-cve"
volumes:
- ./infra/coopid/crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml:ro
- crowdsec_db:/var/lib/crowdsec/data
- crowdsec_config:/etc/crowdsec
- caddy_logs:/var/log/caddy:ro
depends_on:
- caddy
# Все секреты CoopID — file-based из infra/coopid/secrets/ (каталог в .gitignore).
# ⚠️ Файлов нет → `docker compose up -d` ПАДАЕТ на валидации ("secret not found")
# и не поднимает НИЧЕГО, включая базовый стек. Перед первым запуском на любом
# чекауте/ноде ОБЯЗАТЕЛЬНО: bash scripts/coopid-gen-secrets.sh.
# На prod секреты раскладывает плейбук ~/playbooks. Подробности: infra/coopid/README.md
secrets:
coop_pg_authentik_password:
file: ./infra/coopid/secrets/coop_pg_authentik_password
coop_pg_app_password:
file: ./infra/coopid/secrets/coop_pg_app_password
authentik_secret_key:
file: ./infra/coopid/secrets/authentik_secret_key
authentik_bootstrap_password:
file: ./infra/coopid/secrets/authentik_bootstrap_password
authentik_bootstrap_token:
file: ./infra/coopid/secrets/authentik_bootstrap_token
authentik_webhook_token:
file: ./infra/coopid/secrets/authentik_webhook_token
auth_v2_session_binding_secret:
file: ./infra/coopid/secrets/auth_v2_session_binding_secret
coop_cert_key:
file: ./infra/coopid/secrets/coop_cert_key
coop_vault_key:
file: ./infra/coopid/secrets/coop_vault_key
volumes:
mongo_data:
postgres_data:
minio_data:
caddy_data:
caddy_config:
caddy_logs:
crowdsec_db:
crowdsec_config:
+149
View File
@@ -0,0 +1,149 @@
# CoopID — Disaster Recovery Runbook
Восстановление кооператива из ежедневного S3-бэкапа (Story 9.8) после потери
сервера. Цель — **RTO 12 часа** до прохождения smoke-теста.
> **Формат бэкапа.** `coopid-backup.sh` снимает **физический** `pg_basebackup`
> всего PG-кластера (обе БД сразу) в `s3://{bucket}/{coopname}/{YYYY-MM-DD}.tar.gz`.
> Это НЕ логический `pg_dump` → восстановление выполняется **распаковкой архива в
> каталог данных PostgreSQL (PGDATA)**, а не командой `pg_restore`.
> Этот runbook — базовый сценарий восстановления PG. Сценарные runbook'и
> DR-01..DR-04 (Story 9.10) ссылаются на него.
## 0. Предпосылки
- Доступ к S3-бакету `coopid-backups` (ключи `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`).
- Известны: `COOPNAME` и дата бэкапа (`YYYY-MM-DD`), который восстанавливаем.
- **Секреты кооператива** на момент снятия бэкапа: каталог `infra/coopid/secrets/`
(особенно `coop_pg_app_password`, `coop_pg_authentik_password`) — их пароли ДОЛЖНЫ
совпадать с тем, что в бэкапе (см. §6, грабля №1). Храните их вместе с бэкапами
или будьте готовы к `ALTER ROLE`.
## 1. Новый сервер с Docker
```bash
# Ubuntu/Debian; на ноде нужны docker + docker compose v2 и aws CLI.
curl -fsSL https://get.docker.com | sh
git clone <repo> mono && cd mono # тот же репозиторий
# разложить секреты (ТЕ ЖЕ, что при снятии бэкапа — см. §6 грабля №1)
bash scripts/coopid-gen-secrets.sh # ТОЛЬКО если секреты потеряны (см. §6)
```
## 2. Скачать backup из S3
```bash
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
COOPNAME=voskhod ; DATE=2026-06-10
aws s3 cp "s3://coopid-backups/${COOPNAME}/${DATE}.tar.gz" ./backup.tar.gz
# проверить, что архив не пустой и валиден:
gzip -t backup.tar.gz && tar -tzf backup.tar.gz | head # должен показать PG_VERSION, base/, global/, pg_wal/
```
## 3. Восстановить в новый PG-том (распаковка в PGDATA)
postgres:18: `PGDATA=/var/lib/postgresql/18/docker`, том примонтирован на родитель
`/var/lib/postgresql`. Имя тома — `{project}_postgres_data` (project = имя каталога
репозитория, напр. `mono_postgres_data`). Стек должен быть остановлен.
```bash
PROJECT=mono # docker compose project name
docker compose down # если что-то поднято
# чистый том
docker volume rm ${PROJECT}_postgres_data 2>/dev/null || true
docker volume create ${PROJECT}_postgres_data
# распаковать содержимое архива в PGDATA внутри тома
docker run --rm -v ${PROJECT}_postgres_data:/v -v "$PWD":/b alpine sh -c '
mkdir -p /v/18/docker &&
tar -xzf /b/backup.tar.gz -C /v/18/docker &&
chown -R 999:999 /v/18/docker' # postgres в образе работает под uid 999
```
WAL включён в базовый бэкап (`pg_basebackup -X fetch`), поэтому при старте PG сам
выполнит crash-recovery и поднимется на момент бэкапа. PITR (точка во времени между
бэкапами) в MVP не предусмотрен — восстановление до состояния выбранного дня.
## 4. Поднять стек
```bash
docker compose up -d
docker compose ps # postgres healthy → authentik healthy → coopback/caddy
docker compose logs -f postgres | grep -m1 "database system is ready to accept connections"
```
## 5. Smoke-test (вход → сертификат → цепочка)
Проверяем сквозной контур CoopID одним пайщиком.
```bash
BASE=https://localhost:8443 # caddy (или прод-домен); SNI обязателен для localhost
COOP=voskhod
# (a) Вход: пароль на authentik → session_binding_token.
# Проще всего — через SDK @coopenomics/auth (login(email,password)),
# ниже — эквивалент curl поверх endpoint'ов Эпика 1:
# 1) получить сессию authentik (UI-флоу или /api/v3/flows/...), сохранить cookie;
# 2) обменять на binding-cookie:
curl -sk "$BASE/coop/session/bind" -X POST -b authentik_cookies.txt -c binding.txt -w '%{http_code}\n'
# ожидаемо 204 + cookie coop_session_binding
# (b) Второй этап: подпись метки времени ключом пайщика (SDK signTimestamp) →
curl -sk "$BASE/coop/verify/timestamp" -X POST -b binding.txt \
-H 'Content-Type: application/json' \
-d '{"signature":"SIG_K1_...","timestamp":"<ISO>","binding_token":"<jwt>"}'
# ожидаемо 200 + {access_token, refresh_token, participant_certificate?}
# (c) Выпуск/получение сертификата:
curl -sk "$BASE/coop/certificate" -H "Authorization: Bearer <access_token>"
# ожидаемо 200 + {participant_certificate: "<compact JWS>"}
# (d) Проверка цепочки coop_chain в сертификате:
echo "<participant_certificate>" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.coop_chain'
# ожидаемо массив [ano, voskhod, vostok]; coopname совпадает; iat/exp валидны
```
**Критерий успеха smoke-теста:** пайщик вошёл, сертификат выпущен, `coop_chain`
содержит полную цепочку доверия. Если все три шага зелёные — кооператив восстановлен.
## 6. Troubleshooting (частые грабли)
**№1. authentik/coopback не подключаются к БД (`password authentication failed`).**
Главная грабля. Пароли ролей `authentik_user`/`coop_app_user` хранятся ВНУТРИ бэкапа
(`pg_authid`). Init-скрипт `infra/coopid/postgres/init/01-init.sh` выполняется ТОЛЬКО
на пустом томе — на восстановленном он НЕ запускается, роли уже есть из бэкапа.
→ Либо разложить в `infra/coopid/secrets/` ТЕ ЖЕ пароли, что были при снятии бэкапа;
либо привести пароли ролей под текущие секреты:
```bash
docker compose exec -T postgres psql -U postgres -v ON_ERROR_STOP=1 \
-v apw="$(cat infra/coopid/secrets/coop_pg_authentik_password)" \
-v cpw="$(cat infra/coopid/secrets/coop_pg_app_password)" <<'SQL'
ALTER ROLE authentik_user PASSWORD :'apw';
ALTER ROLE coop_app_user PASSWORD :'cpw';
SQL
```
**№2. PG не стартует, `data directory ... has wrong ownership`.** Файлы PGDATA должны
принадлежать uid 999 (postgres в образе). Повторите `chown -R 999:999 /v/18/docker`
(см. §3).
**№3. PG не стартует, несовпадение мажорной версии (`incompatible ... PG_VERSION`).**
Физический бэкап восстановим ТОЛЬКО на ту же мажорную версию PostgreSQL (18).
Используйте образ `postgres:18`. Кросс-версионно — только логический dump (вне MVP).
**№4. `gzip -t` падает / `tar` обрывается.** Архив битый или скачан не полностью —
возьмите соседний день (retention 30 дней) и повторите §2.
**№5. S3: AccessDenied / NoSuchKey.** Проверьте ключи, бакет, регион и что объект
ещё не истёк по lifecycle (хранятся 30 дней). При SSE-S3 на чтение доп-действий не нужно.
**№6. caddy «TLS internal error» на localhost.** Запрос без SNI — добавьте
`--resolve localhost:8443:127.0.0.1` (см. `infra/coopid/README.md`).
## RTO
Ожидаемое время восстановления — **12 часа**: ~15 мин подготовка сервера, ~10–40 мин
скачивание+распаковка (зависит от размера БД и канала), ~10 мин старт стека и миграций,
~15 мин smoke-test и устранение граблей §6. Цель RTO для MVP — **≤ 2 часов**.
+154
View File
@@ -0,0 +1,154 @@
# CoopID — инфраструктура per-coop (Story 1.1)
Стек OIDC-аутентификации кооператива встроен в **основной** `docker-compose.yaml`
монорепы: authentik 2026.2 (server + worker), caddy (внешний TLS 1.3) и crowdsec.
Отдельный postgres не поднимается — данные authentik и доменные данные CoopID
живут **двумя отдельными БД в существующем сервисе `postgres`**:
- `authentik_db` — роль `authentik_user` (данные authentik);
- `coop_domain_db` — роль `coop_app_user` (vaults, audit, кэши — миграции V2.4.x).
Ролевая изоляция: подключиться к чужой БД может только её владелец (и суперюзер).
Redis — общий `monoredis` (authentik в DB-индексе 1, controller в 0).
Весь стек поднимается **одной командой** — никаких overlay `-f`-цепочек.
> ⚠️ **ОБЯЗАТЕЛЬНО перед первым `docker compose up -d` (и на каждом чекауте/ноде).**
> CoopID-сервисы в `docker-compose.yaml` ссылаются на file-секреты из
> `infra/coopid/secrets/*` (каталог в `.gitignore` — в репозиторий не попадает).
> Если этих файлов нет, `docker compose up -d` **падает на этапе валидации
> конфига** (`secret ... not found`) и не поднимает вообще ничего, включая
> базовый стек. Лекарство — один раз прогнать:
>
> ```bash
> bash scripts/coopid-gen-secrets.sh
> ```
>
> Это касается любого чекаута монорепы (mono-ai-1..5) после `git pull` ветки с
> этими изменениями, не только mono-ai-3. На prod секреты раскладывает плейбук
> `~/playbooks` — там скрипт-генератор не используется.
## Запуск
```bash
# 1. Секреты (один раз; идемпотентно — существующие не перезаписывает)
bash scripts/coopid-gen-secrets.sh
# 2. Весь стек
docker compose up -d
```
## Состав (в docker-compose.yaml)
| Сервис | Образ | Назначение |
|---|---|---|
| `postgres` | postgres:18 | Общий postgres. Кроме `voskhod` держит `authentik_db` и `coop_domain_db` (создаются init-скриптом на свежем томе либо вручную — см. ниже) |
| `authentik-server` | ghcr.io/goauthentik/server:2026.2 | OIDC Provider; UI/API на :9000 внутри сети |
| `authentik-worker` | ghcr.io/goauthentik/server:2026.2 | Фоновые задачи authentik |
| `caddy` | caddy:2.11 | Внешний HTTPS (TLS 1.3 min): `/api/coopback/*` → controller, остальное → authentik; JSON access-log |
| `crowdsec` | crowdsecurity/crowdsec:v1.7.8 | Разбор access-логов caddy (активный bouncer — Story 9.2) |
## Две БД в существующем postgres
`infra/coopid/postgres/init/01-init.sh` смонтирован в `/docker-entrypoint-initdb.d`
и заводит роли+БД **только при пустом data dir** (правило официального образа
postgres). Поэтому:
- **Свежая машина / пустой том** — `docker compose up -d` создаёт обе БД сам.
- **Уже инициализированный том** (как на текущих dev-стендах) — init-скрипт
не запускается, БД заводятся вручную тем же SQL:
```bash
AUTHPW=$(cat infra/coopid/secrets/coop_pg_authentik_password)
APPPW=$(cat infra/coopid/secrets/coop_pg_app_password)
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 \
-v apw="$AUTHPW" -v cpw="$APPPW" -U postgres -d postgres <<'SQL'
CREATE ROLE authentik_user LOGIN PASSWORD :'apw';
CREATE ROLE coop_app_user LOGIN PASSWORD :'cpw';
CREATE DATABASE authentik_db OWNER authentik_user;
CREATE DATABASE coop_domain_db OWNER coop_app_user;
REVOKE CONNECT ON DATABASE authentik_db FROM PUBLIC;
REVOKE CONNECT ON DATABASE coop_domain_db FROM PUBLIC;
SQL
```
Затем coopback на старте применит миграции `coop_domain_db` (V2.4.0/V2.4.1).
## Секреты
File-based Docker Secrets из `infra/coopid/secrets/` (каталог в `.gitignore`):
`coop_cert_key` (secp256k1 PEM — подпись `participant_certificate`),
`coop_vault_key`, `authentik_secret_key`, bootstrap-пароль/токен `akadmin`,
`authentik_webhook_token`, `auth_v2_session_binding_secret`, два пароля ролей
postgres. Суперюзер postgres — inline-пароль в compose, отдельного секрета нет.
В `.env`/compose значений секретов нет; контейнеры читают их из `/run/secrets/*`
(postgres-роли — через init-скрипт, authentik — `file://`, coopback — `*_FILE`).
На prod секреты раскладывает плейбук с правами `0400`.
## Порты (dev, mono-ai-3)
- `127.0.0.1:8443` — caddy HTTPS (self-signed при `COOPID_DOMAIN=localhost`)
- `127.0.0.1:8088` — caddy HTTP
- `127.0.0.1:9008` — authentik напрямую (мимо caddy, для отладки; `AUTHENTIK_HOST_PORT`)
- `127.0.0.1:5532` — postgres (обе БД, `PG_HOST_PORT`)
## Проверка
```bash
docker compose ps
# SNI обязателен: caddy с авто-TLS для localhost без SNI отдаёт TLS internal error.
curl -sk --tlsv1.3 --resolve localhost:8443:127.0.0.1 https://localhost:8443/ \
-o /dev/null -w '%{http_code}\n' # 302/200 от authentik (порт = CADDY_HTTPS_PORT)
# Ролевая изоляция (ожидаемо permission denied):
docker compose exec postgres psql -U coop_app_user -d authentik_db -c 'select 1'
```
Учётка администратора authentik: `akadmin`, пароль — в
`infra/coopid/secrets/authentik_bootstrap_password`.
## Бэкапы (Story 9.8)
Ежедневный `pg_basebackup` всего PG-кластера (обе БД сразу) → один gzip-tar → S3
`s3://{bucket}/{coopname}/{YYYY-MM-DD}.tar.gz` с SSE-S3. Retention 30 дней — через
S3 lifecycle rule (не ручным удалением). На каждый прогон — строка `audit_events`
(`coopid.backup.created`: `s3_key`, `size_bytes`, `duration_seconds`).
Скрипт: `infra/coopid/scripts/coopid-backup.sh`. Env:
```bash
export COOPNAME=voskhod # имя кооператива (обязательно)
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
export S3_BUCKET=coopid-backups # дефолт
export S3_ENDPOINT= # пусто = реальный AWS S3; для MinIO: http://minio:9000
export RETENTION_DAYS=30 # дефолт
export PG_CONTAINER=coop-postgres # имя контейнера postgres
```
Один раз при деплое — создать bucket и поставить lifecycle:
```bash
COOPNAME=voskhod ./infra/coopid/scripts/coopid-backup.sh --setup
```
Cron (ставит прод-плейбук `~/playbooks`; локально — в crontab хоста), 03:00 UTC daily:
```cron
0 3 * * * cd /path/to/mono && COOPNAME=voskhod AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \
./infra/coopid/scripts/coopid-backup.sh >> /var/log/coopid-backup.log 2>&1
```
`--dry-run` печатает команды без выполнения. Восстановление — runbook
`docs/operations/disaster-recovery.md` (Story 9.9). Требования к PG: `wal_level>=replica`
(дефолт) и replication-доступ суперпользователя по локальному сокету контейнера.
## Troubleshooting
- **Init-скрипт postgres выполняется только при пустом data dir.** На живом томе
заводите БД вручную (см. блок выше). Ротация паролей ролей — `ALTER ROLE`, не
перегенерацией секрета.
- **Прод с реальным доменом:** задайте `COOPID_DOMAIN`, `CADDY_BIND=0.0.0.0`,
`CADDY_HTTP_PORT=80`, `CADDY_HTTPS_PORT=443` — ACME HTTP-01 должен быть
достижим снаружи на 80/443.
- **authentik на postgres:18:** официальная поддержка на pg16, но pg18 как
сервер обратносовместим (Django+psycopg). На dev/testnet общий postgres
используется сознательно — единый стек одной командой важнее версии-в-версию.
@@ -0,0 +1,91 @@
# CoopID Story 1.5: парольная политика (zxcvbn ≥3, ≥12 символов, русское
# сообщение) + аудит отказов в coop_domain_db через webhook к controller'у.
#
# Полиси переопределяется по identifiers дефолтного blueprint'а (upsert) —
# весь flow password-change не форкаем, при апгрейдах authentik дрейфа меньше.
# execution_logging: true — единственный источник событий policy_execution
# в этой инсталляции (дефолтные полиси его не включают), поэтому controller
# фильтрует только result.passing=false.
version: 1
metadata:
name: CoopID - password policy & weak-password audit
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- identifiers:
name: default-password-change-password-policy
model: authentik_policies_password.passwordpolicy
attrs:
check_static_rules: true
check_have_i_been_pwned: false # HIBP — Growth (FR21)
check_zxcvbn: true
zxcvbn_score_threshold: 3
length_min: 12
password_field: password
error_message: Пароль слишком слабый. Попробуйте длиннее или с большей вариативностью
execution_logging: true
- identifiers:
name: coopid-event-matcher-policy-execution
id: coopid-event-matcher
model: authentik_policies_event_matcher.eventmatcherpolicy
attrs:
action: policy_execution
- identifiers:
name: coopid-webhook-body
id: coopid-webhook-body
model: authentik_events.notificationwebhookmapping
attrs:
expression: |
event = notification.event
ctx = (event.context or {}) if event else {}
return {
"severity": notification.severity,
"event_action": event.action if event else None,
"event_user": ((event.user or {}).get("username")) if event else None,
"passing": ((ctx.get("result") or {}).get("passing")),
"messages": ((ctx.get("result") or {}).get("messages")),
"created": event.created.isoformat() if event else None,
}
- identifiers:
name: coopid-webhook-headers
id: coopid-webhook-headers
model: authentik_events.notificationwebhookmapping
attrs:
# Токен подставляется при применении blueprint'а из env контейнера
# (COOPID_WEBHOOK_TOKEN); сверяется controller'ом constant-time.
expression: !Format [
"return {'X-Authentik-Webhook-Token': '%s'}",
!Env COOPID_WEBHOOK_TOKEN,
]
- identifiers:
name: coopid-webhook-transport
id: coopid-webhook-transport
model: authentik_events.notificationtransport
attrs:
mode: webhook
webhook_url: http://coopback:2998/coop/internal/authentik-events
webhook_mapping_body: !KeyOf coopid-webhook-body
webhook_mapping_headers: !KeyOf coopid-webhook-headers
send_once: true
- identifiers:
name: coopid-weak-password-rule
id: coopid-weak-password-rule
model: authentik_events.notificationrule
attrs:
severity: notice
transports:
- !KeyOf coopid-webhook-transport
destination_group: !Find [authentik_core.group, [name, authentik Admins]]
- identifiers:
policy: !KeyOf coopid-event-matcher
target: !KeyOf coopid-weak-password-rule
order: 0
model: authentik_policies.policybinding
attrs:
enabled: true
+32
View File
@@ -0,0 +1,32 @@
# Edge-вход кооператива (CoopID). TLS 1.3 минимум; внутрь сети — plain HTTP.
# COOPID_DOMAIN=localhost (dev) даёт self-signed сертификат внутреннего CA;
# реальный домен автоматически получает Let's Encrypt.
{
admin off
}
{$COOPID_DOMAIN:localhost} {
tls {
protocols tls1.3
}
log {
output file /var/log/caddy/access.log {
roll_size 50MiB
roll_keep 5
}
format json
}
# REST/GraphQL controller'а — до общего catch-all.
handle /api/coopback/* {
uri strip_prefix /api/coopback
reverse_proxy coopback:2998
}
# Всё остальное — OIDC Provider (authentik UI, /application/o/*, flows).
handle {
reverse_proxy authentik-server:9000
}
}
+6
View File
@@ -0,0 +1,6 @@
# Источники логов для crowdsec: JSON access-лог caddy (shared volume caddy_logs).
# Активные блокировки (bouncer в caddy) подключаются в Story 9.2.
filenames:
- /var/log/caddy/access.log
labels:
type: caddy
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Инициализация coop-postgres (выполняется entrypoint'ом postgres только при пустом data dir):
# две БД с ролевой изоляцией — authentik_db (authentik_user) и coop_domain_db (coop_app_user).
# Пароли ролей берутся из Docker Secrets, в env/compose они не попадают.
set -euo pipefail
AUTHENTIK_PW="$(cat /run/secrets/coop_pg_authentik_password)"
APP_PW="$(cat /run/secrets/coop_pg_app_password)"
# Пароли — через psql-переменные с квотированием :'var': не попадают в текст
# SQL-команды (логи statement'ов, pg_stat_activity) и безопасны к спецсимволам.
psql -v ON_ERROR_STOP=1 -v apw="$AUTHENTIK_PW" -v cpw="$APP_PW" \
--username "$POSTGRES_USER" --dbname postgres <<-'EOSQL'
CREATE ROLE authentik_user LOGIN PASSWORD :'apw';
CREATE ROLE coop_app_user LOGIN PASSWORD :'cpw';
CREATE DATABASE authentik_db OWNER authentik_user;
CREATE DATABASE coop_domain_db OWNER coop_app_user;
-- Ролевая изоляция: подключаться к БД может только её владелец (и суперпользователь).
REVOKE CONNECT ON DATABASE authentik_db FROM PUBLIC;
REVOKE CONNECT ON DATABASE coop_domain_db FROM PUBLIC;
EOSQL
# Запрет создания объектов в public-схеме чужой БД на случай выдачи CONNECT в будущем.
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname authentik_db <<-EOSQL
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT ALL ON SCHEMA public TO authentik_user;
EOSQL
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname coop_domain_db <<-EOSQL
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT ALL ON SCHEMA public TO coop_app_user;
EOSQL
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# CoopID daily backup (Story 9.8): pg_basebackup всего PG-кластера (authentik_db +
# coop_domain_db + любые прочие БД внутри контейнера) → один gzip-tar → S3
# `s3://{bucket}/{coopname}/{YYYY-MM-DD}.tar.gz` с SSE-S3. На каждый прогон —
# строка audit_events (coopid.backup.created). Retention 30 дней — S3 lifecycle
# rule (ставится один раз режимом `--setup`), НЕ ручным удалением.
#
# Один скрипт для dev (MinIO) и prod (реальный S3): endpoint и креды — из env.
# Cron 03:00 UTC ставит прод-плейбук (~/playbooks); crontab-строка — в README.
#
# Использование:
# COOPNAME=voskhod ./coopid-backup.sh # сделать бэкап
# COOPNAME=voskhod ./coopid-backup.sh --setup # создать bucket + lifecycle 30d
# COOPNAME=voskhod ./coopid-backup.sh --dry-run # показать команды, не выполнять
#
# Обязательные env: COOPNAME, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY.
# Опциональные env (со значениями по умолчанию ниже):
# S3_BUCKET (coopid-backups), S3_ENDPOINT (пусто=реальный AWS S3),
# RETENTION_DAYS (30), PG_CONTAINER (coop-postgres), PG_SUPERUSER (postgres),
# COOP_DOMAIN_DB (coop_domain_db), S3_SSE (AES256), AWS_DEFAULT_REGION (us-east-1).
set -euo pipefail
COOPNAME="${COOPNAME:?COOPNAME обязателен (имя кооператива)}"
S3_BUCKET="${S3_BUCKET:-coopid-backups}"
S3_ENDPOINT="${S3_ENDPOINT:-}"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
PG_CONTAINER="${PG_CONTAINER:-coop-postgres}"
PG_SUPERUSER="${PG_SUPERUSER:-postgres}"
COOP_DOMAIN_DB="${COOP_DOMAIN_DB:-coop_domain_db}"
S3_SSE="${S3_SSE:-AES256}"
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
MODE="backup"
case "${1:-}" in
--setup) MODE="setup" ;;
--dry-run) MODE="dry-run" ;;
"") MODE="backup" ;;
*) echo "Неизвестный аргумент: $1 (ожидается --setup | --dry-run)" >&2; exit 2 ;;
esac
# aws CLI с опциональным endpoint (MinIO в dev / реальный S3 в prod).
aws_s3() {
if [[ -n "$S3_ENDPOINT" ]]; then aws --endpoint-url "$S3_ENDPOINT" "$@"; else aws "$@"; fi
}
log() { echo "[coopid-backup] $*"; }
# --- режим --setup: bucket + lifecycle (retention 30 дней) ---
setup_bucket() {
log "ensure bucket s3://$S3_BUCKET"
if ! aws_s3 s3api head-bucket --bucket "$S3_BUCKET" >/dev/null 2>&1; then
aws_s3 s3 mb "s3://$S3_BUCKET"
fi
log "apply lifecycle: expire objects after ${RETENTION_DAYS}d"
aws_s3 s3api put-bucket-lifecycle-configuration \
--bucket "$S3_BUCKET" \
--lifecycle-configuration "{
\"Rules\": [{
\"ID\": \"coopid-backups-retention-${RETENTION_DAYS}d\",
\"Status\": \"Enabled\",
\"Filter\": { \"Prefix\": \"\" },
\"Expiration\": { \"Days\": ${RETENTION_DAYS} }
}]
}"
log "setup завершён"
}
# --- запись результата в append-only audit_events (coop_domain_db) ---
# Значения подставляются через psql-переменные с квотированием :'var' — не
# попадают в текст команды и безопасны к спецсимволам (как в init-скрипте).
audit() {
local result="$1" s3_key="$2" size_bytes="$3" duration="$4" stage="$5"
docker exec -i "$PG_CONTAINER" \
psql -v ON_ERROR_STOP=1 -q \
-v cn="$COOPNAME" -v key="$s3_key" -v sz="$size_bytes" -v dur="$duration" \
-v res="$result" -v stg="$stage" \
--username "$PG_SUPERUSER" --dbname "$COOP_DOMAIN_DB" >/dev/null <<-'EOSQL'
INSERT INTO audit_events (event, subject_id, actor, result, context)
VALUES (
'coopid.backup.created', NULL, 'backup-cron', :'res',
jsonb_build_object(
'coopname', :'cn', 's3_key', :'key',
'size_bytes', (:'sz')::bigint, 'duration_seconds', (:'dur')::int,
'stage', :'stg'
)
);
EOSQL
}
run_backup() {
local date_utc start end duration tmp archive size s3_key
date_utc="$(date -u +%F)"
start="$(date +%s)"
s3_key="${COOPNAME}/${date_utc}.tar.gz"
tmp="$(mktemp -d)"
archive="${tmp}/${date_utc}.tar.gz"
# shellcheck disable=SC2064 # tmp фиксируем сейчас, очистка при любом выходе
trap "rm -rf '$tmp'" EXIT
log "pg_basebackup кластера из контейнера $PG_CONTAINER"
if ! docker exec "$PG_CONTAINER" \
pg_basebackup --username "$PG_SUPERUSER" --format=tar --gzip \
--wal-method=fetch --pgdata=- > "$archive"; then
audit failure "$s3_key" 0 "$(( $(date +%s) - start ))" basebackup || true
log "ОШИБКА: pg_basebackup не удался" >&2
exit 1
fi
size="$(stat -c%s "$archive")"
if [[ "$size" -le 0 ]]; then
audit failure "$s3_key" 0 "$(( $(date +%s) - start ))" basebackup || true
log "ОШИБКА: пустой архив бэкапа" >&2
exit 1
fi
log "upload → s3://${S3_BUCKET}/${s3_key} (${size} байт, SSE=${S3_SSE})"
if ! aws_s3 s3 cp "$archive" "s3://${S3_BUCKET}/${s3_key}" --sse "$S3_SSE"; then
audit failure "$s3_key" "$size" "$(( $(date +%s) - start ))" upload || true
log "ОШИБКА: загрузка в S3 не удалась" >&2
exit 1
fi
end="$(date +%s)"
duration="$(( end - start ))"
audit success "$s3_key" "$size" "$duration" "done"
log "готово: ${s3_key} за ${duration}s"
}
dry_run() {
cat <<EOF
[dry-run] backup-команды для coopname=${COOPNAME}:
docker exec ${PG_CONTAINER} pg_basebackup --username ${PG_SUPERUSER} \\
--format=tar --gzip --wal-method=fetch --pgdata=- > <tmp>/$(date -u +%F).tar.gz
aws${S3_ENDPOINT:+ --endpoint-url ${S3_ENDPOINT}} s3 cp <tmp>/$(date -u +%F).tar.gz \\
s3://${S3_BUCKET}/${COOPNAME}/$(date -u +%F).tar.gz --sse ${S3_SSE}
audit_events ← coopid.backup.created (s3_key, size_bytes, duration_seconds)
[dry-run] retention: S3 lifecycle expire ${RETENTION_DAYS}d (см. --setup)
EOF
}
case "$MODE" in
setup) setup_bucket ;;
dry-run) dry_run ;;
backup) run_backup ;;
esac
+3 -2
View File
@@ -24,18 +24,19 @@
]
},
"dependencies": {
"@coopenomics/auth": "workspace:*",
"@coopenomics/blago-cli": "workspace:*",
"@coopenomics/boot": "workspace:*",
"@coopenomics/cleos": "workspace:*",
"@coopenomics/contracts": "workspace:*",
"@coopenomics/controller": "workspace:*",
"@coopenomics/desktop": "workspace:*",
"@coopenomics/factory": "workspace:*",
"@coopenomics/inter": "workspace:*",
"@coopenomics/notifications": "workspace:*",
"@coopenomics/parser": "workspace:*",
"@coopenomics/sdk": "workspace:*",
"@coopenomics/setup": "workspace:*",
"@coopenomics/inter": "workspace:*",
"@coopenomics/blago-cli": "workspace:*",
"commitgpt": "^2.0.0",
"cooptypes": "workspace:*",
"eslint": "^9.9.1",
+77
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@coopenomics/auth':
specifier: workspace:*
version: link:components/auth
'@coopenomics/blago-cli':
specifier: workspace:*
version: link:components/blago-cli
@@ -70,6 +73,49 @@ importers:
specifier: ^5.9.3
version: 5.9.3
components/auth:
dependencies:
'@noble/hashes':
specifier: ^1.8.0
version: 1.8.0
'@wharfkit/antelope':
specifier: ^1.1.1
version: 1.1.1
'@wharfkit/session':
specifier: ^1.4.0
version: 1.6.1
cooptypes:
specifier: workspace:*
version: link:../cooptypes
jose:
specifier: ^6.2.2
version: 6.2.3
oidc-client-ts:
specifier: ^3.1.0
version: 3.5.0
devDependencies:
'@antfu/eslint-config':
specifier: ^3.3.2
version: 3.16.0(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.31)(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1))
'@types/node':
specifier: ^22.5.4
version: 22.19.15
eslint:
specifier: ^9.9.1
version: 9.39.4(jiti@2.6.1)
typescript:
specifier: ^5.5.4
version: 5.9.3
unbuild:
specifier: ^2.0.0
version: 2.0.0(sass@1.98.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))
vite:
specifier: ^5.4.3
version: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)
vitest:
specifier: ^2.0.5
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)
components/blago-cli:
dependencies:
'@coopenomics/sdk':
@@ -475,6 +521,9 @@ importers:
joi-to-typescript:
specifier: ^4.13.0
version: 4.15.0(joi@17.13.3)
jose:
specifier: ^5.10.0
version: 5.10.0
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
@@ -1175,6 +1224,15 @@ importers:
'@coopenomics/factory':
specifier: workspace:*
version: link:../factory
'@wharfkit/antelope':
specifier: ^1.1.1
version: 1.1.1
'@wharfkit/session':
specifier: ^1.4.0
version: 1.6.1
'@wharfkit/wallet-plugin-privatekey':
specifier: ^1.1.0
version: 1.1.0(@wharfkit/session@1.6.1)
cooptypes:
specifier: workspace:*
version: link:../cooptypes
@@ -12991,6 +13049,9 @@ packages:
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@@ -13177,6 +13238,10 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
kareem@3.2.0:
resolution: {integrity: sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==}
engines: {node: '>=18.0.0'}
@@ -14723,6 +14788,10 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
oidc-client-ts@3.5.0:
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
engines: {node: '>=18'}
ol-contextmenu@5.5.0:
resolution: {integrity: sha512-IglB0OfzSmcP++dykqrQU62lJAn/4O61hxyD0s5V03HE70Efm6EgSaNq8YOlgEbnqfUL+7F3fS7VI3UV+0+NZg==}
engines: {node: '>=16', npm: '>=8'}
@@ -34276,6 +34345,8 @@ snapshots:
jose@5.10.0: {}
jose@6.2.3: {}
joycon@3.1.1: {}
js-beautify@1.14.11:
@@ -34484,6 +34555,8 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
jwt-decode@4.0.0: {}
kareem@3.2.0: {}
katex@0.16.43:
@@ -36409,6 +36482,10 @@ snapshots:
ohash@2.0.11: {}
oidc-client-ts@3.5.0:
dependencies:
jwt-decode: 4.0.0
ol-contextmenu@5.5.0(ol@9.2.4):
dependencies:
ol: 9.2.4
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Генерация file-based Docker Secrets для CoopID-стека (dev).
# Идемпотентен: существующие непустые файлы не перезаписывает.
# На prod секреты раскладывает плейбук — этот скрипт только для локальной разработки.
set -euo pipefail
command -v openssl >/dev/null || { echo "Нужен openssl (генерация ключей/паролей)"; exit 1; }
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS_DIR="$ROOT/infra/coopid/secrets"
mkdir -p "$SECRETS_DIR"
gen() {
local mode="$1" name="$2"
shift 2
local file="$SECRETS_DIR/$name"
if [[ -s "$file" ]]; then
echo " = $name — уже существует, пропущен"
return
fi
"$@" > "$file"
chmod "$mode" "$file"
echo " + $name — создан"
}
rand_hex() { openssl rand -hex 32; }
rand_pass() { openssl rand -base64 36 | tr -d '\n=/+'; }
secp256k1_pem() { openssl ecparam -genkey -name secp256k1 -noout; }
echo "Секреты CoopID → $SECRETS_DIR"
# Ключ подписи participant_certificate (ES256K, secp256k1).
gen 0400 coop_cert_key secp256k1_pem
# AES-ключ vault'а кооператива (subject_type='coop').
gen 0400 coop_vault_key rand_hex
# authentik: secret key + bootstrap-учётка akadmin (образ работает под uid 1000 = владелец файлов).
gen 0400 authentik_secret_key rand_pass
gen 0400 authentik_bootstrap_password rand_pass
gen 0400 authentik_bootstrap_token rand_pass
# Shared-токен вебхука authentik → controller (audit weak-password, Story 1.5).
# Дублируется в .env (COOPID_WEBHOOK_TOKEN) для blueprint'а authentik.
gen 0400 authentik_webhook_token rand_pass
# Секрет подписи session_binding_token (HS256, Story 1.6).
gen 0400 auth_v2_session_binding_secret rand_pass
if ! grep -q '^COOPID_WEBHOOK_TOKEN=' "$ROOT/.env" 2>/dev/null; then
printf '\n# Webhook-токен authentik→coopback (= infra/coopid/secrets/authentik_webhook_token)\nCOOPID_WEBHOOK_TOKEN=%s\n' "$(cat "$SECRETS_DIR/authentik_webhook_token")" >> "$ROOT/.env"
echo " + COOPID_WEBHOOK_TOKEN добавлен в .env"
fi
# postgres: пароли прикладных ролей CoopID (authentik_user, coop_app_user).
# Суперюзер postgres использует inline-пароль из docker-compose.yaml — отдельный
# секрет ему не нужен. file-секреты в plain compose монтируются bind-mount'ом с
# хостовыми правами, а init-скрипты официального образа выполняются под uid 999
# (postgres) — поэтому пароли world-readable. Только dev; prod-права (0400) — плейбук.
gen 0444 coop_pg_authentik_password rand_pass
gen 0444 coop_pg_app_password rand_pass
echo "Готово. Дальше: docker compose up -d (весь стек одной командой)."
+30 -9
View File
@@ -1,14 +1,35 @@
{
"files": [],
"references": [
{ "path": "./components/controller" },
{ "path": "./components/desktop" },
{ "path": "./components/sdk" },
{ "path": "./components/cooptypes" },
{ "path": "./components/notifications" },
{ "path": "./components/factory" },
{ "path": "./components/parser" },
{ "path": "./components/boot" },
{ "path": "./components/migrator" }
{
"path": "./components/auth"
},
{
"path": "./components/controller"
},
{
"path": "./components/desktop"
},
{
"path": "./components/sdk"
},
{
"path": "./components/cooptypes"
},
{
"path": "./components/notifications"
},
{
"path": "./components/factory"
},
{
"path": "./components/parser"
},
{
"path": "./components/boot"
},
{
"path": "./components/migrator"
}
]
}