Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5714e3090b | |||
| 2a167ee458 | |||
| da08e22686 | |||
| a8882bdc2f | |||
| 4a51f69a2f | |||
| 179c9eb803 | |||
| 903926e34c | |||
| 2575e9545d | |||
| d1d7410731 | |||
| 0ba0d4c382 | |||
| c5085b2707 | |||
| ade9fe20b2 | |||
| fed20d5fba | |||
| 975cc509ae | |||
| d59dc7b796 | |||
| 4221ca707e | |||
| f240778162 | |||
| 412a6d268a | |||
| e02dae4ec7 | |||
| ff50881fd0 | |||
| 6c82544b80 | |||
| eaf4125f21 | |||
| 2e16e7d852 | |||
| 0c68759455 | |||
| 4fc6eaf986 | |||
| 6415db7085 | |||
| 1a97e1208c | |||
| a7f1398529 | |||
| f5bcaa3a69 | |||
| d4a34bb067 | |||
| 844725d660 | |||
| 8d63baa53a | |||
| 1498be5213 | |||
| 6fbce27242 | |||
| 28c4164a61 | |||
| 666672d43e | |||
| 865b5eb550 | |||
| 50ac2bffc3 | |||
| e14e34a36e | |||
| 913153285b | |||
| 95ec869f79 | |||
| c0b7da9f40 | |||
| 0fd5d098be | |||
| 323e1e94a9 | |||
| bd4839b67f | |||
| 5eb7db9bf3 | |||
| 35bfe887bb | |||
| d7ccbda283 | |||
| 3d98728694 | |||
| 6e66c5c086 | |||
| d6f12ed7e9 | |||
| 1a4876a0cd | |||
| 5ed8a63839 | |||
| bfa5c7e00b | |||
| fad6aadf20 | |||
| 662ee9e64f | |||
| 7c703abe24 | |||
| a3fcc4b063 | |||
| f991ba21d8 | |||
| e2608c9e2a | |||
| bf48d7a8bd | |||
| 2c74db214b | |||
| 372d30e430 | |||
| e6522e65c6 | |||
| 51c8f2abf1 | |||
| 364215bcf1 | |||
| 0cef265a2c | |||
| 37127cc8ec | |||
| 7ccb2e844f | |||
| 1effd2f898 | |||
| 4ed652ef3e | |||
| edfde6fa1e | |||
| a288e8888b | |||
| f4701d07c7 | |||
| d31aa50768 | |||
| 9ece08eed9 | |||
| 0a6ede48ef | |||
| 2f15111f99 | |||
| c6ca8a2fa5 | |||
| 3ed1c5ee95 | |||
| d63ace86e4 | |||
| e67001a82a | |||
| c4febaa749 | |||
| f573d0e5a3 | |||
| fafb991561 | |||
| 49ecba1149 | |||
| aa482316ee | |||
| 33548c2200 | |||
| 7b6fdd823e | |||
| 0065314b95 | |||
| f8e835b4a8 | |||
| 4ac9e17296 | |||
| 75f5a4bfd5 | |||
| 86dede3d35 |
@@ -39,3 +39,14 @@ MINIO_ROOT_PASSWORD=minioadmin
|
||||
CHAIN_URL=http://127.0.0.1:8888
|
||||
API_URL=http://127.0.0.1:2998/v1/graphql
|
||||
MONGODB_URL=mongodb://127.0.0.1:27017
|
||||
|
||||
# --- CoopID (overlays docker-compose.authentik.yml + docker-compose.edge.yml) ---
|
||||
# Поднимаются только явной цепочкой -f (см. infra/coopid/README.md);
|
||||
# перед первым запуском: bash scripts/coopid-gen-secrets.sh
|
||||
# К портам применяется то же правило offset = (INSTANCE_INDEX - 1) * 10.
|
||||
COOP_PG_HOST_PORT=5632
|
||||
AUTHENTIK_HOST_PORT=9008
|
||||
CADDY_HTTP_PORT=8088
|
||||
CADDY_HTTPS_PORT=8443
|
||||
# Домен кооператива для caddy: localhost = dev self-signed; реальный домен = Let's Encrypt.
|
||||
COOPID_DOMAIN=localhost
|
||||
|
||||
@@ -33,22 +33,7 @@ on:
|
||||
branches: [testnet, main]
|
||||
paths:
|
||||
- 'lerna.json'
|
||||
# Ручной деплой: выбрать окружение и (опционально) версию/коммит. Нужен для
|
||||
# отката на старую версию, редеплоя без бампа и точечного hotfix в один контур —
|
||||
# всё, что FF-промоушн (scripts/promote.sh, только вперёд) сделать не может.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Куда деплоить'
|
||||
type: choice
|
||||
options:
|
||||
- testnet
|
||||
- main
|
||||
required: true
|
||||
ref:
|
||||
description: 'Релизный тег/коммит для сборки (пусто = верхушка выбранного окружения)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -65,22 +50,13 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# push → собираем пушнутый коммит; ручной запуск → выбранный ref
|
||||
# (пусто = верхушка ветки окружения, на которой запущен workflow).
|
||||
ref: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref) || github.sha }}
|
||||
|
||||
- name: Resolve branch, build mode and version
|
||||
id: resolve
|
||||
run: |
|
||||
# Окружение: при ручном workflow_dispatch — из inputs.environment;
|
||||
# при push — это ветка push'а (testnet/main). Версия/сборка берутся
|
||||
# из реально вычекнутого дерева, не из ветки-триггера.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
BRANCH="${{ github.event.inputs.environment }}"
|
||||
else
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
fi
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
# Ветка — это ветка push'а (или выбранная в workflow_dispatch).
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
SHA="${{ github.sha }}"
|
||||
|
||||
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
|
||||
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
|
||||
@@ -390,18 +366,6 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lerna run build
|
||||
|
||||
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
|
||||
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
|
||||
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
|
||||
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
|
||||
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
|
||||
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
|
||||
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
|
||||
- name: Generate controller schema.gql (required by sdk prepublishOnly)
|
||||
working-directory: components/controller
|
||||
run: pnpm run generate-schema
|
||||
|
||||
- run: pnpm lerna publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: SDK cross-runtime
|
||||
|
||||
# Story 9.13 (CoopID, Эпик 9): smoke-тесты @coopenomics/auth на трёх рантаймах —
|
||||
# Node (vitest), браузер (vitest browser mode + chromium), desktop-runtime
|
||||
# (electron, main-process против собранного dist). SDK уходит клиентам в три
|
||||
# разных рантайма, и регрессия вида «crypto.subtle недоступен / bundle не
|
||||
# грузится в браузере» не ловится Node-юнитами вовсе.
|
||||
#
|
||||
# Триггер — только изменения самого пакета: прогон тяжёлый (браузер + electron
|
||||
# + xvfb), на каждый PR в dev его гонять незачем.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- 'components/auth/**'
|
||||
- '.github/workflows/sdk-cross-runtime.yaml'
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: sdk-cross-runtime-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cross-runtime:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install workspace deps
|
||||
run: pnpm install --frozen-lockfile --filter @coopenomics/auth...
|
||||
|
||||
# xvfb — electron на Linux требует X-сервер; браузеры playwright ставим
|
||||
# вместе с системными библиотеками chromium (--with-deps).
|
||||
- name: Install runtime prerequisites
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb
|
||||
# test:electron поднимает реальный electron — ему нужны GTK/ATK/ALSA-библиотеки,
|
||||
# которых нет в наборе `playwright --with-deps chromium` (chromium на другом тулките).
|
||||
# Симптом без них: electron «error while loading shared libraries: libgtk-3.so.0».
|
||||
# Ubuntu 24.04 (noble) — пакеты с суффиксом t64; ставим по одному, чтобы расхождение
|
||||
# имени пакета не валило весь шаг.
|
||||
for pkg in libgtk-3-0t64 libnotify4 libnss3 libxss1 libxtst6 libatspi2.0-0t64 libasound2t64; do
|
||||
sudo apt-get install -y "$pkg" || true
|
||||
done
|
||||
cd components/auth && pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run cross-runtime smoke
|
||||
run: pnpm test:cross-runtime
|
||||
@@ -25,3 +25,6 @@ components/contracts/cpp/ledger2/scripts/out/
|
||||
.pnpm-store
|
||||
schema.gql
|
||||
.claude
|
||||
|
||||
# CoopID: file-based Docker Secrets (генерируются scripts/coopid-gen-secrets.sh)
|
||||
infra/coopid/secrets/
|
||||
|
||||
@@ -45,14 +45,6 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
|
||||
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
|
||||
COPY . .
|
||||
|
||||
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
|
||||
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
|
||||
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
|
||||
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
|
||||
# Отключаем сами загрузки, пакеты всё равно ставятся.
|
||||
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
|
||||
PUPPETEER_SKIP_DOWNLOAD=true
|
||||
|
||||
# Используем существующий lockfile. Если он не совпадает с workspace —
|
||||
# падать сразу, не дрейфовать незаметно.
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineBuildConfig } from 'unbuild'
|
||||
|
||||
// Один dual-выход (ESM .mjs + CJS .cjs + .d.ts) обслуживает все три target'а:
|
||||
// браузер и desktop-runtime берут import→.mjs, Node — require→.cjs (exports-map).
|
||||
export default defineBuildConfig({
|
||||
entries: ['src/index'],
|
||||
declaration: true,
|
||||
clean: true,
|
||||
rollup: {
|
||||
emitCJS: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// @ts-check
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
type: 'lib',
|
||||
rules: {
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
'no-console': 'off',
|
||||
'ts/explicit-function-return-type': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@coopenomics/auth",
|
||||
"type": "module",
|
||||
"version": "2026.6.9-18",
|
||||
"private": false,
|
||||
"packageManager": "pnpm@9.9.0",
|
||||
"description": "SDK аутентификации CoopID: OIDC, vault, кошелёк и подпись для браузера, Node и desktop",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"keywords": [],
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"./dist/*",
|
||||
"./dist/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "pnpm run typecheck",
|
||||
"build": "unbuild",
|
||||
"dev": "unbuild --stub",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:node": "vitest run test/cross-runtime",
|
||||
"test:browser": "vitest run --config vitest.config.browser.ts",
|
||||
"test:electron": "node test/cross-runtime/run-electron.mjs",
|
||||
"test:cross-runtime": "pnpm run build && pnpm run test:node && pnpm run test:browser && pnpm run test:electron",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@wharfkit/antelope": "^1.1.1",
|
||||
"@wharfkit/session": "^1.4.0",
|
||||
"cooptypes": "workspace:*",
|
||||
"jose": "^6.2.2",
|
||||
"oidc-client-ts": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.3.2",
|
||||
"@types/node": "^22.5.4",
|
||||
"@vitest/browser": "2.1.9",
|
||||
"electron": "36.9.5",
|
||||
"eslint": "^9.9.1",
|
||||
"playwright": "1.59.1",
|
||||
"typescript": "^5.5.4",
|
||||
"unbuild": "^2.0.0",
|
||||
"vite": "^5.4.3",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* participant_certificate: чтение claims на клиенте (ЛК, Story 1.9) и offline-
|
||||
* верификация (Story 4.4). Здесь — только ДЕКОДИРОВАНИЕ payload и производные
|
||||
* (статус, человекочитаемые типы верификации). Проверка подписи против trust
|
||||
* anchor — отдельно (verifyOffline, chain/), для отображения в ЛК не требуется.
|
||||
*/
|
||||
import { decodeJwt } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
export * from './schema-policy'
|
||||
|
||||
export interface CoopChainLink {
|
||||
account: string
|
||||
public_key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждённый тип верификации в удостоверении (Story 4.3). Структурная форма
|
||||
* для RP: что подтверждено (`type`), когда (`verified_at`, ISO-8601 UTC) и на каком
|
||||
* основании (`source`).
|
||||
*/
|
||||
export interface VerificationTypeClaim {
|
||||
type: string
|
||||
verified_at: string
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Claims participant_certificate (зеркало payload контроллера, Story 1.8). */
|
||||
export interface ParticipantCertificateClaims {
|
||||
iss: string
|
||||
/** UUID пайщика */
|
||||
sub: string
|
||||
/** серийный номер удостоверения */
|
||||
jti: string
|
||||
iat: number
|
||||
exp: number
|
||||
coopname: string
|
||||
coop_chain: CoopChainLink[]
|
||||
verification_types: VerificationTypeClaim[]
|
||||
identification: Record<string, unknown> | null
|
||||
claim_schema_version: string
|
||||
/** 152-ФЗ-обязательство RP удалить данные пайщика (Story 4.8), напр. `erase_on_exclusion`. */
|
||||
data_retention_contract: string
|
||||
/** Дедлайн удаления данных RP, unix-секунды (`iat + 30 дней`, Story 4.8). */
|
||||
retention_deadline_ts: number
|
||||
}
|
||||
|
||||
/** Нормализовать сырой claim verification_types в структурную форму (Story 4.3). */
|
||||
function normalizeVerificationTypes(raw: unknown): VerificationTypeClaim[] {
|
||||
if (!Array.isArray(raw))
|
||||
return []
|
||||
return raw
|
||||
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null && typeof (e as Record<string, unknown>).type === 'string')
|
||||
.map(e => ({
|
||||
type: String(e.type),
|
||||
verified_at: String(e.verified_at ?? ''),
|
||||
source: String(e.source ?? ''),
|
||||
}))
|
||||
}
|
||||
|
||||
export type CertificateStatus = 'active' | 'expiring' | 'expired'
|
||||
|
||||
/** Окно «истекает» до exp (24ч по умолчанию у сертификата; здесь — последний час). */
|
||||
export const CERTIFICATE_EXPIRING_WINDOW_MS = 60 * 60 * 1000
|
||||
|
||||
/** Человекочитаемые описания типов верификации (claim `verification_types`). */
|
||||
export const VERIFICATION_TYPE_LABELS: Record<string, string> = {
|
||||
coop_baseline: 'Базовое подтверждение кооперативом',
|
||||
}
|
||||
|
||||
/** Описание типа верификации; неизвестный — отдаём как есть (forward-compat). */
|
||||
export function verificationTypeLabel(type: string): string {
|
||||
return VERIFICATION_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
/**
|
||||
* Декодировать compact JWS в claims БЕЗ проверки подписи (для отображения в ЛК).
|
||||
* Бросает AuthV2Error при структурно некорректном сертификате.
|
||||
*/
|
||||
export function decodeParticipantCertificate(jws: string): ParticipantCertificateClaims {
|
||||
let raw: Record<string, unknown>
|
||||
try {
|
||||
raw = decodeJwt(jws) as Record<string, unknown>
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Некорректный participant_certificate: не удалось прочитать claims')
|
||||
}
|
||||
if (typeof raw.jti !== 'string' || typeof raw.exp !== 'number' || typeof raw.sub !== 'string')
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'participant_certificate без обязательных claims (jti/exp/sub)')
|
||||
|
||||
return {
|
||||
iss: String(raw.iss ?? ''),
|
||||
sub: raw.sub,
|
||||
jti: raw.jti,
|
||||
iat: Number(raw.iat ?? 0),
|
||||
exp: raw.exp,
|
||||
coopname: String(raw.coopname ?? ''),
|
||||
coop_chain: Array.isArray(raw.coop_chain) ? (raw.coop_chain as CoopChainLink[]) : [],
|
||||
verification_types: normalizeVerificationTypes(raw.verification_types),
|
||||
identification: (raw.identification as Record<string, unknown> | null) ?? null,
|
||||
claim_schema_version: String(raw.claim_schema_version ?? ''),
|
||||
data_retention_contract: String(raw.data_retention_contract ?? ''),
|
||||
retention_deadline_ts: Number(raw.retention_deadline_ts ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** Статус по сроку действия: expired / expiring (близко к exp) / active. */
|
||||
export function certificateStatus(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): CertificateStatus {
|
||||
const expMs = claims.exp * 1000
|
||||
if (nowMs >= expMs)
|
||||
return 'expired'
|
||||
if (expMs - nowMs <= CERTIFICATE_EXPIRING_WINDOW_MS)
|
||||
return 'expiring'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
/**
|
||||
* Окно упреждающего перезапроса сертификата (Story 4.6): за 5 минут до `exp` SDK
|
||||
* молча обновляет удостоверение, чтобы у короткоживущего cert (дефолт 1ч) не было
|
||||
* разрыва доступа на стыке.
|
||||
*/
|
||||
export const CERTIFICATE_RENEWAL_LEAD_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Сколько миллисекунд ждать до момента перезапроса (`exp − lead`). Никогда не
|
||||
* отрицательно: если до `exp` осталось ≤ lead (или cert уже истёк) — 0 (перезапрос сразу).
|
||||
*/
|
||||
export function computeRenewalDelayMs(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): number {
|
||||
const renewAtMs = claims.exp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS
|
||||
return Math.max(0, renewAtMs - nowMs)
|
||||
}
|
||||
|
||||
/** Управление запланированным авто-обновлением сертификата. */
|
||||
export interface CertificateRenewalHandle {
|
||||
/** Отменить запланированный перезапрос (например, при logout). */
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/** Параметры планировщика — для подмены времени/таймеров в тестах. */
|
||||
export interface ScheduleCertificateRenewalOptions {
|
||||
now?: () => number
|
||||
setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>
|
||||
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Запланировать silent renewal сертификата за 5 минут до `exp` (Story 4.6). По
|
||||
* срабатыванию таймера вызывает `renew()` — хост-колбэк, который обновляет access
|
||||
* через `refresh_token` и тянет свежий cert (`getParticipantCertificate`), — затем
|
||||
* перепланирует от `exp` нового удостоверения. Кросс-рантайм: SDK владеет ЛОГИКОЙ
|
||||
* планирования, а транспорт/refresh — на стороне приложения (как и весь fetch в SDK).
|
||||
* Бесконечный цикл сам себя продлевает, пока не вызван `cancel()`. Ошибка `renew`
|
||||
* не роняет процесс (логируется), но и НЕ перепланирует — повторную попытку инициирует хост.
|
||||
*/
|
||||
export function scheduleCertificateRenewal(
|
||||
initialJws: string,
|
||||
renew: () => Promise<string>,
|
||||
options: ScheduleCertificateRenewalOptions = {},
|
||||
): CertificateRenewalHandle {
|
||||
const now = options.now ?? (() => Date.now())
|
||||
const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms))
|
||||
const clearTimer = options.clearTimer ?? (h => clearTimeout(h))
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let cancelled = false
|
||||
|
||||
const scheduleFrom = (jws: string): void => {
|
||||
if (cancelled)
|
||||
return
|
||||
const claims = decodeParticipantCertificate(jws)
|
||||
const delay = computeRenewalDelayMs(claims, now())
|
||||
timer = setTimer(() => {
|
||||
void renew()
|
||||
.then((nextJws) => {
|
||||
if (!cancelled)
|
||||
scheduleFrom(nextJws)
|
||||
})
|
||||
.catch(() => {
|
||||
// best-effort: сбой обновления (нет сети/refresh истёк) не роняет цикл;
|
||||
// повторную попытку инициирует приложение (например, при следующем действии).
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
scheduleFrom(initialJws)
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true
|
||||
if (timer !== null)
|
||||
clearTimer(timer)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Политика версий схемы claims удостоверения на стороне SDK (Story 4.10).
|
||||
* Зеркало публичной `/.well-known/coopid-schema-policy.json` контроллера: типы,
|
||||
* сравнение версий и кэш с TTL 24ч. `verifyOffline` сверяет `claim_schema_version`
|
||||
* проверяемого сертификата с минимально поддерживаемой версией из этого кэша.
|
||||
*
|
||||
* Кросс-рантайм: SDK владеет ЛОГИКОЙ кеша/сравнения; сам HTTP-fetch политики
|
||||
* инжектируется хостом (как и весь fetch в SDK — chain/ остаётся чисто офлайновым).
|
||||
*/
|
||||
|
||||
/** Путь публичной политики версий схемы у контроллера кооператива. */
|
||||
export const SCHEMA_POLICY_WELL_KNOWN_PATH = '/.well-known/coopid-schema-policy.json'
|
||||
|
||||
/** TTL кэша политики: офлайн принимает закэшированную min-версию до 24 часов (FR72). */
|
||||
export const SCHEMA_POLICY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export interface CoopIdSchemaPolicy {
|
||||
current_version: string
|
||||
min_supported_version: string
|
||||
deprecation: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Сравнить две версии схемы. Версии — целочисленные строки (`'0'`, `'1'`, …):
|
||||
* сравниваем численно, при нечисловой версии — лексикографический фолбэк.
|
||||
* `< 0` если a старее b, `0` если равны, `> 0` если a новее.
|
||||
*/
|
||||
export function compareSchemaVersions(a: string, b: string): number {
|
||||
const na = Number(a)
|
||||
const nb = Number(b)
|
||||
if (Number.isFinite(na) && Number.isFinite(nb))
|
||||
return na - nb
|
||||
if (a === b)
|
||||
return 0
|
||||
return a < b ? -1 : 1
|
||||
}
|
||||
|
||||
/** Поддерживается ли версия схемы (не старее минимально поддерживаемой). */
|
||||
export function isSchemaVersionSupported(version: string, minSupported: string): boolean {
|
||||
return compareSchemaVersions(version, minSupported) >= 0
|
||||
}
|
||||
|
||||
export interface SchemaPolicyCacheOptions {
|
||||
/** Хост-колбэк, тянущий политику с `SCHEMA_POLICY_WELL_KNOWN_PATH` (fetch — у приложения). */
|
||||
fetchPolicy: () => Promise<CoopIdSchemaPolicy>
|
||||
/** «Сейчас» в мс (инъекция для детерминизма/тестов). */
|
||||
now?: () => number
|
||||
/** TTL кэша в мс (по умолчанию 24ч). */
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export interface SchemaPolicyCache {
|
||||
/** Политика из кэша (свежий fetch при истёкшем TTL; stale-кэш при офлайне). */
|
||||
getPolicy: () => Promise<CoopIdSchemaPolicy>
|
||||
/** Минимально поддерживаемая версия — для передачи в `verifyOffline`. */
|
||||
getMinSupportedVersion: () => Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Кэш политики версий схемы с TTL 24ч (Story 4.10, FR72). В пределах TTL отдаёт
|
||||
* закэшированную политику без сети. По истечении TTL пробует обновить; если fetch
|
||||
* упал (офлайн/недоступность) — отдаёт последнюю валидную политику (недоступность
|
||||
* сети НЕ роняет офлайн-проверку; короткий cert TTL ограничивает риск устаревания).
|
||||
* Нет кэша и fetch упал → пробрасывает ошибку (политику взять неоткуда).
|
||||
*/
|
||||
export function createSchemaPolicyCache(options: SchemaPolicyCacheOptions): SchemaPolicyCache {
|
||||
const now = options.now ?? (() => Date.now())
|
||||
const ttlMs = options.ttlMs ?? SCHEMA_POLICY_CACHE_TTL_MS
|
||||
let cached: { policy: CoopIdSchemaPolicy, fetchedAt: number } | null = null
|
||||
|
||||
const getPolicy = async (): Promise<CoopIdSchemaPolicy> => {
|
||||
if (cached && now() - cached.fetchedAt < ttlMs)
|
||||
return cached.policy
|
||||
try {
|
||||
const policy = await options.fetchPolicy()
|
||||
cached = { policy, fetchedAt: now() }
|
||||
return policy
|
||||
}
|
||||
catch (error) {
|
||||
if (cached)
|
||||
return cached.policy
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getPolicy,
|
||||
getMinSupportedVersion: async () => (await getPolicy()).min_supported_version,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { CoopChainLink } from '../certificate'
|
||||
/**
|
||||
* Цепочка доверия: офлайн-проверка participant_certificate walk'ом
|
||||
* `ano → voskhod → vostok → participant` от embedded trust anchor.
|
||||
* Реализация verify — Story 4.4; чтение ключей `cert` из COOPOS — здесь.
|
||||
*/
|
||||
import { APIClient } from '@wharfkit/antelope'
|
||||
import { base64url } from 'jose'
|
||||
import { compareSchemaVersions } from '../certificate/schema-policy'
|
||||
import { TRUST_ANCHOR_ANO_CERT_PUBKEY } from '../config/trust-anchor'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
/**
|
||||
* Публичный ключ permission `cert` аккаунта в COOPOS (Story 1.3).
|
||||
* Возвращает строку в формате Antelope (`PUB_K1_...`).
|
||||
*/
|
||||
export async function readCertPublicKey(rpcUrl: string, account: string): Promise<string> {
|
||||
const client = new APIClient({ url: rpcUrl })
|
||||
const acc = await client.v1.chain.get_account(account).catch((e: unknown) => {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.NetworkError,
|
||||
`COOPOS недоступен или аккаунт ${account} не найден: ${e instanceof Error ? e.message : String(e)}`,
|
||||
)
|
||||
})
|
||||
const cert = acc.permissions.find(p => String(p.perm_name) === 'cert')
|
||||
const key = cert?.required_auth.keys[0]?.key
|
||||
if (!cert || !key) {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.ChainVerificationFailed,
|
||||
`На аккаунте ${account} нет permission cert с ключом — цепочка доверия не настроена (Story 1.3).`,
|
||||
)
|
||||
}
|
||||
// MVP-инвариант: cert — строго single-key (multi-sig для ano — Growth).
|
||||
// Молча взять keys[0] у multi-sig значило бы проверять подпись против
|
||||
// одного из N ключей — это дыра в цепочке доверия.
|
||||
const auth = cert.required_auth
|
||||
if (Number(auth.threshold) !== 1 || auth.keys.length !== 1 || auth.accounts.length > 0 || auth.waits.length > 0) {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.ChainVerificationFailed,
|
||||
`Permission cert на ${account} не является single-key (threshold=${auth.threshold}, keys=${auth.keys.length}) — не поддерживается MVP-верификацией.`,
|
||||
)
|
||||
}
|
||||
return key.toString()
|
||||
}
|
||||
|
||||
export type VerifyOfflineReason =
|
||||
| 'malformed_certificate' // не compact JWS / нет обязательных claims (coop_chain, exp)
|
||||
| 'unsupported_alg' // alg ≠ ES256K
|
||||
| 'expired' // exp в прошлом относительно now
|
||||
| 'untrusted_anchor' // coop_chain не укоренён в известном trust-anchor `ano`
|
||||
| 'untrusted_issuer' // звено цепи (в т.ч. издатель) не совпало с доверенным кэшем ключей
|
||||
| 'signature_mismatch' // подпись не сходится с ключом издателя
|
||||
| 'unsupported_schema_version' // claim_schema_version старее min_supported_version политики
|
||||
|
||||
export interface VerifyOfflineResult {
|
||||
valid: boolean
|
||||
/** Причина отказа, если valid=false. Офлайн-отзыв (revoked) — вне MVP (Story 4.7). */
|
||||
reason?: VerifyOfflineReason
|
||||
/** Аккаунт-издатель (последнее звено coop_chain), под чьим ключом сошлась подпись. */
|
||||
issuer?: string
|
||||
}
|
||||
|
||||
export interface VerifyOfflineOptions {
|
||||
/**
|
||||
* Офлайн-снимок доверенных cert-ключей известных кооперативов
|
||||
* (`chain_manifests_cache`): `account → Antelope public_key`. Источник доверия —
|
||||
* каждое звено `coop_chain` сертификата сверяется с этим набором (закрывает
|
||||
* подделку «свой leaf-ключ + настоящий ano в root»). Без него издатель не
|
||||
* подтверждается → `untrusted_issuer` (fail-closed). Наполнение кэша
|
||||
* (manifest-sync) — отдельная задача.
|
||||
*/
|
||||
trustedKeys?: Record<string, string>
|
||||
/**
|
||||
* Доверенный якорь `ano.cert` (Antelope `PUB_K1_…`). По умолчанию —
|
||||
* `trustedKeys['ano']`, затем вшитый release-pinned `TRUST_ANCHOR_ANO_CERT_PUBKEY`.
|
||||
*/
|
||||
trustAnchor?: string
|
||||
/** «Сейчас» в мс для проверки exp (инъекция для детерминизма/тестов). */
|
||||
now?: number
|
||||
/**
|
||||
* Минимально поддерживаемая версия схемы claims (Story 4.10). Резолвится хостом
|
||||
* из кэша политики (`createSchemaPolicyCache().getMinSupportedVersion`, TTL 24ч).
|
||||
* Если задана и `cert.claim_schema_version` старее неё → `unsupported_schema_version`.
|
||||
* Не задана → ось версии схемы не гейтит (крипто/exp/цепь остаются fail-closed).
|
||||
*/
|
||||
minSchemaVersion?: string
|
||||
}
|
||||
|
||||
/** Порядок группы secp256k1 (n) и его половина — для low-S нормализации подписи. */
|
||||
const SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
|
||||
const SECP256K1_HALF_N = SECP256K1_N >> 1n
|
||||
|
||||
/**
|
||||
* Нормализовать S подписи R||S (64 байта) к нижней половине порядка кривой.
|
||||
* cert подписывает controller через jose+Node KeyObject, а Node-ECDSA может выдать
|
||||
* high-S; wharfkit/noble verify по умолчанию отвергает high-S (`lowS:true`).
|
||||
* Верификация secp256k1 инвариантна к `S ↔ n−S`, поэтому приведение к low-S делает
|
||||
* подпись принимаемой независимо от каноничности подписанта.
|
||||
*/
|
||||
function normalizeLowS(rs: Uint8Array): Uint8Array {
|
||||
let s = 0n
|
||||
for (const byte of rs.slice(32, 64))
|
||||
s = (s << 8n) | BigInt(byte)
|
||||
if (s <= SECP256K1_HALF_N)
|
||||
return rs
|
||||
let low = SECP256K1_N - s
|
||||
const out = new Uint8Array(rs) // копия R||S
|
||||
for (let i = 63; i >= 32; i--) {
|
||||
out[i] = Number(low & 0xFFn)
|
||||
low >>= 8n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface CertHead { alg?: string }
|
||||
interface CertPayload { coop_chain?: CoopChainLink[], exp?: number, claim_schema_version?: string }
|
||||
|
||||
/**
|
||||
* Офлайн-валидация удостоверения `participant_certificate` без обращения к сети
|
||||
* (Story 4.4; Vision: NFC-карты, бумажный QR). Проверяет структуру/alg/exp,
|
||||
* укоренение `coop_chain` в trust-anchor `ano`, принадлежность звеньев доверенному
|
||||
* кэшу ключей и подпись против ключа издателя (последнее звено `coop_chain`).
|
||||
*
|
||||
* Полностью офлайн и fail-closed: без доверенного якоря/кэша вердикт всегда
|
||||
* `valid:false`. Отзыв ключа офлайн не проверяется (вне MVP — Story 4.7, Growth
|
||||
* FR65); компенсируется коротким TTL (Story 4.6).
|
||||
*/
|
||||
export async function verifyOffline(certificate: string, options: VerifyOfflineOptions = {}): Promise<VerifyOfflineResult> {
|
||||
const parts = certificate.split('.')
|
||||
if (parts.length !== 3)
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
const [h, p, s] = parts
|
||||
|
||||
let head: CertHead
|
||||
let payload: CertPayload
|
||||
try {
|
||||
head = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
|
||||
payload = JSON.parse(new TextDecoder().decode(base64url.decode(p)))
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
}
|
||||
if (head.alg !== 'ES256K')
|
||||
return { valid: false, reason: 'unsupported_alg' }
|
||||
|
||||
const chain = payload.coop_chain
|
||||
if (!Array.isArray(chain) || chain.length === 0 || typeof payload.exp !== 'number')
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
|
||||
const now = options.now ?? Date.now()
|
||||
if (now >= payload.exp * 1000)
|
||||
return { valid: false, reason: 'expired' }
|
||||
|
||||
// Версия схемы (Story 4.10): если хост передал минимально поддерживаемую версию
|
||||
// (из кэша политики, TTL 24ч), отвергаем сертификаты старее неё — устаревшая схема
|
||||
// claims не должна проходить как валидная. Без minSchemaVersion ось не гейтит.
|
||||
if (options.minSchemaVersion !== undefined
|
||||
&& compareSchemaVersions(payload.claim_schema_version ?? '0', options.minSchemaVersion) < 0) {
|
||||
return { valid: false, reason: 'unsupported_schema_version' }
|
||||
}
|
||||
|
||||
// Якорь: цепь обязана начинаться с известного `ano`.
|
||||
const root = chain[0]
|
||||
const anchor = options.trustAnchor ?? options.trustedKeys?.ano ?? TRUST_ANCHOR_ANO_CERT_PUBKEY
|
||||
if (!anchor || root.account !== 'ano' || root.public_key !== anchor)
|
||||
return { valid: false, reason: 'untrusted_anchor' }
|
||||
|
||||
// Звенья: при наличии кэша каждое звено должно совпасть с доверенным ключом.
|
||||
if (options.trustedKeys) {
|
||||
for (const link of chain) {
|
||||
if (options.trustedKeys[link.account] !== link.public_key)
|
||||
return { valid: false, reason: 'untrusted_issuer' }
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Без кэша подтверждён только якорь; издателя доверять нельзя — fail-closed.
|
||||
return { valid: false, reason: 'untrusted_issuer' }
|
||||
}
|
||||
|
||||
// Подпись: против ключа издателя (последнее звено coop_chain, оно же `kid`).
|
||||
const issuer = chain[chain.length - 1]
|
||||
let rs: Uint8Array
|
||||
try {
|
||||
rs = base64url.decode(s)
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
}
|
||||
if (rs.length !== 64)
|
||||
return { valid: false, reason: 'malformed_certificate' }
|
||||
|
||||
const { PublicKey, Signature } = await import('@wharfkit/antelope')
|
||||
const normalized = normalizeLowS(rs)
|
||||
const signingInput = new TextEncoder().encode(`${h}.${p}`)
|
||||
let ok = false
|
||||
try {
|
||||
const sig = Signature.from({ type: 'K1', r: normalized.slice(0, 32), s: normalized.slice(32, 64), recid: 0 })
|
||||
ok = sig.verifyMessage(signingInput, PublicKey.from(issuer.public_key))
|
||||
}
|
||||
catch {
|
||||
ok = false // некорректный ключ/подпись в сертификате — не валим исключением
|
||||
}
|
||||
if (!ok)
|
||||
return { valid: false, reason: 'signature_mismatch' }
|
||||
|
||||
return { valid: true, issuer: issuer.account }
|
||||
}
|
||||
|
||||
/**
|
||||
* Окно активности одного публичного ключа подписанта в истории его аккаунта.
|
||||
* Блоки, не время: смена authority в COOPOS привязана к блоку.
|
||||
*/
|
||||
export interface KeyValidityWindow {
|
||||
/** публичный ключ в формате Antelope (`PUB_K1_...`) */
|
||||
public_key: string
|
||||
/** блок, с которого ключ стал активным (включительно) */
|
||||
valid_from_block: number
|
||||
/** блок, по который ключ был активным (включительно); null = активен сейчас */
|
||||
valid_to_block: number | null
|
||||
}
|
||||
|
||||
export type VerifyDocumentReason =
|
||||
| 'malformed_jws'
|
||||
| 'unsupported_alg'
|
||||
| 'signature_mismatch' // ни один исторический ключ не даёт валидную подпись (подделка/чужой)
|
||||
| 'key_not_active_at_signing' // подпись валидна под ключом, но он НЕ был активен на signedAtBlock
|
||||
|
||||
export interface VerifyDocumentParams {
|
||||
/** compact JWS из `signDocument` (Story 2.3) */
|
||||
jws: string
|
||||
/**
|
||||
* Блок, на который проверяется активность ключа — `last_irreversible_block_num`
|
||||
* на момент `iat` подписи. Известен держателю документа (метаданные подписи).
|
||||
*/
|
||||
signedAtBlock: number
|
||||
/**
|
||||
* Историческая хронология ключей подписанта (offline-снимок из
|
||||
* `chain_manifests_cache`). Наполнение кэша (ротация ключей / manifest-sync) —
|
||||
* отдельная задача; здесь — чистая проверка по уже полученной хронологии.
|
||||
*/
|
||||
authority: KeyValidityWindow[]
|
||||
}
|
||||
|
||||
export interface VerifyDocumentResult {
|
||||
valid: boolean
|
||||
reason?: VerifyDocumentReason
|
||||
/** ключ, под которым подпись подтвердилась (если valid) */
|
||||
matched_key?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-валидность подписи документа через chain-walk (Story 2.5). Подпись,
|
||||
* сделанная ключом пайщика ДО ротации, остаётся валидной: проверяем, что ключ был
|
||||
* активен в момент подписи (`signedAtBlock` ∈ окно ключа), а не «активен сейчас».
|
||||
* Так ротация ключа не аннулирует юридически значимые документы.
|
||||
*
|
||||
* Полностью офлайн: работает по переданной хронологии `authority` (кэш манифеста),
|
||||
* без сети. Верификация — `verifyMessage` против ключей-кандидатов (а НЕ recover:
|
||||
* `signDocument` отбрасывает recovery-байт, оставляя JWS-формат R||S).
|
||||
*/
|
||||
export async function verifyDocumentOffline(params: VerifyDocumentParams): Promise<VerifyDocumentResult> {
|
||||
const parts = params.jws.split('.')
|
||||
if (parts.length !== 3)
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
const [h, p, s] = parts
|
||||
|
||||
let header: { alg?: string }
|
||||
try {
|
||||
header = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
}
|
||||
if (header.alg !== 'ES256K')
|
||||
return { valid: false, reason: 'unsupported_alg' }
|
||||
|
||||
let rs: Uint8Array
|
||||
try {
|
||||
rs = base64url.decode(s)
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
}
|
||||
if (rs.length !== 64)
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
|
||||
const { PublicKey, Signature } = await import('@wharfkit/antelope')
|
||||
const signingInput = new TextEncoder().encode(`${h}.${p}`)
|
||||
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
|
||||
|
||||
let signedUnderRotatedKey = false
|
||||
for (const w of params.authority) {
|
||||
let ok = false
|
||||
try {
|
||||
ok = sig.verifyMessage(signingInput, PublicKey.from(w.public_key))
|
||||
}
|
||||
catch {
|
||||
ok = false // некорректный ключ в хронологии — пропускаем, не валим всю проверку
|
||||
}
|
||||
if (!ok)
|
||||
continue
|
||||
const active = params.signedAtBlock >= w.valid_from_block
|
||||
&& (w.valid_to_block === null || params.signedAtBlock <= w.valid_to_block)
|
||||
if (active)
|
||||
return { valid: true, matched_key: w.public_key }
|
||||
// подпись валидна, но этот ключ не был активен в момент подписи — продолжаем
|
||||
// искать (вдруг другой ключ окна подходит); запоминаем для точной причины.
|
||||
signedUnderRotatedKey = true
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
reason: signedUnderRotatedKey ? 'key_not_active_at_signing' : 'signature_mismatch',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Публичные RPC-ноды COOPOS — fallback для чтения ключей/manifest'ов,
|
||||
* когда нода кооператива недоступна (используется начиная со Story 9.5).
|
||||
*
|
||||
* TODO(Story 9.5): наполнить актуальным списком публичных нод сети.
|
||||
*/
|
||||
export const COOPOS_PUBLIC_NODES: readonly string[] = []
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Embedded trust anchor цепочки доверия: публичный ключ `ano.cert` в
|
||||
* Antelope-формате (`PUB_K1_…`, кривая secp256k1). Якорь вшивается в пакет
|
||||
* (release-pinned), чтобы `verifyOffline()` укоренял `coop_chain` без сети.
|
||||
*
|
||||
* Формат — Antelope-строка, а не JWK: вся цепь (`coop_chain`, `readCertPublicKey`,
|
||||
* wharfkit-верификация) оперирует `PUB_K1_…`; JWK был несравним со звеньями
|
||||
* (реконсиляция скелета 1.2 ↔ реальности, Story 4.4).
|
||||
*
|
||||
* TODO(release): подставить реальный ключ после создания permission `cert` на
|
||||
* аккаунте `ano` в COOPOS (hash-pinned bundle в consuming-приложениях, NFR).
|
||||
*/
|
||||
export const TRUST_ANCHOR_ANO_CERT_PUBKEY: string | null = null
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Зеркало enum'а ошибок auth-v2 контроллера (источник истины — controller,
|
||||
* появится в Story 1.11). Расширяется синхронно с серверной стороной.
|
||||
*/
|
||||
export enum AuthV2ErrorCode {
|
||||
NotImplemented = 'not_implemented',
|
||||
InvalidCredentials = 'invalid_credentials',
|
||||
WeakPassword = 'weak_password',
|
||||
SessionBindingReused = 'session_binding_reused',
|
||||
SessionBindingExpired = 'session_binding_expired',
|
||||
TimestampTooOld = 'timestamp_too_old',
|
||||
VaultDecryptionFailed = 'vault_decryption_failed',
|
||||
CertificateExpired = 'certificate_expired',
|
||||
CertificateRevoked = 'certificate_revoked',
|
||||
ChainVerificationFailed = 'chain_verification_failed',
|
||||
CooposDegraded = 'coopos_degraded',
|
||||
TooManyAttempts = 'too_many_attempts',
|
||||
TooManyRecoveryAttempts = 'too_many_recovery_attempts',
|
||||
InvalidTwoFactorCode = 'invalid_2fa_code',
|
||||
TwoFactorNotEnrolled = 'two_factor_not_enrolled',
|
||||
InvalidRecoveryToken = 'invalid_recovery_token',
|
||||
InvalidOfflineCode = 'invalid_offline_code',
|
||||
InsufficientVerification = 'insufficient_verification',
|
||||
NetworkError = 'network_error',
|
||||
WalletLocked = 'wallet_locked',
|
||||
ClientWalletMismatch = 'client_wallet_mismatch',
|
||||
/** Клиентский код: high-stakes-действие (экспорт удостоверения с PII) не подтверждено. */
|
||||
ConsentRequired = 'consent_required',
|
||||
}
|
||||
|
||||
/**
|
||||
* Ошибки SDK в формате OAuth 2.0 ({ error, error_description }): код — машинный,
|
||||
* description — человеко-читаемое сообщение для UI.
|
||||
*/
|
||||
export class AuthV2Error extends Error {
|
||||
readonly code: AuthV2ErrorCode
|
||||
|
||||
constructor(code: AuthV2ErrorCode, description: string) {
|
||||
super(description)
|
||||
this.name = 'AuthV2Error'
|
||||
this.code = code
|
||||
}
|
||||
|
||||
toJSON(): { error: AuthV2ErrorCode, error_description: string } {
|
||||
return { error: this.code, error_description: this.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Внутренний помощник для каркаса: единообразный отказ нереализованных методов. */
|
||||
export function notImplemented(method: string): never {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.NotImplemented,
|
||||
`Метод ${method}() ещё не реализован: скелет SDK (Story 1.2), реализация приходит историями Эпиков 1–2.`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекомендованное действие для UI при ошибке — определяет, какую кнопку/подсказку
|
||||
* показать пайщику (Story 1.11).
|
||||
*/
|
||||
export type AuthV2ErrorAction =
|
||||
| 'retry' // повторить ввод/вход
|
||||
| 'recover' // предложить восстановление доступа («Восстановить доступ»)
|
||||
| 'check_connection' // проверить интернет
|
||||
| 'contact_support' // обратиться в кооператив
|
||||
| 'none'
|
||||
|
||||
/** Описание ошибки для отображения: сообщение + действие + судьба сессии. */
|
||||
export interface AuthV2ErrorView {
|
||||
/** машинный код (null — неожиданная, не-типизированная ошибка) */
|
||||
code: AuthV2ErrorCode | null
|
||||
/** человеко-читаемое сообщение для UI */
|
||||
message: string
|
||||
/** что предложить пайщику */
|
||||
action: AuthV2ErrorAction
|
||||
/**
|
||||
* Сохранять ли текущую сессию. `true` — НЕ разлогинивать (сбой временный/внешний:
|
||||
* нет связи, кооператив недоступен, кошелёк заперт) — NFR20/FR50: оффлайн не
|
||||
* выкидывает пайщика. `false` — сбой требует повторного входа.
|
||||
*/
|
||||
keepSession: boolean
|
||||
}
|
||||
|
||||
type AuthV2ErrorViewBody = Omit<AuthV2ErrorView, 'code'>
|
||||
|
||||
/**
|
||||
* Каталог человеко-читаемых сообщений по кодам ошибок (Story 1.11). Источник
|
||||
* UI-текстов — здесь; контроллер отдаёт только машинный `error`, клиент по нему
|
||||
* выбирает сообщение и actionable-подсказку. Тексты — по-русски, без раскрытия
|
||||
* технических деталей (например, какое именно поле логина неверно — security).
|
||||
*/
|
||||
export const AUTH_V2_ERROR_VIEWS: Record<AuthV2ErrorCode, AuthV2ErrorViewBody> = {
|
||||
[AuthV2ErrorCode.NotImplemented]: {
|
||||
message: 'Функция пока недоступна.',
|
||||
action: 'none',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidCredentials]: {
|
||||
message: 'Неверный email или пароль.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.WeakPassword]: {
|
||||
message: 'Пароль слишком простой. Используйте не менее 12 символов и не повторяйте типичные сочетания.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.SessionBindingReused]: {
|
||||
message: 'Сессия входа уже использована. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.SessionBindingExpired]: {
|
||||
message: 'Время на подтверждение входа истекло. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.TimestampTooOld]: {
|
||||
message: 'Истекло время на подтверждение входа. Повторите попытку.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.VaultDecryptionFailed]: {
|
||||
message: 'Не удалось расшифровать кошелёк. Попробуйте восстановить доступ.',
|
||||
action: 'recover',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CertificateExpired]: {
|
||||
message: 'Срок действия удостоверения истёк. Войдите заново, чтобы обновить его.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CertificateRevoked]: {
|
||||
message: 'Удостоверение отозвано. Обратитесь в кооператив.',
|
||||
action: 'contact_support',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.ChainVerificationFailed]: {
|
||||
message: 'Не удалось подтвердить подпись. Обратитесь в поддержку кооператива.',
|
||||
action: 'contact_support',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CooposDegraded]: {
|
||||
message: 'Кооператив временно недоступен. Повторите попытку позже.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.TooManyAttempts]: {
|
||||
message: 'Слишком много попыток. Подождите немного и попробуйте снова.',
|
||||
action: 'retry',
|
||||
// временный троттлинг — не разлогиниваем пайщика, просто просим подождать.
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.TooManyRecoveryAttempts]: {
|
||||
message: 'Слишком много запросов на восстановление. Подождите и попробуйте позже.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidTwoFactorCode]: {
|
||||
message: 'Неверный код из приложения-аутентификатора. Проверьте код и попробуйте снова.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.TwoFactorNotEnrolled]: {
|
||||
message: 'Второй фактор не подключён.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidRecoveryToken]: {
|
||||
message: 'Ссылка восстановления недействительна или истекла. Запросите восстановление заново.',
|
||||
action: 'recover',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidOfflineCode]: {
|
||||
message: 'Код восстановления неверен или уже использован.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.InsufficientVerification]: {
|
||||
message: 'Недостаточный уровень верификации для этого действия. Обратитесь в кооператив.',
|
||||
action: 'contact_support',
|
||||
// авторизационное ограничение по уровню доверия — сессия валидна, не разлогиниваем.
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.NetworkError]: {
|
||||
message: 'Нет связи с кооперативом. Проверьте интернет.',
|
||||
action: 'check_connection',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.WalletLocked]: {
|
||||
message: 'Кошелёк заблокирован. Введите пароль для доступа к ключу.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.ClientWalletMismatch]: {
|
||||
message: 'Ключ в этом браузере не соответствует аккаунту. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.ConsentRequired]: {
|
||||
message: 'Для экспорта удостоверения нужно подтверждение.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** Безопасный фолбэк для неожиданной (не-AuthV2) ошибки — без утечки технических деталей. */
|
||||
const GENERIC_ERROR_VIEW: AuthV2ErrorViewBody = {
|
||||
message: 'Не удалось выполнить операцию. Попробуйте ещё раз.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Превратить любую брошенную SDK ошибку в готовое к показу описание (Story 1.11):
|
||||
* человеко-читаемое сообщение, рекомендованное действие и флаг сохранения сессии.
|
||||
* Не-типизированные ошибки сводятся к безопасному фолбэку (не показываем `e.message`).
|
||||
*/
|
||||
export function describeAuthV2Error(error: unknown): AuthV2ErrorView {
|
||||
if (error instanceof AuthV2Error)
|
||||
return { code: error.code, ...AUTH_V2_ERROR_VIEWS[error.code] }
|
||||
return { code: null, ...GENERIC_ERROR_VIEW }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Экспорт удостоверения наружу (QR для Vision/предъявления). В MVP существует
|
||||
* только ПОЛНАЯ форма (`full_certificate`) — она несёт PII пайщика, поэтому
|
||||
* огорожена двумя барьерами (разблокированный vault + явное согласие).
|
||||
*
|
||||
* ROADMAP (Growth): анонимная форма `proof_of_membership` — доказательство членства
|
||||
* БЕЗ PII, с коротким сроком (`exp ≤ 24ч`). Она появится отдельным методом
|
||||
* `exportProofQR()`. В MVP его НЕТ намеренно: пока anonymous-формат не существует,
|
||||
* вызвать `exportProofQR()` нельзя (ошибка компиляции) — чтобы по ошибке не выпустить
|
||||
* PII-полный сертификат под видом anonymous.
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { isWalletUnlocked } from '../wallet'
|
||||
|
||||
export interface ExportFullQROptions {
|
||||
/**
|
||||
* Колбэк high-stakes-consent диалога: хост показывает пайщику явное подтверждение
|
||||
* («вы экспортируете удостоверение с персональными данными») и резолвит `true`,
|
||||
* только если пайщик согласился. `false`/reject → экспорт отклоняется.
|
||||
*/
|
||||
confirm: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспорт ПОЛНОГО participant_certificate (с PII) для предъявления/QR. Доступен
|
||||
* только когда (1) vault разблокирован — экспортирует владелец активной сессии, не
|
||||
* случайный держатель запертого устройства; (2) пайщик явно подтвердил high-stakes
|
||||
* consent. Возвращает payload-байты удостоверения; кодирование в визуальный QR —
|
||||
* на стороне хоста/Vision (SDK кросс-рантайм, QR-рендерер не тянет).
|
||||
*/
|
||||
export async function exportFullQR(certificate: string, options: ExportFullQROptions): Promise<Uint8Array> {
|
||||
if (!isWalletUnlocked())
|
||||
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Экспорт удостоверения возможен только при разблокированном кошельке')
|
||||
|
||||
const consented = await options.confirm().catch(() => false)
|
||||
if (!consented)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ConsentRequired, 'Экспорт удостоверения с персональными данными требует явного подтверждения')
|
||||
|
||||
return new TextEncoder().encode(certificate)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type { CertificateRenewalHandle, CertificateStatus, CoopChainLink, CoopIdSchemaPolicy, ParticipantCertificateClaims, ScheduleCertificateRenewalOptions, SchemaPolicyCache, SchemaPolicyCacheOptions, VerificationTypeClaim } from './certificate'
|
||||
export {
|
||||
CERTIFICATE_EXPIRING_WINDOW_MS,
|
||||
CERTIFICATE_RENEWAL_LEAD_MS,
|
||||
certificateStatus,
|
||||
compareSchemaVersions,
|
||||
computeRenewalDelayMs,
|
||||
createSchemaPolicyCache,
|
||||
decodeParticipantCertificate,
|
||||
isSchemaVersionSupported,
|
||||
scheduleCertificateRenewal,
|
||||
SCHEMA_POLICY_CACHE_TTL_MS,
|
||||
SCHEMA_POLICY_WELL_KNOWN_PATH,
|
||||
VERIFICATION_TYPE_LABELS,
|
||||
verificationTypeLabel,
|
||||
} from './certificate'
|
||||
|
||||
export type { KeyValidityWindow, VerifyDocumentParams, VerifyDocumentReason, VerifyDocumentResult, VerifyOfflineOptions, VerifyOfflineReason, VerifyOfflineResult } from './chain'
|
||||
|
||||
export { readCertPublicKey, verifyDocumentOffline, verifyOffline } from './chain'
|
||||
/**
|
||||
* @coopenomics/auth — SDK аутентификации CoopID.
|
||||
*
|
||||
* Публичная поверхность зафиксирована скелетом (Story 1.2): все методы
|
||||
* типизированы и бросают AuthV2Error(not_implemented) до своей реализации.
|
||||
*/
|
||||
export { COOPOS_PUBLIC_NODES } from './config/coopos-public-nodes'
|
||||
|
||||
export { TRUST_ANCHOR_ANO_CERT_PUBKEY } from './config/trust-anchor'
|
||||
export { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from './errors'
|
||||
export type { AuthV2ErrorAction, AuthV2ErrorView } from './errors'
|
||||
|
||||
export type { ExportFullQROptions } from './export'
|
||||
export { exportFullQR } from './export'
|
||||
export type { MigrateParams } from './migration'
|
||||
|
||||
export { canonicalMigrationMessage, migrate } from './migration'
|
||||
export type { FlowExecutorParams, HandshakeResult, LoginParams, LoginResult, LogoutParams, SessionTokens } from './oidc'
|
||||
|
||||
export { authenticateWithFlowExecutor, configureCoopId, configureOidc, configureTokenStorage, currentTokens, DEFAULT_AUTHENTICATION_FLOW, getAccessToken, getParticipantCertificate, login, loginWithMagicLink, logout, performTimestampHandshake, recover, restoreSession } from './oidc'
|
||||
export type { SignDocumentParams, TimestampSignature } from './signing'
|
||||
|
||||
export { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from './signing'
|
||||
export type { EncryptedVaultBlob, VaultSubject } from './vault'
|
||||
|
||||
export { ARGON2ID_PARAMS, decryptPrivateKey, deriveKey, encryptPrivateKey } from './vault'
|
||||
export type { StorageAdapter } from './wallet'
|
||||
|
||||
export { clearLocalVault, clearPinCache, DEFAULT_PIN, fetchVaultBlob, getWallet, hasPinCache, isWalletUnlocked, loadLocalVault, lockWallet, persistPinCache, rotateKey, saveLocalVault, saveToVault, storeVaultBlob, unlockWallet, unlockWithPin, Wallet } from './wallet'
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Миграция действующего пайщика «ключ → пароль» (Story 11.4), клиентская сторона.
|
||||
*
|
||||
* Пайщик сейчас владеет только WIF (легаси, без пароля). `migrate` доказывает
|
||||
* владение ключом подписью метки времени (как легаси `login`), сервер проверяет
|
||||
* её против он-чейн active-permission и ставит пароль в authentik (Story 11.1).
|
||||
* Затем клиент шифрует ТОТ ЖЕ WIF новым паролём в vault (Story 11.3) — приватный
|
||||
* ключ на сервер не уходит. Идемпотентно (повтор с тем же ключом/паролём безопасен).
|
||||
*
|
||||
* Подпись биндится к новому паролю через `pw_hash` в каноническом сообщении:
|
||||
* перехваченный proof нельзя переиспользовать с другим паролём.
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { coopIdApiUrl } from '../oidc/client'
|
||||
import { saveToVault, type StorageAdapter } from '../wallet'
|
||||
|
||||
export interface MigrateParams {
|
||||
email: string
|
||||
/** WIF, которым пайщик владеет сейчас (легаси-ключ из поля входа). */
|
||||
privateKey: string
|
||||
newPassword: string
|
||||
/** Если задано — сохранить локальную копию зашифрованного vault'а на устройстве. */
|
||||
storage?: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Каноническое сообщение proof'а миграции — **зеркало** серверного
|
||||
* `canonicalMigrationMessage` (controller). Ключи в фиксированном алфавитном
|
||||
* порядке: клиент и сервер собирают строку байт-в-байт, иначе `recoverPublicKey`
|
||||
* на сервере восстановит чужой ключ. `pw_hash` — sha256-hex нового пароля.
|
||||
*/
|
||||
export function canonicalMigrationMessage(payload: { ts: string, pw_hash: string }): string {
|
||||
return JSON.stringify({ pw_hash: payload.pw_hash, purpose: 'coopid-key-migration', ts: payload.ts })
|
||||
}
|
||||
|
||||
/** sha256-hex (lowercase) — байт-в-байт совпадает с серверным createHash('sha256').digest('hex'). */
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
|
||||
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Разобрать OAuth2-ошибку контроллера ({ error, error_description }) в AuthV2Error. */
|
||||
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
|
||||
return new AuthV2Error((body?.error as AuthV2ErrorCode) ?? fallback, body?.error_description ?? fallbackMsg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполнить миграцию «ключ → пароль». Возвращает `{ username }` (резолвится
|
||||
* сервером по email; нужен как subject vault'а). Бросает `AuthV2Error`:
|
||||
* InvalidCredentials (неверный ключ/email/подпись), WeakPassword, TimestampTooOld,
|
||||
* CooposDegraded, NetworkError.
|
||||
*/
|
||||
export async function migrate(params: MigrateParams): Promise<{ username: string }> {
|
||||
const apiUrl = coopIdApiUrl()
|
||||
const ts = new Date().toISOString()
|
||||
const message = canonicalMigrationMessage({ ts, pw_hash: await sha256Hex(params.newPassword) })
|
||||
|
||||
// Подпись текущим ключом (recoverable SIG_K1_). Невалидный WIF → понятная ошибка.
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
let signature: string
|
||||
try {
|
||||
signature = PrivateKey.from(params.privateKey).signMessage(new TextEncoder().encode(message)).toString()
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Некорректный приватный ключ')
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl}/coop/migration`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: params.email, timestamp: ts, signature, new_password: params.newPassword }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при миграции: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw await authErrorFromResponse(res, AuthV2ErrorCode.InvalidCredentials, `Миграция отклонена (HTTP ${res.status})`)
|
||||
|
||||
const body = (await res.json()) as { username?: string }
|
||||
if (!body?.username)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Миграция не вернула username')
|
||||
|
||||
// Зашифровать текущий WIF новым паролём → server vault (обязательно) + локальная копия.
|
||||
await saveToVault({ apiUrl, account: body.username, privateKey: params.privateKey, password: params.newPassword, storage: params.storage })
|
||||
return { username: body.username }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Первый этап входа CoopID (Story 11.2) — password через authentik:
|
||||
* (1) встроенная форма гонит email+password в flow-executor authentik и
|
||||
* устанавливает сессию (`flow-executor.ts`); (2) `oidc-client-ts` выполняет
|
||||
* `authorization_code`+PKCE МОЛЧА (`signinSilent`, prompt=none) — сессия уже есть,
|
||||
* второго ввода пароля нет. Так клиент видит пароль (нужно для vault, Story 11.3),
|
||||
* не нарушая FR29: запрещённый `grant_type=password` не используется, грант —
|
||||
* стандартный authorization_code+PKCE (Implicit и ROPC запрещены, RFC 9700).
|
||||
*
|
||||
* Конфигурация (client_id/redirect_uri/scope, база controller'а) задаётся
|
||||
* приложением на старте через `configureOidc`/`configureCoopId` — это среда-зависимые
|
||||
* параметры (frontend кооператива), не зашитые в SDK.
|
||||
*/
|
||||
import { type User, UserManager, type UserManagerSettings } from 'oidc-client-ts'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { authenticateWithFlowExecutor } from './flow-executor'
|
||||
|
||||
interface OidcClientConfig {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
scope: string
|
||||
postLogoutRedirectUri?: string
|
||||
/** redirect_uri скрытого silent-iframe для prompt=none authorize (Story 11.2). */
|
||||
silentRedirectUri?: string
|
||||
}
|
||||
|
||||
let oidcConfig: OidcClientConfig | null = null
|
||||
let coopApiUrl: string | null = null
|
||||
|
||||
/** Конфигурация OIDC-клиента authentik (вызывается приложением на старте). */
|
||||
export function configureOidc(config: { clientId: string, redirectUri: string, scope?: string, postLogoutRedirectUri?: string, silentRedirectUri?: string }): void {
|
||||
oidcConfig = {
|
||||
clientId: config.clientId,
|
||||
redirectUri: config.redirectUri,
|
||||
scope: config.scope ?? 'openid profile',
|
||||
postLogoutRedirectUri: config.postLogoutRedirectUri,
|
||||
silentRedirectUri: config.silentRedirectUri,
|
||||
}
|
||||
}
|
||||
|
||||
/** База URL controller'а кооператива (для второго этапа: bind/verify/refresh). */
|
||||
export function configureCoopId(config: { apiUrl: string }): void {
|
||||
coopApiUrl = config.apiUrl.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/** База controller'а или явная ошибка конфигурации. */
|
||||
export function coopIdApiUrl(): string {
|
||||
if (!coopApiUrl)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, 'CoopID не сконфигурирован: вызовите configureCoopId({ apiUrl }) на старте приложения')
|
||||
return coopApiUrl
|
||||
}
|
||||
|
||||
function userManager(issuer: string): UserManager {
|
||||
if (!oidcConfig)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'OIDC не сконфигурирован: вызовите configureOidc({ clientId, redirectUri }) на старте приложения')
|
||||
const settings: UserManagerSettings = {
|
||||
authority: issuer.replace(/\/$/, ''),
|
||||
client_id: oidcConfig.clientId,
|
||||
redirect_uri: oidcConfig.redirectUri,
|
||||
silent_redirect_uri: oidcConfig.silentRedirectUri ?? oidcConfig.redirectUri,
|
||||
scope: oidcConfig.scope,
|
||||
post_logout_redirect_uri: oidcConfig.postLogoutRedirectUri,
|
||||
response_type: 'code', // Authorization Code + PKCE; oidc-client-ts включает PKCE по умолчанию.
|
||||
}
|
||||
return new UserManager(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проводит первый этап входа и возвращает OIDC-User (id_token/access_token +
|
||||
* установленная сессионная cookie authentik, нужная для bind). Story 11.2:
|
||||
* (1) встроенная форма → flow-executor authentik устанавливает сессию (фактор 1);
|
||||
* (2) `signinSilent` выполняет authorization_code+PKCE молча (сессия уже есть,
|
||||
* prompt=none через скрытый iframe `silent_redirect_uri`) — без попапа и без
|
||||
* повторного ввода пароля.
|
||||
*/
|
||||
export async function authenticateWithAuthentik(params: { issuer: string, email: string, password: string, flowSlug?: string }): Promise<User> {
|
||||
// Фактор-1: учётные данные уходят в authentik (а не в наш backend) — ROPC не используется.
|
||||
await authenticateWithFlowExecutor({ issuer: params.issuer, email: params.email, password: params.password, flowSlug: params.flowSlug })
|
||||
// Грант FR29: code+PKCE поверх уже установленной сессии, без интерактивного UI.
|
||||
const um = userManager(params.issuer)
|
||||
let user: User | null
|
||||
try {
|
||||
user = await um.signinSilent()
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, `Не удалось завершить вход через authentik: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!user)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'authentik не вернул сессию после ввода пароля')
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Story 11.2 — встроенный фактор-1 входа CoopID через flow-executor authentik.
|
||||
*
|
||||
* Зачем НЕ popup/redirect: desktop рисует СВОЮ форму email+password и гонит
|
||||
* учётные данные в интерактивный flow-API authentik. Так клиент пайщика ВИДИТ
|
||||
* пароль — это нужно, чтобы шифровать им password-vault (Story 11.3); требование
|
||||
* вынуждено zero-knowledge-инвариантом vault'а (сервер хранит шифр и расшифровать
|
||||
* не может → ключ шифрования обязан быть клиентским секретом, единственный такой
|
||||
* секрет — пароль). FR29 при этом соблюдается: запрещённый `grant_type=password`
|
||||
* НЕ используется — грант остаётся `authorization_code`+PKCE и выполняется в
|
||||
* `client.ts` уже ПОСЛЕ установки сессии (см. `authenticateWithAuthentik`).
|
||||
*
|
||||
* Топология (Эпик 5, Caddyfile): flow-API доступен same-origin по
|
||||
* `{issuer.origin}/api/v3/flows/executor/<slug>/` — Caddy catch-all проксирует всё
|
||||
* на authentik под доменом кооператива. `credentials:'include'` доставляет и
|
||||
* устанавливает сессионную cookie authentik; CSRF-cookie (`authentik_csrf`) эхо-
|
||||
* отправляется в заголовке `X-authentik-CSRF` (authentik требует его на unsafe-
|
||||
* методах API). Same-origin критичен: кросс-домен CSRF-cookie не прочитать, а
|
||||
* сессионная cookie не доедет до последующего authorize.
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
/** Slug стандартного flow аутентификации authentik (identification → password). */
|
||||
export const DEFAULT_AUTHENTICATION_FLOW = 'default-authentication-flow'
|
||||
|
||||
/** Предохранитель от зацикливания на неожиданной последовательности стадий. */
|
||||
const MAX_FLOW_STEPS = 8
|
||||
|
||||
export interface FlowExecutorParams {
|
||||
/** Issuer кооператива (`https://coop.example/application/o/coopid/`); origin — база flow-API. */
|
||||
issuer: string
|
||||
email: string
|
||||
password: string
|
||||
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
|
||||
flowSlug?: string
|
||||
}
|
||||
|
||||
/** Снимок challenge'а flow-executor (берём только нужные поля; authentik отдаёт больше). */
|
||||
interface FlowChallenge {
|
||||
/** `native` | `redirect` | `shell` — `redirect` означает завершение flow (сессия установлена). */
|
||||
type?: string
|
||||
/** Web-компонент текущей стадии: `ak-stage-identification`, `ak-stage-password`, … */
|
||||
component?: string
|
||||
/** Пер-полевые ошибки валидации (неверный пароль → перерисованная стадия с этим полем). */
|
||||
response_errors?: Record<string, Array<{ string?: string, code?: string }>>
|
||||
}
|
||||
|
||||
/** Текущий CSRF-токен authentik из cookie (same-origin). В Node/тестах — пусто. */
|
||||
function csrfHeader(): Record<string, string> {
|
||||
if (typeof document === 'undefined' || !document.cookie)
|
||||
return {}
|
||||
const match = document.cookie.match(/(?:^|;\s*)authentik_csrf=([^;]+)/)
|
||||
return match ? { 'X-authentik-CSRF': decodeURIComponent(match[1]) } : {}
|
||||
}
|
||||
|
||||
/** URL шага flow-executor. `query` пуст: flow запускается напрямую (authorize делает client.ts). */
|
||||
function flowUrl(base: string, slug: string): string {
|
||||
return `${base}/api/v3/flows/executor/${encodeURIComponent(slug)}/?query=${encodeURIComponent('')}`
|
||||
}
|
||||
|
||||
/** Завершение flow: сессия authentik установлена (redirect-терминал или стадия user-login). */
|
||||
function isSuccess(c: FlowChallenge): boolean {
|
||||
return c.type === 'redirect' || c.component === 'xak-flow-redirect' || c.component === 'ak-stage-user-login'
|
||||
}
|
||||
|
||||
/** Доступ отклонён политикой flow (например, аккаунт неактивен). */
|
||||
function isAccessDenied(c: FlowChallenge): boolean {
|
||||
return c.component === 'ak-stage-access-denied'
|
||||
}
|
||||
|
||||
/** Первое человеко-читаемое сообщение об ошибке валидации из challenge'а (если есть). */
|
||||
function firstResponseError(c: FlowChallenge): string | null {
|
||||
const groups = c.response_errors
|
||||
if (!groups)
|
||||
return null
|
||||
for (const key of Object.keys(groups)) {
|
||||
const msg = groups[key]?.find(e => e.string)?.string
|
||||
if (msg)
|
||||
return msg
|
||||
}
|
||||
return Object.keys(groups).length > 0 ? '' : null
|
||||
}
|
||||
|
||||
async function getChallenge(url: string): Promise<FlowChallenge> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, { method: 'GET', credentials: 'include', headers: { accept: 'application/json', ...csrfHeader() } })
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на старте входа: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `flow-executor (старт) вернул HTTP ${res.status}`)
|
||||
return (await res.json()) as FlowChallenge
|
||||
}
|
||||
|
||||
async function postChallenge(url: string, body: Record<string, unknown>): Promise<FlowChallenge> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json', ...csrfHeader() },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при отправке формы входа: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
// 400 — authentik перерисовывает ту же стадию с `response_errors` (неверный пароль);
|
||||
// это валидное тело challenge'а, читаем его. Остальные не-ok — сетевая/конфиг ошибка.
|
||||
if (!res.ok && res.status !== 400)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `flow-executor вернул HTTP ${res.status}`)
|
||||
return (await res.json()) as FlowChallenge
|
||||
}
|
||||
|
||||
/**
|
||||
* Проводит фактор-1 (email+password) через flow-executor authentik и устанавливает
|
||||
* сессионную cookie. Возвращает `void` при успехе (сессия — побочный эффект cookie),
|
||||
* бросает `AuthV2Error(InvalidCredentials)` при неверных учётных данных / отказе и
|
||||
* `AuthV2Error(NetworkError)` при сетевом сбое.
|
||||
*
|
||||
* Поддерживаются стандартные стадии `ak-stage-identification` и `ak-stage-password`
|
||||
* (в т.ч. совмещённая identification с `password_fields`). Интерактивные стадии
|
||||
* вне scope встроенной формы (MFA-валидация, consent) → InvalidCredentials с
|
||||
* пояснением: их обрабатывает отдельный эпик динамического 2FA.
|
||||
*/
|
||||
export async function authenticateWithFlowExecutor(params: FlowExecutorParams): Promise<void> {
|
||||
const base = new URL(params.issuer).origin
|
||||
const url = flowUrl(base, params.flowSlug ?? DEFAULT_AUTHENTICATION_FLOW)
|
||||
|
||||
let challenge = await getChallenge(url)
|
||||
for (let step = 0; step < MAX_FLOW_STEPS; step++) {
|
||||
if (isSuccess(challenge))
|
||||
return
|
||||
|
||||
if (isAccessDenied(challenge))
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Доступ запрещён: проверьте email и пароль')
|
||||
|
||||
const err = firstResponseError(challenge)
|
||||
if (err !== null)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, err || 'Неверный email или пароль')
|
||||
|
||||
switch (challenge.component) {
|
||||
case 'ak-stage-identification':
|
||||
// Совмещённая identification может ждать и пароль (`password_fields`) — отдаём оба.
|
||||
challenge = await postChallenge(url, { uid_field: params.email, password: params.password })
|
||||
break
|
||||
case 'ak-stage-password':
|
||||
challenge = await postChallenge(url, { password: params.password })
|
||||
break
|
||||
default:
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.InvalidCredentials,
|
||||
`Стадия входа «${challenge.component ?? challenge.type ?? 'неизвестно'}» не поддерживается встроенной формой`,
|
||||
)
|
||||
}
|
||||
}
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Вход не завершён: превышено число шагов flow authentik')
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Второй этап входа CoopID — timestamp-signature handshake (Story 1.7), браузер-
|
||||
* агностичная часть фасада логина. Предполагает уже установленную сессию authentik
|
||||
* (cookie доезжает на bind через `credentials: 'include'`); первый этап (password,
|
||||
* Authorization Code + PKCE через oidc-client-ts, FR29) делает `client.ts`.
|
||||
*
|
||||
* Поток: `/coop/session/bind` → `signTimestamp` (ключ берётся из keystore, наружу
|
||||
* не выходит) → `/coop/verify/timestamp`. Полученные токены кладутся в сессию
|
||||
* (`tokens.ts`) и возвращаются вызывающему.
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { signTimestamp } from '../signing'
|
||||
import { setSession } from './tokens'
|
||||
|
||||
export interface HandshakeResult {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
/** compact JWS ES256K, выпускается controller'ом (Story 1.8); может отсутствовать. */
|
||||
participantCertificate?: string
|
||||
/** true → вход выдан в degraded-режиме (узел недоступен / ключ не финализирован, Story 9.6). */
|
||||
degraded: boolean
|
||||
degradedReason?: string
|
||||
}
|
||||
|
||||
interface BindResponse {
|
||||
binding_token: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
interface VerifyResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
participant_certificate?: string
|
||||
degraded?: boolean
|
||||
degraded_reason?: string
|
||||
}
|
||||
|
||||
/** Извлечь AuthV2Error из тела ответа контроллера (OAuth2-формат { error, error_description }). */
|
||||
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
|
||||
const code = (body?.error as AuthV2ErrorCode) ?? fallback
|
||||
return new AuthV2Error(code, body?.error_description ?? fallbackMsg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет handshake второго этапа и сохраняет сессию. `apiUrl` — база controller'а
|
||||
* кооператива (например `https://coop.example`).
|
||||
*/
|
||||
export async function performTimestampHandshake(apiUrl: string): Promise<HandshakeResult> {
|
||||
const base = apiUrl.replace(/\/$/, '')
|
||||
|
||||
// 1. bind: session_binding_token из тела (Эпик 7, D2). credentials:'include' —
|
||||
// чтобы сессионная cookie authentik доехала до controller'а для резолва username.
|
||||
let bindRes: Response
|
||||
try {
|
||||
bindRes = await fetch(`${base}/coop/session/bind`, { method: 'POST', credentials: 'include' })
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на этапе bind: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (bindRes.status === 401 || bindRes.status === 403)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Сессия authentik не подтверждена: пройдите первый этап входа (password)')
|
||||
if (!bindRes.ok)
|
||||
throw await authErrorFromResponse(bindRes, AuthV2ErrorCode.NetworkError, `bind вернул HTTP ${bindRes.status}`)
|
||||
const bind = (await bindRes.json()) as BindResponse
|
||||
if (!bind?.binding_token)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'bind не вернул session_binding_token')
|
||||
|
||||
// 2. подпись канонической метки (jti/sub — из binding_token; ключ — из keystore,
|
||||
// бросит WalletLocked, если кошелёк заперт).
|
||||
const sig = await signTimestamp({ sessionBindingToken: bind.binding_token })
|
||||
|
||||
// 3. verify: доказательство владения ключом → платформенные токены + сертификат.
|
||||
let verifyRes: Response
|
||||
try {
|
||||
verifyRes = await fetch(`${base}/coop/verify/timestamp`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ signature: sig.signature, timestamp: sig.ts, binding_token: bind.binding_token }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна на этапе verify: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!verifyRes.ok)
|
||||
throw await authErrorFromResponse(verifyRes, AuthV2ErrorCode.ChainVerificationFailed, `verify вернул HTTP ${verifyRes.status}`)
|
||||
|
||||
const v = (await verifyRes.json()) as VerifyResponse
|
||||
setSession(base, { accessToken: v.access_token, refreshToken: v.refresh_token })
|
||||
|
||||
return {
|
||||
accessToken: v.access_token,
|
||||
refreshToken: v.refresh_token,
|
||||
participantCertificate: v.participant_certificate,
|
||||
degraded: Boolean(v.degraded),
|
||||
degradedReason: v.degraded_reason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { VaultSubject } from '../vault/types'
|
||||
import type { StorageAdapter } from '../wallet'
|
||||
/**
|
||||
* OIDC-слой: вход через authentik (password + timestamp-signature),
|
||||
* magic-link, recovery и работа с токенами (oidc-client-ts).
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { encryptPrivateKey } from '../vault/encrypt'
|
||||
import { lockWallet, saveLocalVault, unlockWallet } from '../wallet'
|
||||
import { authenticateWithAuthentik, coopIdApiUrl } from './client'
|
||||
import { performTimestampHandshake } from './handshake'
|
||||
import { clearSession, getAccessToken as getStoredAccessToken } from './tokens'
|
||||
|
||||
export { authenticateWithAuthentik, configureCoopId, configureOidc } from './client'
|
||||
export { authenticateWithFlowExecutor, DEFAULT_AUTHENTICATION_FLOW } from './flow-executor'
|
||||
export type { FlowExecutorParams } from './flow-executor'
|
||||
export type { HandshakeResult } from './handshake'
|
||||
export { performTimestampHandshake } from './handshake'
|
||||
export type { SessionTokens } from './tokens'
|
||||
export { configureTokenStorage, currentTokens, restoreSession } from './tokens'
|
||||
|
||||
export interface LoginParams {
|
||||
/** Issuer кооператива, например `https://coop.example/application/o/coopid/` */
|
||||
issuer: string
|
||||
email: string
|
||||
/**
|
||||
* Пароль пайщика (Story 11.2). Уходит во встроенную форму → flow-executor
|
||||
* authentik (фактор 1), а не в наш backend — запрещённый FR29 `grant_type=password`
|
||||
* не используется. Тем же паролем клиент шифрует password-vault (Story 11.3).
|
||||
*/
|
||||
password: string
|
||||
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
|
||||
flowSlug?: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string
|
||||
idToken: string
|
||||
/** compact JWS ES256K, выпускается controller'ом */
|
||||
participantCertificate: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Двухэтапный вход (Story 1.7, обновлён Story 11.2): (1) password через authentik —
|
||||
* встроенная форма гонит email+password в flow-executor (сессия), затем
|
||||
* `authorization_code`+PKCE молча (`signinSilent`); (2) timestamp-signature handshake
|
||||
* против controller'а (bind → подпись ключом из keystore → verify). Перед вызовом
|
||||
* кошелёк должен быть разблокирован (`unlockWallet`), иначе handshake бросит WalletLocked.
|
||||
*
|
||||
* База controller'а берётся из `configureCoopId({ apiUrl })`, OIDC-клиент — из
|
||||
* `configureOidc({ clientId, redirectUri })` (вызываются приложением на старте).
|
||||
*/
|
||||
export async function login(params: LoginParams): Promise<LoginResult> {
|
||||
const apiUrl = coopIdApiUrl()
|
||||
// 1. password-этап: устанавливает сессию authentik (cookie) + отдаёт id_token.
|
||||
const user = await authenticateWithAuthentik({ issuer: params.issuer, email: params.email, password: params.password, flowSlug: params.flowSlug })
|
||||
// 2. timestamp-signature handshake: платформенные токены + удостоверение.
|
||||
const handshake = await performTimestampHandshake(apiUrl)
|
||||
return {
|
||||
accessToken: handshake.accessToken,
|
||||
idToken: user.id_token ?? '',
|
||||
participantCertificate: handshake.participantCertificate ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Разобрать OAuth2-ошибку контроллера ({ error, error_description }) в AuthV2Error. */
|
||||
async function authErrorFromResponse(res: Response, fallback: AuthV2ErrorCode, fallbackMsg: string): Promise<AuthV2Error> {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string, error_description?: string } | null
|
||||
return new AuthV2Error((body?.error as AuthV2ErrorCode) ?? fallback, body?.error_description ?? fallbackMsg)
|
||||
}
|
||||
|
||||
export interface LoginWithMagicLinkParams {
|
||||
/** Issuer кооператива — для повторного входа через authentik уже новым паролём. */
|
||||
issuer: string
|
||||
/** Email пайщика — фактор-1 повторной аутентификации в authentik после смены ключа. */
|
||||
email: string
|
||||
/** Magic-link токен из ссылки восстановления (или `recovery_token` offline-канала, Story 3.4). */
|
||||
token: string
|
||||
/** TOTP-код из приложения-аутентификатора — второй фактор подтверждения (Story 3.2/3.6). */
|
||||
totp: string
|
||||
/** Новый пароль: им шифруется новый vault и он же ставится в authentik (Story 12.1). */
|
||||
newPassword: string
|
||||
/** Slug flow аутентификации authentik (по умолчанию `default-authentication-flow`). */
|
||||
flowSlug?: string
|
||||
/** Если задано — сохранить локальную копию нового зашифрованного vault'а на устройстве. */
|
||||
storage?: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход по magic-link (восстановление доступа) — полный confirm-флоу + повторный вход
|
||||
* (Эпик 12, Story 12.2). Старый ключ пайщиком утрачен (на то и восстановление),
|
||||
* поэтому клиент генерит НОВУЮ пару: приватный шифруется новым паролём в vault и
|
||||
* наружу/на сервер не уходит, on-chain едет только публичный.
|
||||
*
|
||||
* Account на старте неизвестен — magic-link несёт только непрозрачный `token`. Но он и
|
||||
* не нужен заранее: AAD vault'а больше не зависит от account (см. `vault/encrypt.ts`),
|
||||
* а username для повторного входа отдаёт сам `confirm`, резолвнув его из токена. Так
|
||||
* обходимся без отдельного whoami-by-token эндпоинта.
|
||||
*
|
||||
* Шаги: (1) сгенерировать пару; (2) зашифровать новый ключ новым паролём (AAD=тип
|
||||
* субъекта, без account); (3) `POST /coop/recovery/confirm` {token, TOTP, public_key,
|
||||
* vault, password} — сервер (12.1) ставит пароль в authentik, сохраняет vault под нужным
|
||||
* account, ротирует active-ключ (`registrator::changekey`), отзывает старые сессии и
|
||||
* возвращает `{ username }`; (4) повторный вход новым контуром: authentik-сессия новым
|
||||
* паролём → `unlockWallet` по этому username (скачать только что записанный блоб →
|
||||
* расшифровать → keystore) → timestamp-handshake. Ключ к этому моменту уже ротирован
|
||||
* on-chain, поэтому verify увидит новый pubkey (при лаге узла handshake вернёт degraded,
|
||||
* Story 9.6).
|
||||
*/
|
||||
export async function loginWithMagicLink(params: LoginWithMagicLinkParams): Promise<LoginResult> {
|
||||
const apiUrl = coopIdApiUrl()
|
||||
|
||||
// 1. Новая пара ключей (старый утрачен). WIF в формате `5J…`/`5K…` — как везде в системе.
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const newKey = PrivateKey.generate('K1')
|
||||
const newPrivateKey = newKey.toWif()
|
||||
const newPublicKey = newKey.toPublic().toString()
|
||||
|
||||
// 2. Зашифровать новый ключ новым паролём. AAD = тип субъекта (`participant`), а не
|
||||
// account: id в AAD не участвует (см. vault/encrypt.ts), поэтому шифруем, не зная
|
||||
// username. Сервер сохранит блоб под нужным account сам (по recovery-токену).
|
||||
const subject: VaultSubject = { subject_type: 'participant', subject_id: '' }
|
||||
const vaultBlob = await encryptPrivateKey(newPrivateKey, params.newPassword, subject)
|
||||
|
||||
// 3. confirm: токен magic-link + TOTP + новый материал. Тело — контракт RecoveryConfirmBody.
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl}/coop/recovery/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token: params.token, code: params.totp, public_key: newPublicKey, vault: vaultBlob, password: params.newPassword }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при подтверждении восстановления: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (res.status === 429)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.TooManyRecoveryAttempts, 'Слишком много попыток подтверждения, попробуйте позже')
|
||||
if (!res.ok)
|
||||
throw await authErrorFromResponse(res, AuthV2ErrorCode.InvalidRecoveryToken, `Подтверждение восстановления отклонено (HTTP ${res.status})`)
|
||||
|
||||
// confirm вернул account пайщика (резолвнут из токена) — по нему скачаем и
|
||||
// расшифруем только что сохранённый сервером блоб при повторном входе.
|
||||
const confirmed = (await res.json().catch(() => null)) as { username?: string } | null
|
||||
if (!confirmed?.username)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.InvalidRecoveryToken, 'Подтверждение восстановления не вернуло аккаунт')
|
||||
const account = confirmed.username
|
||||
|
||||
// 4. Локальная копия нового блоба (best-effort на устройстве восстановления).
|
||||
if (params.storage)
|
||||
await saveLocalVault(params.storage, account, vaultBlob)
|
||||
|
||||
// 5. Повторный вход новым контуром. unlockWallet забирает только что сохранённый
|
||||
// серверный блоб и расшифровывает новым паролём — заодно round-trip-проверка vault'а.
|
||||
const user = await authenticateWithAuthentik({ issuer: params.issuer, email: params.email, password: params.newPassword, flowSlug: params.flowSlug })
|
||||
await unlockWallet({ apiUrl, account, password: params.newPassword })
|
||||
const handshake = await performTimestampHandshake(apiUrl)
|
||||
return {
|
||||
accessToken: handshake.accessToken,
|
||||
idToken: user.id_token ?? '',
|
||||
participantCertificate: handshake.participantCertificate ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запросить восстановление доступа (magic-link на email; по стратегии кооператива
|
||||
* — также offline-код). Эпик 3, `POST /coop/recovery/request`. Анти-enumeration:
|
||||
* сервер всегда отвечает 202 вне зависимости от существования аккаунта.
|
||||
*/
|
||||
export async function recover(email: string): Promise<void> {
|
||||
const base = coopIdApiUrl()
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${base}/coop/recovery/request`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе восстановления: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
// 202 — нормальный путь; иные коды (кроме rate-limit) — ошибка конфигурации/сети.
|
||||
if (res.status === 429)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.TooManyRecoveryAttempts, 'Слишком много попыток восстановления, попробуйте позже')
|
||||
if (!res.ok && res.status !== 202)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Запрос восстановления отклонён (HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
/** Текущий access_token (с автообновлением через refresh). Story 1.7. */
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
return getStoredAccessToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* Актуальное participant_certificate текущей сессии — compact JWS из
|
||||
* `GET /coop/certificate` (Story 1.8). `accessToken` — платформенный токен входа
|
||||
* (Bearer). Декодирование claims — `decodeParticipantCertificate` (certificate/).
|
||||
*/
|
||||
export async function getParticipantCertificate(apiUrl: string, accessToken: string): Promise<string> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/certificate`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе удостоверения: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить удостоверение (HTTP ${res.status})`)
|
||||
const body = (await res.json()) as { participant_certificate: string }
|
||||
return body.participant_certificate
|
||||
}
|
||||
|
||||
export interface LogoutParams {
|
||||
/** Базовый URL controller'а кооператива (например `https://coop.example`). */
|
||||
apiUrl: string
|
||||
/** refresh_token текущей сессии — отзывается на сервере. */
|
||||
refreshToken: string
|
||||
/** access_token (опционально) — тоже отзывается. */
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RP-initiated logout (Story 1.10): отзыв токенов на сервере + затирание локального
|
||||
* keystore. Серверный вызов — best-effort; локальное затирание ключа и сессии
|
||||
* выполняется ВСЕГДА (в `finally`), даже если сервер недоступен — безопасность важнее
|
||||
* «чистого» logout: расшифрованный ключ не должен остаться в памяти браузера при
|
||||
* сетевом сбое. Редирект на login — на стороне вызывающего. Стандартный OIDC
|
||||
* end-session — Story 5.1.
|
||||
*/
|
||||
export async function logout(params: LogoutParams): Promise<void> {
|
||||
try {
|
||||
await fetch(`${params.apiUrl.replace(/\/$/, '')}/coop/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: params.refreshToken, access_token: params.accessToken }),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// best-effort: недоступность сервера не должна блокировать локальное затирание ключа
|
||||
}
|
||||
finally {
|
||||
lockWallet()
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { StorageAdapter } from '../wallet/storage-adapter'
|
||||
/**
|
||||
* Lifecycle платформенных токенов сессии CoopID (Эпик 7). Источник истины токена —
|
||||
* этот модуль `@coopenomics/auth`; `@coopenomics/sdk` копирует access в свои
|
||||
* GraphQL-заголовки (D1: bearer не покидает слой SDK, приложение его не трогает).
|
||||
*
|
||||
* Хранение — in-memory (как keystore ключа): по умолчанию живёт только в RAM
|
||||
* вкладки. Персистентность между перезагрузками подключается приложением через
|
||||
* `StorageAdapter` (frontend — IndexedDB), чтобы крипто-/токен-логика не зависела
|
||||
* от среды и тестировалась без браузера.
|
||||
*/
|
||||
import { decodeJwt } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
export interface SessionTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
/** Запас до фактического exp, при котором access считаем «пора обновлять». */
|
||||
const REFRESH_SKEW_SEC = 30
|
||||
|
||||
let tokens: SessionTokens | null = null
|
||||
let apiBase: string | null = null
|
||||
|
||||
/**
|
||||
* Опциональная персистентность токенов между перезагрузками (паритет с легаси,
|
||||
* у которого токены лежат в IndexedDB). По умолчанию контур RAM-only; приложение
|
||||
* подключает `StorageAdapter` (frontend — IndexedDB) через `configureTokenStorage`.
|
||||
* Без этого CoopID-сессия не переживала бы F5 (ключ поднимается из PIN-кэша, а
|
||||
* токен терялся бы), т.е. была бы СЛАБЕЕ легаси.
|
||||
*/
|
||||
const TOKEN_STORAGE_KEY = 'coopid.session.tokens'
|
||||
let storage: StorageAdapter | null = null
|
||||
|
||||
interface PersistedSession {
|
||||
apiBase: string
|
||||
tokens: SessionTokens
|
||||
}
|
||||
|
||||
/** Подключает (или снимает — `null`) персистентность токенов сессии. */
|
||||
export function configureTokenStorage(adapter: StorageAdapter | null): void {
|
||||
storage = adapter
|
||||
}
|
||||
|
||||
/** Кладёт токены текущей сессии (вызывается из handshake/login/refresh). `apiUrl` — база controller'а для refresh. */
|
||||
export function setSession(apiUrl: string, next: SessionTokens): void {
|
||||
apiBase = apiUrl.replace(/\/$/, '')
|
||||
tokens = next
|
||||
// best-effort персист: сбой записи не должен ронять вход (токены уже в RAM).
|
||||
if (storage)
|
||||
void storage.set(TOKEN_STORAGE_KEY, JSON.stringify({ apiBase, tokens: next } satisfies PersistedSession)).catch(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает токены сессии из персистентного хранилища на старте приложения
|
||||
* (после reload). `true` — сессия поднята в RAM. Уже активная RAM-сессия не
|
||||
* перетирается. Без подключённого storage или записи — `false`.
|
||||
*/
|
||||
export async function restoreSession(): Promise<boolean> {
|
||||
if (tokens && apiBase)
|
||||
return true
|
||||
if (!storage)
|
||||
return false
|
||||
const raw = await storage.get(TOKEN_STORAGE_KEY)
|
||||
if (!raw)
|
||||
return false
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PersistedSession
|
||||
if (!parsed?.apiBase || !parsed?.tokens?.accessToken || !parsed?.tokens?.refreshToken)
|
||||
return false
|
||||
apiBase = parsed.apiBase
|
||||
tokens = parsed.tokens
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Затирает токены сессии (logout). Идемпотентно. Стирает и персистентную копию. */
|
||||
export function clearSession(): void {
|
||||
tokens = null
|
||||
apiBase = null
|
||||
if (storage)
|
||||
void storage.remove(TOKEN_STORAGE_KEY).catch(() => undefined)
|
||||
}
|
||||
|
||||
/** Снимок текущих токенов (или null, если сессии нет). */
|
||||
export function currentTokens(): SessionTokens | null {
|
||||
return tokens ? { ...tokens } : null
|
||||
}
|
||||
|
||||
/** Истёк ли (или вот-вот истечёт) access-токен. Невалидный/без exp → считаем истёкшим. */
|
||||
function accessExpired(token: string): boolean {
|
||||
try {
|
||||
const { exp } = decodeJwt(token)
|
||||
if (typeof exp !== 'number')
|
||||
return true
|
||||
return exp - REFRESH_SKEW_SEC <= Math.floor(Date.now() / 1000)
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет пару токенов через REST `/coop/refresh` (то же зеркало платформенной
|
||||
* токен-машинерии, что и legacy GraphQL-refresh). 401 → сессия истекла/отозвана.
|
||||
*/
|
||||
export async function refreshSession(base: string, refreshToken: string): Promise<SessionTokens> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${base}/coop/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при обновлении токена: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (res.status === 401 || res.status === 403)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Сессия истекла: требуется повторный вход')
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось обновить токен (HTTP ${res.status})`)
|
||||
|
||||
const body = (await res.json()) as { access_token: string, refresh_token: string }
|
||||
return { accessToken: body.access_token, refreshToken: body.refresh_token }
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущий access_token с авто-обновлением через refresh (Story 1.7). Бросает, если
|
||||
* активной сессии нет (нужно войти). Обновлённую пару кладёт обратно в сессию.
|
||||
*/
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
if (!tokens || !apiBase)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Нет активной сессии: сначала выполните вход')
|
||||
if (!accessExpired(tokens.accessToken))
|
||||
return tokens.accessToken
|
||||
|
||||
const refreshed = await refreshSession(apiBase, tokens.refreshToken)
|
||||
// setSession обновляет RAM И персистентную копию — иначе на диске остался бы
|
||||
// устаревший refresh-токен и следующий reload поднял бы протухшую сессию.
|
||||
setSession(apiBase, refreshed)
|
||||
return refreshed.accessToken
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Подпись: документы (Story 2.3) и timestamp-метка второго этапа аутентификации
|
||||
* (Story 2.4). Полностью локальные операции (без сети); подпись timestamp НЕ
|
||||
* создаёт audit_events.
|
||||
*/
|
||||
import { base64url, decodeJwt } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { currentView, readUnlockedKey } from '../wallet/storage'
|
||||
|
||||
export interface SignDocumentParams {
|
||||
/** Содержимое документа: байты или строка (кодируется UTF-8). */
|
||||
payload: Uint8Array | string
|
||||
/** Алгоритм подписи; поддерживается ES256K (COOPOS-кривая secp256k1). */
|
||||
alg?: 'ES256K'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись дайджеста транзакции COOPOS ключом пайщика из keystore (мост подписи
|
||||
* CoopID, Эпик 7). Приватный ключ НЕ покидает keystore — наружу уходит только
|
||||
* готовая подпись `SIG_K1_…`. Это та же операция, что внутри
|
||||
* `@wharfkit/wallet-plugin-privatekey`, но ключ берётся из RAM-keystore (2.2), а
|
||||
* не из аргумента: десктопный `WalletPluginCoopId` делегирует сюда `sign()`, и
|
||||
* wharfkit `Session.transact()` подписывает чужими руками, не зная ключа.
|
||||
*
|
||||
* Вход — hex-строка signing-дайджеста (`transaction.signingDigest(chainId)`),
|
||||
* чтобы не тащить wharfkit-типы через границу пакета (иначе ловушка двойного
|
||||
* `Checksum256` из разных копий antelope). Бросает `WalletLocked`, если заперт.
|
||||
*/
|
||||
export async function signChainDigest(digestHex: string): Promise<string> {
|
||||
currentView() // бросает WalletLocked, если keystore заперт
|
||||
const { PrivateKey, Checksum256 } = await import('@wharfkit/antelope')
|
||||
const signature = PrivateKey.from(readUnlockedKey()).signDigest(Checksum256.from(digestHex))
|
||||
return signature.toString()
|
||||
}
|
||||
|
||||
export interface TimestampSignature {
|
||||
ts: string
|
||||
binding_token_jti: string
|
||||
sub: string
|
||||
signature: string
|
||||
public_key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Каноническое сообщение, которое подписывается и которое сервер обязан собрать
|
||||
* байт-в-байт для `recoverMessage` (`/coop/verify/timestamp`, Story 1.7). Ключи в
|
||||
* фиксированном алфавитном порядке — детерминизм между клиентом и сервером.
|
||||
* Экспортируется, чтобы controller переиспользовал ту же канонизацию.
|
||||
*/
|
||||
export function canonicalTimestampMessage(payload: { ts: string, binding_token_jti: string, sub: string }): string {
|
||||
return JSON.stringify({ binding_token_jti: payload.binding_token_jti, sub: payload.sub, ts: payload.ts })
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная подпись документа ключом пайщика (Story 2.3). Возвращает compact JWS
|
||||
* `<header>.<payload>.<signature>` (alg=ES256K). Полностью офлайн: без сети и без
|
||||
* `audit_events` — сервер о подписи не знает (AC: подпись неоспорима и не зависит
|
||||
* от состояния сервера).
|
||||
*
|
||||
* Подпись делается тем же кросс-рантайм secp256k1-примитивом, что и `signTimestamp`
|
||||
* (`@wharfkit/antelope`): jose в браузере ES256K не умеет (WebCrypto без secp256k1),
|
||||
* поэтому compact JWS собирается вручную, а K1-подпись сериализуется в JWS-формат
|
||||
* R||S (recovery-байт отбрасывается — верификация по pubkey, не recover). `kid` в
|
||||
* заголовке = COOPOS-аккаунт подписанта (разрешение ключа при верификации, Story 2.5).
|
||||
*/
|
||||
export async function signDocument(params: SignDocumentParams): Promise<string> {
|
||||
const alg = params.alg ?? 'ES256K'
|
||||
if (alg !== 'ES256K')
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `Неподдерживаемый алгоритм подписи: ${alg}`)
|
||||
|
||||
const { account } = currentView() // бросает WalletLocked, если заперт
|
||||
const wif = readUnlockedKey()
|
||||
|
||||
const payloadBytes = typeof params.payload === 'string'
|
||||
? new TextEncoder().encode(params.payload)
|
||||
: params.payload
|
||||
|
||||
const header = base64url.encode(JSON.stringify({ alg, kid: account }))
|
||||
const payloadB64 = base64url.encode(payloadBytes)
|
||||
const signingInput = `${header}.${payloadB64}`
|
||||
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const sig = PrivateKey.from(wif).signMessage(new TextEncoder().encode(signingInput))
|
||||
// K1-подпись сериализуется как [recovery(1), r(32), s(32)] = 65б; JWS ES256K = R||S
|
||||
// (64б) — recovery-байт не нужен (верификация по pubkey, не recover).
|
||||
const rs = sig.data.array.slice(1)
|
||||
return `${signingInput}.${base64url.encode(rs)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись метки времени для второго этапа auth (Story 2.4). Берёт `sub`/`jti` из
|
||||
* активного `session_binding_token` (Эпик 1: читаем claims без верификации —
|
||||
* подпись делает локальный ключ, доверие даёт сервер при verify), подписывает
|
||||
* каноническое `{ ts, binding_token_jti, sub }` COOPOS-native recoverable
|
||||
* подписью (SIG_K1_..., секрет — из keystore 2.2). Схема самоописана префиксом
|
||||
* SIG_K1_ — alg не хардкодим. Результат уходит в `/coop/verify/timestamp`, где
|
||||
* сервер `recoverMessage` → сверяет pubkey с COOPOS `get_account`.
|
||||
*/
|
||||
export async function signTimestamp(params: { sessionBindingToken: string }): Promise<TimestampSignature> {
|
||||
let claims: { sub?: string, jti?: string }
|
||||
try {
|
||||
claims = decodeJwt(params.sessionBindingToken)
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Некорректный session_binding_token: не удалось прочитать claims')
|
||||
}
|
||||
const sub = claims.sub
|
||||
const jti = claims.jti
|
||||
if (!sub || !jti)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'session_binding_token без обязательных claims sub/jti')
|
||||
|
||||
// Кошелёк должен принадлежать тому же субъекту, что и токен (иначе подпишем
|
||||
// метку чужим ключом — сервер всё равно отвергнет, но ловим раньше и понятнее).
|
||||
const wallet = currentView() // бросает WalletLocked, если заперт
|
||||
if (wallet.account !== sub)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ClientWalletMismatch, `Разблокированный кошелёк (${wallet.account}) не совпадает с субъектом токена (${sub})`)
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const message = canonicalTimestampMessage({ ts, binding_token_jti: jti, sub })
|
||||
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const pk = PrivateKey.from(readUnlockedKey())
|
||||
const signature = pk.signMessage(new TextEncoder().encode(message))
|
||||
|
||||
return {
|
||||
ts,
|
||||
binding_token_jti: jti,
|
||||
sub,
|
||||
signature: signature.toString(),
|
||||
public_key: pk.toPublic().toString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { deriveKey } from './kdf'
|
||||
import type { EncryptedVaultBlob, VaultSubject } from './types'
|
||||
|
||||
const CIPHER_VERSION = 'aes-256-gcm-v1'
|
||||
const KDF_VERSION = 'argon2id-v1'
|
||||
const SALT_LEN = 16
|
||||
const NONCE_LEN = 12
|
||||
|
||||
function toB64Url(bytes: Uint8Array): string {
|
||||
let bin = ''
|
||||
for (const b of bytes) bin += String.fromCharCode(b)
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export function fromB64Url(s: string): Uint8Array {
|
||||
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const out = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* AAD GCM привязывает шифртекст к ТИПУ субъекта (`participant`/`coop`/…), но НЕ к
|
||||
* конкретному account-id. Раньше было `${type}|${id}` — это требовало знать
|
||||
* username ещё до confirm при восстановлении (Эпик 12), хотя сервер и так
|
||||
* резолвит account из recovery-токена; так родился лишний whoami-by-token.
|
||||
*
|
||||
* Account-id убран из AAD намеренно (решение владельца 2026-06-15): пер-юзер
|
||||
* привязку даёт пароль + случайная соль, лежащая в самом блобе; контролем доступа
|
||||
* AAD здесь не был (блоб и так публично читается по account через `GET /coop/vault`).
|
||||
* Поэтому теперь клиент шифрует новый ключ просто паролём, а account для
|
||||
* последующей выборки/расшифровки блоба берёт из ответа confirm.
|
||||
*/
|
||||
function aad(subject: VaultSubject): string {
|
||||
return subject.subject_type
|
||||
}
|
||||
|
||||
/** WebCrypto в strict-TS требует ArrayBuffer-backed view; нормализуем Uint8Array. */
|
||||
function buf(u: Uint8Array): ArrayBuffer {
|
||||
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject-агностичное ядро (Story 2.2): Argon2id(пароль, salt) → AES-256-GCM с
|
||||
* произвольной AAD-строкой. Поверх него работает vault приватного ключа
|
||||
* (AAD=субъект). PIN-слой (AAD=`pin|<account>`) снят в 11.8 (модель «без PIN»).
|
||||
*/
|
||||
export async function encryptWithPassword(
|
||||
plaintext: string,
|
||||
password: string,
|
||||
additionalData: string,
|
||||
): Promise<EncryptedVaultBlob> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN))
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN))
|
||||
const keyBytes = deriveKey(password, salt)
|
||||
|
||||
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['encrypt'])
|
||||
const sealed = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: buf(nonce), additionalData: buf(new TextEncoder().encode(additionalData)) },
|
||||
key,
|
||||
buf(new TextEncoder().encode(plaintext)),
|
||||
),
|
||||
)
|
||||
// WebCrypto склеивает ciphertext+tag; tag GCM — последние 16 байт.
|
||||
const tagLen = 16
|
||||
const ciphertext = sealed.slice(0, sealed.length - tagLen)
|
||||
const authTag = sealed.slice(sealed.length - tagLen)
|
||||
|
||||
return {
|
||||
cipher_version: CIPHER_VERSION,
|
||||
kdf_version: KDF_VERSION,
|
||||
salt: toB64Url(salt),
|
||||
nonce: toB64Url(nonce),
|
||||
ciphertext: toB64Url(ciphertext),
|
||||
auth_tag: toB64Url(authTag),
|
||||
}
|
||||
}
|
||||
|
||||
/** Парная расшифровка ядра; неверный пароль/AAD → `VaultDecryptionFailed`. */
|
||||
export async function decryptWithPassword(
|
||||
blob: EncryptedVaultBlob,
|
||||
password: string,
|
||||
additionalData: string,
|
||||
): Promise<string> {
|
||||
const keyBytes = deriveKey(password, fromB64Url(blob.salt))
|
||||
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['decrypt'])
|
||||
const sealed = new Uint8Array([...fromB64Url(blob.ciphertext), ...fromB64Url(blob.auth_tag)])
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: buf(fromB64Url(blob.nonce)), additionalData: buf(new TextEncoder().encode(additionalData)) },
|
||||
key,
|
||||
buf(sealed),
|
||||
)
|
||||
return new TextDecoder().decode(plain)
|
||||
} catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Не удалось расшифровать: неверный пароль или повреждённые данные')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентское шифрование приватного ключа пайщика (Story 2.1). Сервер получает
|
||||
* только результат — расшифровать может лишь владелец пароля (AAD=субъект).
|
||||
*/
|
||||
export async function encryptPrivateKey(
|
||||
privateKey: string,
|
||||
password: string,
|
||||
subject: VaultSubject,
|
||||
): Promise<EncryptedVaultBlob> {
|
||||
return encryptWithPassword(privateKey, password, aad(subject))
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная расшифровка (round-trip тесты и keystore 2.2). Серверу недоступна —
|
||||
* type-driven ban (см. controller VaultService).
|
||||
*/
|
||||
export async function decryptPrivateKey(
|
||||
blob: EncryptedVaultBlob,
|
||||
password: string,
|
||||
subject: VaultSubject,
|
||||
): Promise<string> {
|
||||
return decryptWithPassword(blob, password, aad(subject))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Vault-слой: client-side шифрование/расшифровка приватного ключа
|
||||
* (Argon2id + AES-256-GCM). Сервер хранит только зашифрованный блоб и не
|
||||
* может его расшифровать (type-driven ban в controller VaultService).
|
||||
*/
|
||||
export type { EncryptedVaultBlob, VaultSubject } from './types'
|
||||
export { ARGON2ID_PARAMS, deriveKey } from './kdf'
|
||||
export { decryptPrivateKey, encryptPrivateKey, fromB64Url } from './encrypt'
|
||||
@@ -0,0 +1,17 @@
|
||||
import { argon2id } from '@noble/hashes/argon2'
|
||||
|
||||
/** Параметры Argon2id KDF (CoopID): зафиксированы версией `argon2id-v1`. */
|
||||
export const ARGON2ID_PARAMS = {
|
||||
m: 65536, // 64 MiB
|
||||
t: 3,
|
||||
p: 4,
|
||||
dkLen: 32, // ключ AES-256
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Деривация 32-байтного ключа из пароля и соли (Argon2id).
|
||||
* Изоморфно: @noble/hashes работает в браузере, Node и desktop-runtime.
|
||||
*/
|
||||
export function deriveKey(password: string, salt: Uint8Array): Uint8Array {
|
||||
return argon2id(new TextEncoder().encode(password), salt, ARGON2ID_PARAMS)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Субъект, к которому привязан зашифрованный блоб (AAD). */
|
||||
export interface VaultSubject {
|
||||
subject_type: 'participant' | 'coop' | 'council_action'
|
||||
subject_id: string
|
||||
}
|
||||
|
||||
/** Зашифрованный блоб vault'а: всё, что хранит и отдаёт сервер. base64url-поля. */
|
||||
export interface EncryptedVaultBlob {
|
||||
cipher_version: string
|
||||
kdf_version: string
|
||||
salt: string
|
||||
nonce: string
|
||||
ciphertext: string
|
||||
auth_tag: string
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { EncryptedVaultBlob, VaultSubject } from '../vault/types'
|
||||
import type { StorageAdapter } from './storage-adapter'
|
||||
/**
|
||||
* Кошелёк (Story 2.2): разблокировка после логина, доступ к публичному «виду»
|
||||
* ключа в памяти, запирание на logout. Приватный ключ живёт ТОЛЬКО в keystore
|
||||
* (storage.ts) и наружу не выходит — `Wallet` его не содержит и не сериализует.
|
||||
* Desktop-кошелёк переезжает на этот модуль (миграция — Эпик 7).
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
|
||||
import { decryptPrivateKey, encryptPrivateKey } from '../vault/encrypt'
|
||||
import { saveLocalVault } from './local-vault'
|
||||
import { clearPinProtected, DEFAULT_PIN, hasPinProtected, loadPinProtected, savePinProtected } from './pin'
|
||||
import { currentView, isUnlocked, readUnlockedKey, storeUnlocked, wipeKeystore } from './storage'
|
||||
|
||||
/**
|
||||
* Несериализуемая обёртка кошелька: отдаёт только аккаунт и публичный ключ.
|
||||
* Приватного ключа в объекте НЕТ — `JSON.stringify(wallet)` физически не может
|
||||
* его раскрыть. Подпись (Stories 2.3/2.4) берёт ключ из keystore, не из Wallet.
|
||||
*/
|
||||
export class Wallet {
|
||||
readonly account: string
|
||||
readonly publicKey: string
|
||||
|
||||
constructor(view: { account: string, publicKey: string }) {
|
||||
this.account = view.account
|
||||
this.publicKey = view.publicKey
|
||||
}
|
||||
|
||||
toJSON(): { account: string, publicKey: string } {
|
||||
return { account: this.account, publicKey: this.publicKey }
|
||||
}
|
||||
}
|
||||
|
||||
/** Деривует публичный ключ из приватного (WIF K1) через WharfKit antelope. */
|
||||
async function derivePublicKey(privateKey: string): Promise<string> {
|
||||
// Ленивый импорт: тяжёлый antelope не тянем в bundle, пока кошелёк не разблокируют.
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
try {
|
||||
return PrivateKey.from(privateKey).toPublic().toString()
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Расшифрованное значение не является валидным приватным ключом')
|
||||
}
|
||||
}
|
||||
|
||||
/** GET зашифрованного blob'а пайщика с контроллера (blob-only, см. 2.2). */
|
||||
export async function fetchVaultBlob(apiUrl: string, subjectId: string): Promise<EncryptedVaultBlob> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault/participant/${encodeURIComponent(subjectId)}`)
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе vault: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить vault (HTTP ${res.status})`)
|
||||
return res.json() as Promise<EncryptedVaultBlob>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST зашифрованного blob'а на контроллер (Story 11.3, `POST /coop/vault`, 201).
|
||||
* Тело — blob + плоские `subject_type`/`subject_id` (контракт `StoreVaultDto`).
|
||||
* Сервер только сохраняет шифр и расшифровать его не может (type-ban в 2.1).
|
||||
*/
|
||||
export async function storeVaultBlob(apiUrl: string, subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...blob, subject_type: subject.subject_type, subject_id: subject.subject_id }),
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при сохранении vault: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось сохранить vault (HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
interface SaveToVaultParams {
|
||||
apiUrl: string
|
||||
account: string
|
||||
privateKey: string
|
||||
password: string
|
||||
/** Если задано — дополнительно сохранить локальную копию зашифрованного blob'а. */
|
||||
storage?: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Шифрует приватный ключ паролём (Argon2id+AES-256-GCM, AAD=субъект) и сохраняет
|
||||
* vault: на сервере (обязательно) и локально (если передан `storage`). Возвращает
|
||||
* зашифрованный blob. Приватный ключ наружу/на сервер не уходит — только шифр.
|
||||
* Используется миграцией «ключ→пароль» (Story 11.4) и сменой пароля.
|
||||
*/
|
||||
export async function saveToVault(params: SaveToVaultParams): Promise<EncryptedVaultBlob> {
|
||||
const subject: VaultSubject = { subject_type: 'participant', subject_id: params.account }
|
||||
const blob = await encryptPrivateKey(params.privateKey, params.password, subject)
|
||||
await storeVaultBlob(params.apiUrl, subject, blob)
|
||||
if (params.storage)
|
||||
await saveLocalVault(params.storage, params.account, blob)
|
||||
return blob
|
||||
}
|
||||
|
||||
interface UnlockParams {
|
||||
apiUrl: string
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная разблокировка: забрать blob → расшифровать паролём на клиенте →
|
||||
* положить ключ в keystore. При неверном пароле keystore остаётся пуст.
|
||||
*/
|
||||
export async function unlockWallet(params: UnlockParams): Promise<Wallet> {
|
||||
const blob = await fetchVaultBlob(params.apiUrl, params.account)
|
||||
const privateKey = await decryptPrivateKey(blob, params.password, {
|
||||
subject_type: 'participant',
|
||||
subject_id: params.account,
|
||||
})
|
||||
const publicKey = await derivePublicKey(privateKey)
|
||||
storeUnlocked({ account: params.account, publicKey, privateKey })
|
||||
return new Wallet({ account: params.account, publicKey })
|
||||
}
|
||||
|
||||
/** Текущий разблокированный кошелёк. Бросает `WalletLocked`, если заперт. */
|
||||
export async function getWallet(): Promise<Wallet> {
|
||||
return new Wallet(currentView())
|
||||
}
|
||||
|
||||
/** Заперт ли кошелёк (нет ключа в памяти). */
|
||||
export function isWalletUnlocked(): boolean {
|
||||
return isUnlocked()
|
||||
}
|
||||
|
||||
/** Затирает ключ из памяти (logout). Идемпотентно. */
|
||||
export function lockWallet(): void {
|
||||
wipeKeystore()
|
||||
}
|
||||
|
||||
/** Ротация ключа пайщика (updateauth + перешифровка vault). Story 3.3. */
|
||||
export async function rotateKey(): Promise<void> {
|
||||
notImplemented('rotateKey')
|
||||
}
|
||||
|
||||
interface PersistPinParams {
|
||||
/** ПИН для шифрования локального кэша; по умолчанию — `DEFAULT_PIN` ('000000', прозрачный). */
|
||||
pin?: string
|
||||
storage: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Перешифровывает текущий разблокированный ключ ПИНом и кладёт в локальный кэш
|
||||
* (уточнённая at-rest модель, см. `pin.ts`). Вызывается сразу после успешного
|
||||
* `unlockWallet`/`migrate`, чтобы последующие входы шли по ПИН, а не по паролю.
|
||||
* Бросает `WalletLocked`, если кошелёк заперт (нечего кэшировать). Ключ читается
|
||||
* пакет-внутренней `readUnlockedKey()` и наружу не выходит — в кэш ложится шифр.
|
||||
*/
|
||||
export async function persistPinCache(params: PersistPinParams): Promise<void> {
|
||||
const { account } = currentView() // бросает WalletLocked, если заперт
|
||||
await savePinProtected(readUnlockedKey(), params.pin ?? DEFAULT_PIN, account, params.storage)
|
||||
}
|
||||
|
||||
interface UnlockWithPinParams {
|
||||
/** ПИН; по умолчанию — `DEFAULT_PIN` ('000000', прозрачная авто-разблокировка). */
|
||||
pin?: string
|
||||
storage: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Разблокировка из локального PIN-кэша без round-trip к серверу и без пароля
|
||||
* (reload устройства, авто-лок по простою). `null` — кэша нет (нужен полный вход
|
||||
* `unlockWallet` паролём). Неверный ПИН → `VaultDecryptionFailed`. При успехе
|
||||
* кладёт ключ в keystore и возвращает `Wallet`.
|
||||
*/
|
||||
export async function unlockWithPin(params: UnlockWithPinParams): Promise<Wallet | null> {
|
||||
const loaded = await loadPinProtected(params.pin ?? DEFAULT_PIN, params.storage)
|
||||
if (!loaded)
|
||||
return null
|
||||
const publicKey = await derivePublicKey(loaded.privateKey)
|
||||
storeUnlocked({ account: loaded.account, publicKey, privateKey: loaded.privateKey })
|
||||
return new Wallet({ account: loaded.account, publicKey })
|
||||
}
|
||||
|
||||
/** Есть ли локальный PIN-кэш (выбор сценария разблокировки на загрузке). */
|
||||
export async function hasPinCache(storage: StorageAdapter): Promise<boolean> {
|
||||
return hasPinProtected(storage)
|
||||
}
|
||||
|
||||
/** Удаляет локальный PIN-кэш («забыть устройство» / смена аккаунта). */
|
||||
export async function clearPinCache(storage: StorageAdapter): Promise<void> {
|
||||
await clearPinProtected(storage)
|
||||
}
|
||||
|
||||
export { clearLocalVault, loadLocalVault, saveLocalVault } from './local-vault'
|
||||
export { DEFAULT_PIN } from './pin'
|
||||
export type { StorageAdapter } from './storage-adapter'
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Локальная копия зашифрованного vault-блоба (Story 11.3). Хранит то же, что и
|
||||
* сервер — Argon2id+AES-256-GCM ciphertext, бесполезный без пароля; расшифровать
|
||||
* нельзя без знания пароля пайщика (zero-knowledge инвариант сохраняется и локально).
|
||||
*
|
||||
* Зачем дублировать серверную копию на устройстве: вход и подпись возможны без
|
||||
* round-trip к контроллеру (офлайн/деградация узла), а также чтобы сразу после
|
||||
* миграции (Story 11.4) ключ был доступен локально. Сервер остаётся источником
|
||||
* истины и обязателен для входа с НОВОГО устройства (там локальной копии нет).
|
||||
*
|
||||
* Это НЕ расшифрованный ключ: расшифрованный WIF живёт только в RAM-keystore
|
||||
* (`storage.ts`) и стирается на логауте (Story 11.8). Локально лежит лишь шифр.
|
||||
*/
|
||||
import type { EncryptedVaultBlob } from '../vault/types'
|
||||
import type { StorageAdapter } from './storage-adapter'
|
||||
|
||||
const LOCAL_VAULT_KEY = 'coopid.wallet.vault'
|
||||
|
||||
interface LocalVaultRecord {
|
||||
account: string
|
||||
blob: EncryptedVaultBlob
|
||||
}
|
||||
|
||||
/** Сохраняет (перезаписывает) локальную копию зашифрованного blob'а пайщика. */
|
||||
export async function saveLocalVault(storage: StorageAdapter, account: string, blob: EncryptedVaultBlob): Promise<void> {
|
||||
const record: LocalVaultRecord = { account, blob }
|
||||
await storage.set(LOCAL_VAULT_KEY, JSON.stringify(record))
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает локальную копию blob'а для `account` (или `null`, если записи нет /
|
||||
* она от другого аккаунта). Подмена account → `null`: чужой blob не отдаём, даже
|
||||
* расшифровать его без чужого пароля всё равно нельзя.
|
||||
*/
|
||||
export async function loadLocalVault(storage: StorageAdapter, account: string): Promise<EncryptedVaultBlob | null> {
|
||||
const raw = await storage.get(LOCAL_VAULT_KEY)
|
||||
if (!raw)
|
||||
return null
|
||||
const record = JSON.parse(raw) as LocalVaultRecord
|
||||
return record.account === account ? record.blob : null
|
||||
}
|
||||
|
||||
/** Удаляет локальную копию vault'а (смена аккаунта / «забыть устройство»). */
|
||||
export async function clearLocalVault(storage: StorageAdapter): Promise<void> {
|
||||
await storage.remove(LOCAL_VAULT_KEY)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { EncryptedVaultBlob } from '../vault/types'
|
||||
import type { StorageAdapter } from './storage-adapter'
|
||||
/**
|
||||
* Локальный PIN-кэш приватного ключа (уточнённая модель CoopID, 2026-06-15 —
|
||||
* СУПЕРДИТ «без PIN» из Story 11.8). Двухуровневая защита ключа:
|
||||
*
|
||||
* 1) Серверный vault ← ПАРОЛЬ (сложный, записан отдельно). Расшифровывается
|
||||
* только при входе; повторно пароль не спрашиваем. Защита от кражи блоба.
|
||||
* 2) Локальный кэш ← ПИН (этот модуль). После входа расшифрованный WIF
|
||||
* перешифровывается ПИНом тем же Argon2id+AES-256-GCM и кладётся в
|
||||
* `StorageAdapter`. Дальнейшие разблокировки (reload, авто-лок по простою) —
|
||||
* ПИНом, НЕ паролём.
|
||||
*
|
||||
* Модель угроз: ПИН защищает не от похищения блоба (от него защищает пароль), а
|
||||
* «от дурака» — постороннего/ребёнка за разблокированным устройством. ПИН по
|
||||
* умолчанию `DEFAULT_PIN` ('000000') делает разблокировку прозрачной (ключ де-факто
|
||||
* лежит локально с тривиальной защитой — осознанная плата за «не вводить пароль
|
||||
* повторно»); кастомный ПИН поднимает планку до анти-«дурак» (6 цифр). AAD
|
||||
* `pin|<account>` привязывает обёртку к аккаунту; account лежит в самой записи,
|
||||
* поэтому знать его заранее не нужно (в отличие от серверного blob'а).
|
||||
*/
|
||||
import { decryptWithPassword, encryptWithPassword } from '../vault/encrypt'
|
||||
|
||||
export type { StorageAdapter } from './storage-adapter'
|
||||
|
||||
const STORAGE_KEY = 'coopid.wallet.pin-vault'
|
||||
|
||||
/** Стандартный «прозрачный» ПИН: разблокировка происходит автоматически и незаметно. */
|
||||
export const DEFAULT_PIN = '000000'
|
||||
|
||||
function aad(account: string): string {
|
||||
return `pin|${account}`
|
||||
}
|
||||
|
||||
interface PinRecord {
|
||||
account: string
|
||||
blob: EncryptedVaultBlob
|
||||
}
|
||||
|
||||
/** Сохраняет ключ под ПИН в storage (перезаписывает предыдущую запись). */
|
||||
export async function savePinProtected(
|
||||
privateKey: string,
|
||||
pin: string,
|
||||
account: string,
|
||||
storage: StorageAdapter,
|
||||
): Promise<void> {
|
||||
const blob = await encryptWithPassword(privateKey, pin, aad(account))
|
||||
const record: PinRecord = { account, blob }
|
||||
await storage.set(STORAGE_KEY, JSON.stringify(record))
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает ключ из storage по ПИН. `null` — записи нет. Неверный ПИН →
|
||||
* `VaultDecryptionFailed` (из ядра). Возвращает `{account, privateKey}` — account
|
||||
* берётся из самой записи, знать его до разблокировки не требуется.
|
||||
*/
|
||||
export async function loadPinProtected(
|
||||
pin: string,
|
||||
storage: StorageAdapter,
|
||||
): Promise<{ account: string, privateKey: string } | null> {
|
||||
const raw = await storage.get(STORAGE_KEY)
|
||||
if (!raw)
|
||||
return null
|
||||
const record = JSON.parse(raw) as PinRecord
|
||||
const privateKey = await decryptWithPassword(record.blob, pin, aad(record.account))
|
||||
return { account: record.account, privateKey }
|
||||
}
|
||||
|
||||
/** Есть ли локальный PIN-кэш (без расшифровки — для выбора сценария разблокировки). */
|
||||
export async function hasPinProtected(storage: StorageAdapter): Promise<boolean> {
|
||||
return (await storage.get(STORAGE_KEY)) !== null
|
||||
}
|
||||
|
||||
/** Удаляет PIN-запись (logout с «забыть устройство» / смена аккаунта). */
|
||||
export async function clearPinProtected(storage: StorageAdapter): Promise<void> {
|
||||
await storage.remove(STORAGE_KEY)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Подключаемый async key-value стор для персистентных артефактов кошелька
|
||||
* (локальная копия vault'а — Story 11.3). PIN-слой снят в 11.8 (модель «без PIN»).
|
||||
*
|
||||
* Абстракция нужна для кросс-рантайма (NFR26): в браузере — обёртка над
|
||||
* localStorage/IndexedDB, в desktop-runtime — своя реализация, в Node/тестах —
|
||||
* память. Крипто и логика хранения от среды не зависят.
|
||||
*/
|
||||
export interface StorageAdapter {
|
||||
get: (key: string) => Promise<string | null>
|
||||
set: (key: string, value: string) => Promise<void>
|
||||
remove: (key: string) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* In-memory keystore (Story 2.2). ЕДИНСТВЕННОЕ место, где живёт расшифрованный
|
||||
* приватный ключ пайщика — module-private переменная, только в RAM процесса/вкладки.
|
||||
*
|
||||
* Публичная поверхность SDK (`getWallet()`) приватный ключ НЕ отдаёт: signing-модули
|
||||
* (Stories 2.3/2.4) берут его через пакет-внутреннюю `readUnlockedKey()`. На logout —
|
||||
* `wipeKeystore()` затирает ключ (перезапись + сброс ссылки).
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
interface UnlockedEntry {
|
||||
account: string
|
||||
publicKey: string
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
/** Открытое состояние сессии. null = заперто. */
|
||||
let unlocked: UnlockedEntry | null = null
|
||||
|
||||
/** Кладёт расшифрованный ключ в keystore (вызывается из unlockWallet). */
|
||||
export function storeUnlocked(entry: UnlockedEntry): void {
|
||||
unlocked = entry
|
||||
}
|
||||
|
||||
/** Заперт ли кошелёк (нет расшифрованного ключа в памяти). */
|
||||
export function isUnlocked(): boolean {
|
||||
return unlocked !== null
|
||||
}
|
||||
|
||||
/** Публичный «вид» кошелька без приватного ключа. Бросает, если заперто. */
|
||||
export function currentView(): { account: string, publicKey: string } {
|
||||
if (!unlocked)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Кошелёк заперт: сначала вызовите unlockWallet()')
|
||||
return { account: unlocked.account, publicKey: unlocked.publicKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакет-внутренний доступ к приватному ключу для signing-модулей (2.3/2.4).
|
||||
* НЕ реэкспортируется из index.ts — наружу ключ не выходит.
|
||||
*/
|
||||
export function readUnlockedKey(): string {
|
||||
if (!unlocked)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.WalletLocked, 'Кошелёк заперт: подпись невозможна без unlockWallet()')
|
||||
return unlocked.privateKey
|
||||
}
|
||||
|
||||
/** Затирает ключ из памяти (logout / lockWallet). Идемпотентно. */
|
||||
export function wipeKeystore(): void {
|
||||
if (unlocked) {
|
||||
// Перезаписываем строку-ключ перед сбросом ссылки, чтобы уменьшить окно жизни
|
||||
// в куче (JS-строки иммутабельны — гарантий нет, но снимаем прямую ссылку).
|
||||
unlocked.privateKey = '\0'.repeat(unlocked.privateKey.length)
|
||||
unlocked = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CERTIFICATE_RENEWAL_LEAD_MS,
|
||||
computeRenewalDelayMs,
|
||||
scheduleCertificateRenewal,
|
||||
} from '../src/certificate'
|
||||
|
||||
/** Собрать декодируемый compact-JWS с нужными claims (подпись не проверяется при decode). */
|
||||
function fakeCert(claims: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'ES256K', typ: 'JWT' })).toString('base64url')
|
||||
const payload = Buffer.from(JSON.stringify({ jti: 'serial-1', sub: 'uuid-1', ...claims })).toString('base64url')
|
||||
return `${header}.${payload}.sig`
|
||||
}
|
||||
|
||||
describe('computeRenewalDelayMs', () => {
|
||||
it('обычный кейс: задержка до exp − lead', () => {
|
||||
const nowMs = 1_000_000
|
||||
const exp = Math.floor(nowMs / 1000) + 3600 // +1ч
|
||||
const delay = computeRenewalDelayMs({ exp }, nowMs)
|
||||
expect(delay).toBe(3600 * 1000 - CERTIFICATE_RENEWAL_LEAD_MS)
|
||||
})
|
||||
|
||||
it('до exp осталось ≤ lead → 0 (перезапрос немедленно)', () => {
|
||||
const nowMs = 1_000_000
|
||||
const exp = Math.floor(nowMs / 1000) + 120 // 2 мин < lead (5 мин)
|
||||
expect(computeRenewalDelayMs({ exp }, nowMs)).toBe(0)
|
||||
})
|
||||
|
||||
it('cert уже истёк → 0', () => {
|
||||
const nowMs = 1_000_000
|
||||
const exp = Math.floor(nowMs / 1000) - 10
|
||||
expect(computeRenewalDelayMs({ exp }, nowMs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scheduleCertificateRenewal', () => {
|
||||
it('срабатывает в exp − lead, зовёт renew и перепланирует от нового cert', async () => {
|
||||
let currentNow = 0
|
||||
const timers: Array<{ cb: () => void, delay: number }> = []
|
||||
const setTimer = vi.fn((cb: () => void, delay: number) => {
|
||||
timers.push({ cb, delay })
|
||||
return timers.length as any
|
||||
})
|
||||
const clearTimer = vi.fn()
|
||||
|
||||
const firstExp = 3600 // сек
|
||||
const secondExp = 7200
|
||||
const first = fakeCert({ exp: firstExp })
|
||||
const second = fakeCert({ exp: secondExp })
|
||||
const renew = vi.fn().mockResolvedValue(second)
|
||||
|
||||
scheduleCertificateRenewal(first, renew, { now: () => currentNow, setTimer, clearTimer })
|
||||
|
||||
// первый таймер запланирован на exp − lead
|
||||
expect(setTimer).toHaveBeenCalledTimes(1)
|
||||
expect(timers[0].delay).toBe(firstExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS)
|
||||
|
||||
// время дошло до срабатывания → дёргаем callback
|
||||
currentNow = firstExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS
|
||||
timers[0].cb()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(renew).toHaveBeenCalledTimes(1)
|
||||
// перепланирование от exp нового сертификата
|
||||
expect(setTimer).toHaveBeenCalledTimes(2)
|
||||
expect(timers[1].delay).toBe(secondExp * 1000 - CERTIFICATE_RENEWAL_LEAD_MS - currentNow)
|
||||
})
|
||||
|
||||
it('cancel() отменяет запланированный таймер и не перепланирует', async () => {
|
||||
const timers: Array<{ cb: () => void, delay: number }> = []
|
||||
const setTimer = vi.fn((cb: () => void, delay: number) => {
|
||||
timers.push({ cb, delay })
|
||||
return timers.length as any
|
||||
})
|
||||
const clearTimer = vi.fn()
|
||||
const renew = vi.fn().mockResolvedValue(fakeCert({ exp: 7200 }))
|
||||
|
||||
const handle = scheduleCertificateRenewal(fakeCert({ exp: 3600 }), renew, { now: () => 0, setTimer, clearTimer })
|
||||
handle.cancel()
|
||||
expect(clearTimer).toHaveBeenCalledTimes(1)
|
||||
|
||||
// даже если таймер «выстрелит» после cancel — renew не перепланирует
|
||||
timers[0].cb()
|
||||
await Promise.resolve()
|
||||
expect(setTimer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { SignJWT } from 'jose'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CERTIFICATE_EXPIRING_WINDOW_MS,
|
||||
certificateStatus,
|
||||
decodeParticipantCertificate,
|
||||
verificationTypeLabel,
|
||||
} from '../src/certificate'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
|
||||
async function makeCert(overrides: Record<string, unknown> = {}, opts: { jti?: string, exp?: string } = {}): Promise<string> {
|
||||
let b = new SignJWT({
|
||||
coopname: 'voskhod',
|
||||
coop_chain: [
|
||||
{ account: 'ano', public_key: 'PUB_K1_ano' },
|
||||
{ account: 'voskhod', public_key: 'PUB_K1_vos' },
|
||||
{ account: 'vostok', public_key: 'PUB_K1_vostok' },
|
||||
],
|
||||
verification_types: [{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' }],
|
||||
identification: { type: 'individual', username: 'ant', first_name: 'Иван' },
|
||||
claim_schema_version: '1',
|
||||
...overrides,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuer('https://voskhod.coop')
|
||||
.setSubject('uuid-1')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(opts.exp ?? '24h')
|
||||
if (opts.jti !== undefined || overrides.jti === undefined)
|
||||
b = b.setJti(opts.jti ?? 'serial-123')
|
||||
return b.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
|
||||
}
|
||||
|
||||
describe('decodeParticipantCertificate', () => {
|
||||
it('декодирует все claims в типизированный объект', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert())
|
||||
expect(claims.sub).toBe('uuid-1')
|
||||
expect(claims.jti).toBe('serial-123')
|
||||
expect(claims.coopname).toBe('voskhod')
|
||||
expect(claims.claim_schema_version).toBe('1')
|
||||
expect(claims.verification_types).toEqual([
|
||||
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
|
||||
])
|
||||
expect(claims.coop_chain.map(l => l.account)).toEqual(['ano', 'voskhod', 'vostok'])
|
||||
expect(claims.identification).toMatchObject({ type: 'individual', username: 'ant' })
|
||||
expect(claims.exp).toBeGreaterThan(claims.iat)
|
||||
})
|
||||
|
||||
it('identification может быть null', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert({ identification: null }))
|
||||
expect(claims.identification).toBeNull()
|
||||
})
|
||||
|
||||
it('retention-claims читаются (Story 4.8)', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert({
|
||||
data_retention_contract: 'erase_on_exclusion',
|
||||
retention_deadline_ts: 1800000000,
|
||||
}))
|
||||
expect(claims.data_retention_contract).toBe('erase_on_exclusion')
|
||||
expect(claims.retention_deadline_ts).toBe(1800000000)
|
||||
})
|
||||
|
||||
it('retention-claims отсутствуют → безопасные дефолты (Story 4.8)', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert())
|
||||
expect(claims.data_retention_contract).toBe('')
|
||||
expect(claims.retention_deadline_ts).toBe(0)
|
||||
})
|
||||
|
||||
it('verification_types: структурные записи сохраняются, мусор отбрасывается (Story 4.3)', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert({
|
||||
verification_types: [
|
||||
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
|
||||
'legacy_string',
|
||||
{ source: 'no_type' },
|
||||
],
|
||||
}))
|
||||
expect(claims.verification_types).toEqual([
|
||||
{ type: 'coop_baseline', verified_at: '2026-01-02T03:04:05.000Z', source: 'cooperative_decision' },
|
||||
])
|
||||
})
|
||||
|
||||
it('не-JWT строка → AuthV2Error(chain_verification_failed)', () => {
|
||||
let err: unknown
|
||||
try {
|
||||
decodeParticipantCertificate('garbage')
|
||||
}
|
||||
catch (e) {
|
||||
err = e
|
||||
}
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
|
||||
})
|
||||
|
||||
it('без обязательных claims (нет jti) → AuthV2Error', async () => {
|
||||
// токен без jti
|
||||
const noJti = await new SignJWT({ coopname: 'voskhod' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject('uuid-1')
|
||||
.setExpirationTime('24h')
|
||||
.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
|
||||
expect(() => decodeParticipantCertificate(noJti)).toThrow(AuthV2Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('certificateStatus', () => {
|
||||
const now = 1_900_000_000_000 // фиксированный «сейчас» (мс)
|
||||
it('exp в прошлом → expired', () => {
|
||||
expect(certificateStatus({ exp: now / 1000 - 10 }, now)).toBe('expired')
|
||||
})
|
||||
it('exp в окне «истекает» → expiring', () => {
|
||||
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS - 1000) / 1000
|
||||
expect(certificateStatus({ exp }, now)).toBe('expiring')
|
||||
})
|
||||
it('exp далеко → active', () => {
|
||||
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS + 10 * 60 * 1000) / 1000
|
||||
expect(certificateStatus({ exp }, now)).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verificationTypeLabel', () => {
|
||||
it('известный тип → человекочитаемое описание', () => {
|
||||
expect(verificationTypeLabel('coop_baseline')).toBe('Базовое подтверждение кооперативом')
|
||||
})
|
||||
it('неизвестный тип → возвращается как есть', () => {
|
||||
expect(verificationTypeLabel('future_kyc_x')).toBe('future_kyc_x')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Интеграционный тест Story 1.3: SDK читает publickey permission `cert` из COOPOS.
|
||||
* Требует живую дев-ноду: COOPID_TEST_RPC=http://127.0.0.1:8908 pnpm test
|
||||
* Без env — скипается (юнит-прогоны CI не зависят от цепи).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { readCertPublicKey } from '../src/index'
|
||||
|
||||
const RPC = process.env.COOPID_TEST_RPC
|
||||
|
||||
describe.skipIf(!RPC)('readCertPublicKey — против дев-сети COOPOS', () => {
|
||||
it.each(['vostok', 'voskhod', 'ano'])('читает ключ cert у %s', async (account) => {
|
||||
const key = await readCertPublicKey(RPC!, account)
|
||||
expect(key).toMatch(/^(PUB_K1_|EOS)/)
|
||||
})
|
||||
|
||||
it('аккаунт без cert → ChainVerificationFailed', async () => {
|
||||
const err = await readCertPublicKey(RPC!, 'eosio').then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
|
||||
})
|
||||
|
||||
it('несуществующий аккаунт → NetworkError', async () => {
|
||||
const err = await readCertPublicKey(RPC!, 'nonexistacct').then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Electron-таргет cross-runtime smoke (Story 9.13): те же проверки, что в
|
||||
* smoke.test.ts, но против СОБРАННОГО артефакта (dist/index.cjs — то, что
|
||||
* реально получают клиенты) в main-process electron. Запуск — через
|
||||
* run-electron.mjs (`pnpm test:electron`), не напрямую.
|
||||
*
|
||||
* Без vitest: минимальный assert + exit-код, чтобы не тащить тест-раннер в
|
||||
* electron. Сценарии держать синхронными со smoke.test.ts.
|
||||
*/
|
||||
const { app } = require('electron')
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
function assert(cond, name) {
|
||||
if (!cond)
|
||||
throw new Error(`smoke-проверка провалена: ${name}`)
|
||||
console.log(`[electron-smoke] OK ${name}`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const sdk = require('../../dist/index.cjs')
|
||||
|
||||
// vault round-trip + unlockWallet (WebCrypto AES-GCM + Argon2id в main-process)
|
||||
const blob = await sdk.encryptPrivateKey(KEY, PW, { subject_type: 'participant', subject_id: ACCOUNT })
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify(blob), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
const wallet = await sdk.unlockWallet({ apiUrl: 'https://coop.stub', account: ACCOUNT, password: PW })
|
||||
assert(wallet.account === ACCOUNT && wallet.publicKey.startsWith('PUB_K1_'), 'vault round-trip + unlockWallet')
|
||||
assert(sdk.isWalletUnlocked() === true, 'isWalletUnlocked после unlock')
|
||||
|
||||
// signDocument → verifyDocumentOffline
|
||||
const jws = await sdk.signDocument({ payload: 'кросс-рантайм smoke-документ' })
|
||||
assert(jws.split('.').length === 3, 'signDocument: compact JWS')
|
||||
const verdict = await sdk.verifyDocumentOffline({
|
||||
jws,
|
||||
signedAtBlock: 100,
|
||||
authority: [{ public_key: wallet.publicKey, valid_from_block: 1, valid_to_block: null }],
|
||||
})
|
||||
assert(verdict.valid === true, 'verifyDocumentOffline подтверждает подпись')
|
||||
|
||||
// запертый кошелёк → типизированная WalletLocked
|
||||
sdk.lockWallet()
|
||||
const lockedErr = await sdk.signDocument({ payload: 'x' }).then(() => null, e => e)
|
||||
assert(lockedErr instanceof sdk.AuthV2Error && lockedErr.code === sdk.AuthV2ErrorCode.WalletLocked, 'WalletLocked при запертом кошельке')
|
||||
|
||||
// login/getAccessToken реализованы (вход 1.7 + flow-executor Story 11.2): в stub-окружении
|
||||
// без живого authentik/controller дают типизированную AuthV2Error (login→network_error,
|
||||
// getAccessToken→wallet_locked). Конкретный код не пиннингуем — важно, что ошибка
|
||||
// типизированная и код валиден для enum'а. Синхронно со smoke.test.ts.
|
||||
const errorCodes = Object.values(sdk.AuthV2ErrorCode)
|
||||
for (const [name, call] of [
|
||||
['login', () => sdk.login({ issuer: 'https://coop.stub/application/o/coopid/', email: 'a@b.c', password: 'p' })],
|
||||
['getAccessToken', () => sdk.getAccessToken()],
|
||||
]) {
|
||||
const err = await call().then(() => null, e => e)
|
||||
assert(err instanceof sdk.AuthV2Error && errorCodes.includes(err.code), `${name}: типизированная AuthV2Error`)
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(run).then(
|
||||
() => {
|
||||
console.log('[electron-smoke] все проверки пройдены')
|
||||
app.exit(0)
|
||||
},
|
||||
(e) => {
|
||||
console.error('[electron-smoke] FAIL:', e)
|
||||
app.exit(1)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Лаунчер electron-таргета (Story 9.13): резолвит бинарь electron, проверяет
|
||||
* наличие dist (smoke идёт против собранного артефакта), на безголовом хосте
|
||||
* заворачивает запуск в `xvfb-run -a` (electron на Linux требует X-сервер).
|
||||
* `--no-sandbox` — для контейнерных/CI-окружений без user namespaces.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import electronPath from 'electron'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pkgRoot = join(here, '..', '..')
|
||||
|
||||
if (!existsSync(join(pkgRoot, 'dist', 'index.cjs'))) {
|
||||
console.error('[electron-smoke] dist/index.cjs не найден — сначала `pnpm build` (test:cross-runtime делает это сам)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const electronArgs = ['--no-sandbox', join(here, 'electron-main.cjs')]
|
||||
const needXvfb = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
|
||||
|
||||
const [cmd, args] = needXvfb
|
||||
? ['xvfb-run', ['-a', electronPath, ...electronArgs]]
|
||||
: [electronPath, electronArgs]
|
||||
|
||||
const res = spawnSync(cmd, args, { stdio: 'inherit', cwd: pkgRoot })
|
||||
|
||||
if (res.error) {
|
||||
console.error(`[electron-smoke] не удалось запустить ${cmd}: ${res.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(res.status ?? 1)
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Cross-runtime smoke (Story 9.13): один и тот же сценарий гоняется в Node
|
||||
* (`pnpm test:node`) и в браузере (`pnpm test:browser`, chromium headless);
|
||||
* electron-таргет (`pnpm test:electron`) выполняет те же проверки против
|
||||
* собранного dist (см. electron-main.cjs).
|
||||
*
|
||||
* Правила файла:
|
||||
* - только ПУБЛИЧНЫЙ API пакета (как у клиентов) и никаких Node-специфичных
|
||||
* глобалов (Buffer, process, fs) — иначе браузерный таргет перестаёт быть
|
||||
* честным; сеть стабится через globalThis.fetch;
|
||||
* - дорогой Argon2id (KDF vault'а) выполняется ОДИН раз в beforeAll: в
|
||||
* chromium pure-JS KDF в разы медленнее Node (минуты), per-test KDF
|
||||
* упирается в таймауты. Тесты зависят от порядка: lockWallet — последний.
|
||||
*/
|
||||
import type { Wallet } from '../../src'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AuthV2Error,
|
||||
AuthV2ErrorCode,
|
||||
encryptPrivateKey,
|
||||
getAccessToken,
|
||||
isWalletUnlocked,
|
||||
lockWallet,
|
||||
login,
|
||||
signDocument,
|
||||
unlockWallet,
|
||||
verifyDocumentOffline,
|
||||
} from '../../src'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
describe('cross-runtime smoke: vault → wallet → подпись → верификация', () => {
|
||||
let wallet: Wallet
|
||||
|
||||
beforeAll(async () => {
|
||||
// Стаб контроллера: GET /coop/vault/... отдаёт заранее зашифрованный blob.
|
||||
const blob = await encryptPrivateKey(KEY, PW, { subject_type: 'participant', subject_id: ACCOUNT })
|
||||
vi.stubGlobal('fetch', async () =>
|
||||
new Response(JSON.stringify(blob), { status: 200, headers: { 'content-type': 'application/json' } }))
|
||||
wallet = await unlockWallet({ apiUrl: 'https://coop.stub', account: ACCOUNT, password: PW })
|
||||
// 10 мин: два прогона Argon2id (encrypt + unlock) — в браузере это минуты
|
||||
}, 600_000)
|
||||
|
||||
afterAll(() => {
|
||||
lockWallet()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('vault round-trip + unlockWallet: WebCrypto AES-GCM и Argon2id работают в этом рантайме', () => {
|
||||
expect(wallet.account).toBe(ACCOUNT)
|
||||
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
})
|
||||
|
||||
it('signDocument → compact JWS, verifyDocumentOffline подтверждает подпись', async () => {
|
||||
const jws = await signDocument({ payload: 'кросс-рантайм smoke-документ' })
|
||||
expect(jws.split('.')).toHaveLength(3)
|
||||
|
||||
const verdict = await verifyDocumentOffline({
|
||||
jws,
|
||||
signedAtBlock: 100,
|
||||
authority: [{ public_key: wallet.publicKey, valid_from_block: 1, valid_to_block: null }],
|
||||
})
|
||||
expect(verdict.valid).toBe(true)
|
||||
})
|
||||
|
||||
// SDK-сторона login/getAccessToken реализована (вход 1.7 + flow-executor Story 11.2).
|
||||
// Smoke фиксирует кросс-рантайм: модуль грузится, функции вызываемы, канал
|
||||
// типизированных ошибок работает в каждом рантайме. В stub-окружении без живого
|
||||
// authentik/controller: login → AuthV2Error(network_error) (flow-executor не достучался),
|
||||
// getAccessToken → AuthV2Error(wallet_locked) (нет активной сессии). Конкретный код
|
||||
// не пиннингуем жёстко — важно, что ошибка типизированная (AuthV2Error) в любом рантайме.
|
||||
it('login()/getAccessToken(): вызываемы и дают типизированную AuthV2Error в каждом рантайме', async () => {
|
||||
for (const call of [
|
||||
() => login({ issuer: 'https://coop.stub/application/o/coopid/', email: 'a@b.c', password: 'p' }),
|
||||
() => getAccessToken(),
|
||||
]) {
|
||||
const err = await call().then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
// код — валидное значение enum'а (канал типизированных ошибок целостен)
|
||||
expect(Object.values(AuthV2ErrorCode)).toContain((err as AuthV2Error).code)
|
||||
}
|
||||
})
|
||||
|
||||
// Последний по порядку: запирает общий кошелёк из beforeAll
|
||||
it('запертый кошелёк: signDocument даёт типизированную WalletLocked, не runtime-краш', async () => {
|
||||
lockWallet()
|
||||
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from '../src/index'
|
||||
|
||||
describe('describeAuthV2Error (Story 1.11)', () => {
|
||||
it('каталог покрывает каждый код ошибки', () => {
|
||||
for (const code of Object.values(AuthV2ErrorCode))
|
||||
expect(AUTH_V2_ERROR_VIEWS[code], code).toBeDefined()
|
||||
})
|
||||
|
||||
it('по AC: неверные учётные данные — общее сообщение без раскрытия поля', () => {
|
||||
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'whatever'))
|
||||
expect(view.code).toBe(AuthV2ErrorCode.InvalidCredentials)
|
||||
expect(view.message).toBe('Неверный email или пароль.')
|
||||
// перечисляет оба поля через «или» — не раскрывает, какое именно неверно (security)
|
||||
expect(view.message).toMatch(/email или пароль/i)
|
||||
expect(view.message).not.toMatch(/не найден|не существует|нет такого|такой email/i)
|
||||
expect(view.keepSession).toBe(false)
|
||||
})
|
||||
|
||||
it('по AC: vault не расшифровывается — предлагает восстановление доступа', () => {
|
||||
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'bad key'))
|
||||
expect(view.action).toBe('recover')
|
||||
expect(view.message).toMatch(/восстановить доступ/i)
|
||||
})
|
||||
|
||||
it('по AC: сетевая ошибка — «проверьте интернет» и НЕ разлогинивает (NFR20/FR50)', () => {
|
||||
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.NetworkError, 'fetch failed'))
|
||||
expect(view.action).toBe('check_connection')
|
||||
expect(view.message).toMatch(/проверьте интернет/i)
|
||||
expect(view.keepSession).toBe(true)
|
||||
})
|
||||
|
||||
it('внешний сбой (кооператив недоступен) — сессия сохраняется', () => {
|
||||
expect(describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'down')).keepSession).toBe(true)
|
||||
})
|
||||
|
||||
it('неожиданная (не-AuthV2) ошибка — безопасный фолбэк без утечки technical message', () => {
|
||||
const view = describeAuthV2Error(new Error('TypeError: cannot read property x of undefined'))
|
||||
expect(view.code).toBeNull()
|
||||
expect(view.message).not.toMatch(/TypeError|undefined/)
|
||||
expect(view.keepSession).toBe(true)
|
||||
})
|
||||
|
||||
it('сообщение берётся из каталога, а не из e.message сервера', () => {
|
||||
const view = describeAuthV2Error(new AuthV2Error(AuthV2ErrorCode.TimestampTooOld, 'raw server text'))
|
||||
expect(view.message).not.toBe('raw server text')
|
||||
expect(view.message).toBe(AUTH_V2_ERROR_VIEWS[AuthV2ErrorCode.TimestampTooOld].message)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { exportFullQR } from '../src/export'
|
||||
import { lockWallet } from '../src/wallet'
|
||||
import { storeUnlocked } from '../src/wallet/storage'
|
||||
|
||||
const CERT = 'header.payload.sig'
|
||||
|
||||
afterEach(() => lockWallet())
|
||||
|
||||
function unlock(): void {
|
||||
storeUnlocked({ account: 'ant', publicKey: 'PUB_K1_ant', privateKey: '5KdummyWIF' })
|
||||
}
|
||||
|
||||
describe('exportFullQR (Story 4.9)', () => {
|
||||
it('заперт vault → WalletLocked, consent даже не запрашивается', async () => {
|
||||
lockWallet()
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
await expect(exportFullQR(CERT, { confirm })).rejects.toMatchObject({ code: AuthV2ErrorCode.WalletLocked })
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consent отклонён → ConsentRequired', async () => {
|
||||
unlock()
|
||||
await expect(exportFullQR(CERT, { confirm: async () => false })).rejects.toMatchObject({ code: AuthV2ErrorCode.ConsentRequired })
|
||||
})
|
||||
|
||||
it('reject в confirm трактуется как отказ → ConsentRequired', async () => {
|
||||
unlock()
|
||||
const confirm = async (): Promise<boolean> => {
|
||||
throw new Error('dialog closed')
|
||||
}
|
||||
await expect(exportFullQR(CERT, { confirm })).rejects.toMatchObject({ code: AuthV2ErrorCode.ConsentRequired })
|
||||
})
|
||||
|
||||
it('разблокирован + consent дан → payload-байты с данными сертификата', async () => {
|
||||
unlock()
|
||||
const bytes = await exportFullQR(CERT, { confirm: async () => true })
|
||||
expect(bytes).toBeInstanceOf(Uint8Array)
|
||||
expect(new TextDecoder().decode(bytes)).toBe(CERT)
|
||||
})
|
||||
|
||||
it('ошибки — типизированная AuthV2Error', async () => {
|
||||
lockWallet()
|
||||
const err = await exportFullQR(CERT, { confirm: async () => true }).catch(e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as api from '../src/index'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/index'
|
||||
|
||||
const PUBLIC_API = [
|
||||
'login',
|
||||
'loginWithMagicLink',
|
||||
'recover',
|
||||
'getAccessToken',
|
||||
'getParticipantCertificate',
|
||||
'logout',
|
||||
'verifyOffline',
|
||||
'signDocument',
|
||||
'signTimestamp',
|
||||
'getWallet',
|
||||
'rotateKey',
|
||||
'exportFullQR',
|
||||
] as const
|
||||
|
||||
// Методы, ещё не реализованные (бросают not_implemented). По мере реализации
|
||||
// историй метод уходит отсюда: getWallet — 2.2, signTimestamp — 2.4, verifyOffline — 4.4,
|
||||
// exportFullQR — 4.9 (гейтится unlocked vault + consent, не not_implemented), login —
|
||||
// 1.7/11.2 (flow-executor → network_error без живого authentik), recover — Эпик 3,
|
||||
// getAccessToken — 1.7 (wallet_locked без сессии), loginWithMagicLink — 12.2 (confirm-флоу
|
||||
// + повторный handshake). Реальный stub остался один: rotateKey (3.3).
|
||||
const IMPLEMENTED = new Set(['getWallet', 'signTimestamp', 'getParticipantCertificate', 'logout', 'signDocument', 'verifyOffline', 'exportFullQR', 'login', 'recover', 'getAccessToken', 'loginWithMagicLink'])
|
||||
const STILL_STUBBED = PUBLIC_API.filter(m => !IMPLEMENTED.has(m))
|
||||
|
||||
describe('@coopenomics/auth — скелет SDK', () => {
|
||||
it('экспортирует всю публичную поверхность', () => {
|
||||
for (const method of PUBLIC_API)
|
||||
expect(api[method], method).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('stub\'ы отказывают типизированной AuthV2Error(not_implemented), а не молчат', async () => {
|
||||
for (const method of STILL_STUBBED) {
|
||||
const err = await (api[method] as () => Promise<unknown>)().then(
|
||||
() => null,
|
||||
e => e,
|
||||
)
|
||||
expect(err, method).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code, method).toBe(AuthV2ErrorCode.NotImplemented)
|
||||
}
|
||||
})
|
||||
|
||||
it('ошибка сериализуется в формат OAuth 2.0', () => {
|
||||
const err = new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Неверный email или пароль')
|
||||
expect(err.toJSON()).toEqual({
|
||||
error: 'invalid_credentials',
|
||||
error_description: 'Неверный email или пароль',
|
||||
})
|
||||
})
|
||||
|
||||
it('exportProofQR НЕ экспортируется в MVP (anonymous-форма — Growth, Story 4.9)', () => {
|
||||
expect((api as Record<string, unknown>).exportProofQR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('trust anchor и список нод доступны (placeholder до release-pin/9.5)', () => {
|
||||
expect(api.TRUST_ANCHOR_ANO_CERT_PUBKEY).toBeNull()
|
||||
expect(api.COOPOS_PUBLIC_NODES).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const lockWallet = vi.fn()
|
||||
|
||||
// keystore-затирание мокаем на уровне модуля wallet — logout обязан вызвать его
|
||||
// независимо от исхода сетевого запроса.
|
||||
vi.mock('../src/wallet', () => ({
|
||||
lockWallet,
|
||||
}))
|
||||
|
||||
const { logout } = await import('../src/oidc')
|
||||
|
||||
describe('logout (Story 1.10)', () => {
|
||||
beforeEach(() => {
|
||||
lockWallet.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('шлёт POST /coop/logout с refresh/access и затирает keystore', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await logout({ apiUrl: 'https://coop.example/', refreshToken: 'r1', accessToken: 'a1' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, opts] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://coop.example/coop/logout')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ refresh_token: 'r1', access_token: 'a1' })
|
||||
expect(lockWallet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('затирает keystore ДАЖЕ при недоступности сервера (finally)', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(logout({ apiUrl: 'https://coop.example', refreshToken: 'r1' })).resolves.toBeUndefined()
|
||||
expect(lockWallet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { canonicalMigrationMessage, migrate } from '../src/migration'
|
||||
import { configureCoopId } from '../src/oidc/client'
|
||||
|
||||
const WIF = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
|
||||
beforeEach(() => configureCoopId({ apiUrl: 'https://coop.example' }))
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('canonicalMigrationMessage (Story 11.4)', () => {
|
||||
it('фиксированный алфавитный порядок ключей: pw_hash, purpose, ts', () => {
|
||||
expect(canonicalMigrationMessage({ ts: 'T', pw_hash: 'H' }))
|
||||
.toBe('{"pw_hash":"H","purpose":"coopid-key-migration","ts":"T"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrate (Story 11.4) — «ключ → пароль»', () => {
|
||||
it('невалидный WIF → InvalidCredentials, без сетевого запроса', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(migrate({ email: 'a@e.com', privateKey: 'not-a-key', newPassword: 'Strong#Pass1' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'invalid_credentials' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ошибка сервера (400 weak_password) пробрасывается; vault не сохраняется', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ error: 'weak_password', error_description: 'Пароль слишком короткий' }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(migrate({ email: 'a@e.com', privateKey: WIF, newPassword: 'weakweak' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'weak_password' })
|
||||
// только POST /coop/migration, без POST /coop/vault
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/migration')
|
||||
})
|
||||
|
||||
it('happy path: POST /coop/migration → username, затем saveToVault (POST /coop/vault)', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ username: 'ant' }) }) // migration
|
||||
.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) }) // vault store
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await migrate({ email: 'a@e.com', privateKey: WIF, newPassword: 'Strong#Pass1' })
|
||||
|
||||
expect(result).toEqual({ username: 'ant' })
|
||||
// 1) запрос миграции
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/migration')
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(body.email).toBe('a@e.com')
|
||||
expect(body.new_password).toBe('Strong#Pass1')
|
||||
expect(typeof body.timestamp).toBe('string')
|
||||
expect(body.signature).toMatch(/^SIG_/)
|
||||
// 2) сохранение vault новым паролём (subject = возвращённый username)
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('https://coop.example/coop/vault')
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toMatchObject({ subject_type: 'participant', subject_id: 'ant' })
|
||||
}, 60000)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { authenticateWithFlowExecutor } from '../src/oidc/flow-executor'
|
||||
|
||||
const ISSUER = 'https://coop.example/application/o/coopid/'
|
||||
const FLOW_URL = 'https://coop.example/api/v3/flows/executor/default-authentication-flow/?query='
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
/** Ответ flow-executor: 200 + тело challenge'а. */
|
||||
function challenge(body: unknown, status = 200) {
|
||||
return { ok: status < 400, status, json: async () => body }
|
||||
}
|
||||
|
||||
describe('authenticateWithFlowExecutor (Story 11.2) — встроенный фактор-1 authentik', () => {
|
||||
it('раздельные стадии identification → password → redirect: успех, верные URL/тела/credentials', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-identification' })) // GET
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' })) // POST email
|
||||
.mockResolvedValueOnce(challenge({ type: 'redirect' })) // POST password → завершение
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'user@e.com', password: 'S3cret!' })
|
||||
|
||||
// GET старта flow
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(FLOW_URL)
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('GET')
|
||||
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
|
||||
// POST identification — uid_field + password (совмещённая форма безопасна и для раздельной)
|
||||
expect(fetchMock.mock.calls[1][1].method).toBe('POST')
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ uid_field: 'user@e.com', password: 'S3cret!' })
|
||||
// POST password
|
||||
expect(JSON.parse(fetchMock.mock.calls[2][1].body)).toEqual({ password: 'S3cret!' })
|
||||
expect(fetchMock.mock.calls[2][1].credentials).toBe('include')
|
||||
})
|
||||
|
||||
it('совмещённая identification сразу завершает flow (redirect)', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-identification' }))
|
||||
.mockResolvedValueOnce(challenge({ type: 'redirect' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('завершение по стадии ak-stage-user-login (сессия установлена)', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-user-login' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('неверный пароль (response_errors, HTTP 400) → InvalidCredentials с сообщением authentik', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password', response_errors: { password: [{ string: 'Неверный пароль', code: 'invalid' }] } }, 400))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'bad' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'invalid_credentials', message: 'Неверный пароль' })
|
||||
})
|
||||
|
||||
it('ak-stage-access-denied → InvalidCredentials', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(challenge({ component: 'ak-stage-access-denied' })))
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'invalid_credentials' })
|
||||
})
|
||||
|
||||
it('неподдерживаемая интерактивная стадия (MFA) → InvalidCredentials с её именем', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-authenticator-validate' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'invalid_credentials', message: expect.stringContaining('ak-stage-authenticator-validate') })
|
||||
})
|
||||
|
||||
it('сеть упала на старте → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
|
||||
await expect(authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' }))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'network_error' })
|
||||
})
|
||||
|
||||
it('эхо CSRF-cookie уходит в заголовке X-authentik-CSRF (same-origin)', async () => {
|
||||
vi.stubGlobal('document', { cookie: 'foo=1; authentik_csrf=tok123; bar=2' })
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(challenge({ component: 'ak-stage-password' }))
|
||||
.mockResolvedValueOnce(challenge({ type: 'redirect' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await authenticateWithFlowExecutor({ issuer: ISSUER, email: 'u@e.com', password: 'p' })
|
||||
expect(fetchMock.mock.calls[0][1].headers['X-authentik-CSRF']).toBe('tok123')
|
||||
expect(fetchMock.mock.calls[1][1].headers['X-authentik-CSRF']).toBe('tok123')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PrivateKey } from '@wharfkit/antelope'
|
||||
import { SignJWT } from 'jose'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error } from '../src/errors'
|
||||
import { performTimestampHandshake } from '../src/oidc/handshake'
|
||||
import { clearSession, currentTokens } from '../src/oidc/tokens'
|
||||
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PUB = PrivateKey.from(KEY).toPublic().toString()
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
async function bindingTokenFor(sub: string, jti = 'jti-1'): Promise<string> {
|
||||
return new SignJWT({ stage_completed: 'password' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(sub)
|
||||
.setJti(jti)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('120s')
|
||||
.sign(new TextEncoder().encode('test-binding-secret'))
|
||||
}
|
||||
|
||||
function okJson(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearSession()
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wipeKeystore()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('performTimestampHandshake (Story 1.7) — handshake внутрь SDK', () => {
|
||||
it('bind → sign → verify: токены/сертификат + сессия сохранена; credentials:include на обоих', async () => {
|
||||
const binding = await bindingTokenFor(ACCOUNT)
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
|
||||
.mockResolvedValueOnce(okJson({ access_token: 'acc', refresh_token: 'ref', participant_certificate: 'cert', degraded: false }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const r = await performTimestampHandshake('https://coop.example/')
|
||||
|
||||
expect(r).toMatchObject({ accessToken: 'acc', refreshToken: 'ref', participantCertificate: 'cert', degraded: false })
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/session/bind')
|
||||
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('https://coop.example/coop/verify/timestamp')
|
||||
expect(fetchMock.mock.calls[1][1].credentials).toBe('include')
|
||||
const verifyBody = JSON.parse(fetchMock.mock.calls[1][1].body)
|
||||
expect(verifyBody.binding_token).toBe(binding)
|
||||
expect(typeof verifyBody.signature).toBe('string')
|
||||
expect(typeof verifyBody.timestamp).toBe('string')
|
||||
expect(currentTokens()).toEqual({ accessToken: 'acc', refreshToken: 'ref' })
|
||||
})
|
||||
|
||||
it('degraded-вход (узел недоступен/ключ не финализирован) прокидывается', async () => {
|
||||
const binding = await bindingTokenFor(ACCOUNT)
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
|
||||
.mockResolvedValueOnce(okJson({ access_token: 'a', refresh_token: 'r', degraded: true, degraded_reason: 'rpc_unavailable' })))
|
||||
|
||||
const r = await performTimestampHandshake('https://coop.example')
|
||||
expect(r.degraded).toBe(true)
|
||||
expect(r.degradedReason).toBe('rpc_unavailable')
|
||||
})
|
||||
|
||||
it('bind 401 → InvalidCredentials (сессия authentik не подтверждена), сессия не сохраняется', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) }))
|
||||
await expect(performTimestampHandshake('https://coop.example')).rejects.toBeInstanceOf(AuthV2Error)
|
||||
expect(currentTokens()).toBeNull()
|
||||
})
|
||||
|
||||
it('verify !ok → AuthV2Error с кодом из тела (OAuth2 error)', async () => {
|
||||
const binding = await bindingTokenFor(ACCOUNT)
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ error: 'chain_verification_failed', error_description: 'Подпись не соответствует ключу' }) }))
|
||||
|
||||
await expect(performTimestampHandshake('https://coop.example')).rejects.toMatchObject({ code: 'chain_verification_failed' })
|
||||
})
|
||||
|
||||
it('кошелёк заперт → WalletLocked (подпись невозможна, verify не вызывается)', async () => {
|
||||
wipeKeystore()
|
||||
const binding = await bindingTokenFor(ACCOUNT)
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(okJson({ binding_token: binding, expires_in: 120 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(performTimestampHandshake('https://coop.example')).rejects.toMatchObject({ code: 'wallet_locked' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1) // только bind, до verify не дошли
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Граница повторного входа (authentik-сессия + on-chain timestamp-handshake) — сетевые
|
||||
// интеграции; мокаем их, чтобы юнит-тест проверял детерминированный confirm-флоу:
|
||||
// генерацию пары, AAD vault'а, тело запроса confirm и маппинг ошибок. Крипто vault'а
|
||||
// и antelope здесь настоящие — round-trip ключа проверяется по-настоящему.
|
||||
vi.mock('../src/oidc/client', async importActual => ({
|
||||
...(await importActual<typeof import('../src/oidc/client')>()),
|
||||
authenticateWithAuthentik: vi.fn().mockResolvedValue({ id_token: 'ID_TOK' }),
|
||||
}))
|
||||
vi.mock('../src/oidc/handshake', async importActual => ({
|
||||
...(await importActual<typeof import('../src/oidc/handshake')>()),
|
||||
performTimestampHandshake: vi.fn().mockResolvedValue({ accessToken: 'AT', refreshToken: 'RT', participantCertificate: 'CERT', degraded: false }),
|
||||
}))
|
||||
vi.mock('../src/wallet', async importActual => ({
|
||||
...(await importActual<typeof import('../src/wallet')>()),
|
||||
unlockWallet: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const { loginWithMagicLink } = await import('../src/oidc')
|
||||
const { authenticateWithAuthentik, configureCoopId } = await import('../src/oidc/client')
|
||||
const { performTimestampHandshake } = await import('../src/oidc/handshake')
|
||||
const { decryptPrivateKey } = await import('../src/vault/encrypt')
|
||||
const { unlockWallet } = await import('../src/wallet')
|
||||
|
||||
const BASE = 'https://coop.example'
|
||||
// account больше НЕ параметр — сервер возвращает его в ответе confirm (резолвит из токена).
|
||||
const ACCOUNT = 'ant'
|
||||
const PARAMS = {
|
||||
issuer: 'https://coop.example/application/o/coopid/',
|
||||
email: 'ant@example.com',
|
||||
token: 'magic-token-xyz',
|
||||
totp: '123456',
|
||||
newPassword: 'Strong#NewPass1',
|
||||
}
|
||||
|
||||
beforeEach(() => configureCoopId({ apiUrl: BASE }))
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('loginWithMagicLink (Story 12.2) — восстановление доступа', () => {
|
||||
it('happy: новая пара → корректное тело confirm → vault round-trip → повторный вход', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ username: ACCOUNT }) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await loginWithMagicLink(PARAMS)
|
||||
|
||||
// единственный сетевой вызов — confirm (unlock/handshake/authentik замоканы).
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/coop/recovery/confirm`)
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(body.token).toBe(PARAMS.token)
|
||||
expect(body.code).toBe(PARAMS.totp)
|
||||
expect(body.password).toBe(PARAMS.newPassword)
|
||||
expect(body.public_key).toMatch(/^PUB_K1_/)
|
||||
expect(body.vault).toMatchObject({ cipher_version: expect.any(String), salt: expect.any(String), ciphertext: expect.any(String) })
|
||||
|
||||
// AAD больше НЕ зависит от account: тот же блоб расшифровывается под ЛЮБЫМ
|
||||
// subject_id (важен только тип субъекта) — регресс-гард решения 2026-06-15.
|
||||
// И приватный ключ соответствует отправленному public_key — целостность пары.
|
||||
const wif = await decryptPrivateKey(body.vault, PARAMS.newPassword, { subject_type: 'participant', subject_id: 'any-other-account' })
|
||||
expect(PrivateKey.from(wif).toPublic().toString()).toBe(body.public_key)
|
||||
|
||||
// повторный вход новым контуром выполнен; account взят из ответа confirm.
|
||||
expect(authenticateWithAuthentik).toHaveBeenCalledWith(expect.objectContaining({ email: PARAMS.email, password: PARAMS.newPassword, issuer: PARAMS.issuer }))
|
||||
expect(unlockWallet).toHaveBeenCalledWith({ apiUrl: BASE, account: ACCOUNT, password: PARAMS.newPassword })
|
||||
expect(performTimestampHandshake).toHaveBeenCalledWith(BASE)
|
||||
expect(result).toEqual({ accessToken: 'AT', idToken: 'ID_TOK', participantCertificate: 'CERT' })
|
||||
}, 60000)
|
||||
|
||||
it('confirm вернул 200 без username → InvalidRecoveryToken; повторный вход не запускается', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }))
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_recovery_token' })
|
||||
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
|
||||
expect(performTimestampHandshake).not.toHaveBeenCalled()
|
||||
}, 60000)
|
||||
|
||||
it('неверный TOTP (400 invalid_2fa_code) → проброс; повторный вход не запускается', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'invalid_2fa_code', error_description: 'Неверный код' }) }))
|
||||
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_2fa_code' })
|
||||
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
|
||||
expect(performTimestampHandshake).not.toHaveBeenCalled()
|
||||
}, 60000)
|
||||
|
||||
it('недействительный токен (400 invalid_recovery_token) → проброс', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'invalid_recovery_token', error_description: 'Ссылка недействительна' }) }))
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'invalid_recovery_token' })
|
||||
}, 60000)
|
||||
|
||||
it('429 → TooManyRecoveryAttempts', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }))
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'too_many_recovery_attempts' })
|
||||
}, 60000)
|
||||
|
||||
it('сеть упала на confirm → NetworkError; повторный вход не запускается', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
|
||||
await expect(loginWithMagicLink(PARAMS)).rejects.toMatchObject({ code: 'network_error' })
|
||||
expect(authenticateWithAuthentik).not.toHaveBeenCalled()
|
||||
}, 60000)
|
||||
|
||||
it('storage задан → локальная копия нового блоба сохранена под account', async () => {
|
||||
const store = new Map<string, string>()
|
||||
const storage = {
|
||||
get: vi.fn(async (k: string) => store.get(k) ?? null),
|
||||
set: vi.fn(async (k: string, v: string) => { store.set(k, v) }),
|
||||
remove: vi.fn(async (k: string) => { store.delete(k) }),
|
||||
}
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ username: ACCOUNT }) }))
|
||||
|
||||
await loginWithMagicLink({ ...PARAMS, storage })
|
||||
|
||||
expect(storage.set).toHaveBeenCalledTimes(1)
|
||||
const saved = JSON.parse(storage.set.mock.calls[0][1] as string)
|
||||
expect(saved.account).toBe(ACCOUNT)
|
||||
expect(saved.blob).toMatchObject({ cipher_version: expect.any(String) })
|
||||
}, 60000)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { recover } from '../src/oidc'
|
||||
import { configureCoopId } from '../src/oidc/client'
|
||||
|
||||
beforeEach(() => configureCoopId({ apiUrl: 'https://coop.example' }))
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('recover (Эпик 3) — запрос восстановления доступа', () => {
|
||||
it('шлёт POST /coop/recovery/request с email (202 — нормальный путь, анти-enumeration)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await recover('user@example.com')
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/recovery/request')
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ email: 'user@example.com' })
|
||||
})
|
||||
|
||||
it('429 → TooManyRecoveryAttempts', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }))
|
||||
await expect(recover('u@e.com')).rejects.toMatchObject({ code: 'too_many_recovery_attempts' })
|
||||
})
|
||||
|
||||
it('сеть упала → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
|
||||
await expect(recover('u@e.com')).rejects.toMatchObject({ code: 'network_error' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { StorageAdapter } from '../src/wallet'
|
||||
import { SignJWT } from 'jose'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error } from '../src/errors'
|
||||
import { clearSession, configureTokenStorage, currentTokens, getAccessToken, restoreSession, setSession } from '../src/oidc/tokens'
|
||||
|
||||
/** JWT с заданным exp (сек от now); содержимое не важно — getAccessToken читает только exp. */
|
||||
async function jwtExpIn(seconds: number): Promise<string> {
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${seconds}s`)
|
||||
.sign(new TextEncoder().encode('t'))
|
||||
}
|
||||
|
||||
function okJson(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body }
|
||||
}
|
||||
|
||||
/** In-memory StorageAdapter для тестов персистентности токенов. */
|
||||
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
|
||||
const m = new Map<string, string>()
|
||||
return {
|
||||
get: async k => m.get(k) ?? null,
|
||||
set: async (k, v) => void m.set(k, v),
|
||||
remove: async k => void m.delete(k),
|
||||
dump: () => Object.fromEntries(m),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => clearSession())
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
configureTokenStorage(null)
|
||||
clearSession()
|
||||
})
|
||||
|
||||
describe('getAccessToken lifecycle (Story 1.7) — токен внутри SDK, авто-refresh', () => {
|
||||
it('нет активной сессии → бросает', async () => {
|
||||
await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
|
||||
it('свежий access → возвращается без обращения к сети', async () => {
|
||||
const access = await jwtExpIn(3600)
|
||||
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
expect(await getAccessToken()).toBe(access)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('истёкший access → refresh через /coop/refresh, новая пара сохраняется', async () => {
|
||||
const expired = await jwtExpIn(-10)
|
||||
const fresh = await jwtExpIn(3600)
|
||||
setSession('https://coop.example/', { accessToken: expired, refreshToken: 'r-old' })
|
||||
const fetchMock = vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
expect(await getAccessToken()).toBe(fresh)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/refresh')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ refresh_token: 'r-old' })
|
||||
expect(currentTokens()).toEqual({ accessToken: fresh, refreshToken: 'r-new' })
|
||||
})
|
||||
|
||||
it('refresh 401 → SessionBindingExpired (нужен повторный вход)', async () => {
|
||||
const expired = await jwtExpIn(-10)
|
||||
setSession('https://coop.example', { accessToken: expired, refreshToken: 'r' })
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }))
|
||||
|
||||
await expect(getAccessToken()).rejects.toMatchObject({ code: 'session_binding_expired' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('персистентность токенов (паритет с легаси — переживание reload)', () => {
|
||||
it('setSession персистит пару токенов в storage', async () => {
|
||||
const storage = memoryStorage()
|
||||
const access = await jwtExpIn(3600)
|
||||
configureTokenStorage(storage)
|
||||
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
|
||||
expect(JSON.stringify(storage.dump())).toContain(access)
|
||||
})
|
||||
|
||||
it('restoreSession поднимает пару после «reload» (RAM пуста, storage цел) — сессия переживает F5', async () => {
|
||||
const access = await jwtExpIn(3600)
|
||||
// «До reload»: storage с записью, как её оставил setSession в прошлой сессии.
|
||||
const storage = memoryStorage()
|
||||
await storage.set('coopid.session.tokens', JSON.stringify({ apiBase: 'https://coop.example', tokens: { accessToken: access, refreshToken: 'r' } }))
|
||||
// «После reload»: RAM пуста (beforeEach), подключаем storage и восстанавливаем.
|
||||
configureTokenStorage(storage)
|
||||
|
||||
expect(await restoreSession()).toBe(true)
|
||||
expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' })
|
||||
expect(await getAccessToken()).toBe(access) // токен жив без сети
|
||||
})
|
||||
|
||||
it('нет персистентной записи → restoreSession=false (нужен вход)', async () => {
|
||||
configureTokenStorage(memoryStorage())
|
||||
expect(await restoreSession()).toBe(false)
|
||||
await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
|
||||
it('активная RAM-сессия не перетирается restoreSession', async () => {
|
||||
const access = await jwtExpIn(3600)
|
||||
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
|
||||
configureTokenStorage(memoryStorage()) // пустой storage
|
||||
expect(await restoreSession()).toBe(true) // уже в RAM
|
||||
expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' })
|
||||
})
|
||||
|
||||
it('refresh обновляет персистентную копию (на диске свежий refresh-токен)', async () => {
|
||||
const storage = memoryStorage()
|
||||
const expired = await jwtExpIn(-10)
|
||||
const fresh = await jwtExpIn(3600)
|
||||
configureTokenStorage(storage)
|
||||
setSession('https://coop.example', { accessToken: expired, refreshToken: 'r-old' })
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' })))
|
||||
|
||||
expect(await getAccessToken()).toBe(fresh)
|
||||
const dump = JSON.stringify(storage.dump())
|
||||
expect(dump).toContain('r-new')
|
||||
expect(dump).not.toContain('r-old')
|
||||
})
|
||||
|
||||
it('clearSession стирает персистентную копию (logout)', async () => {
|
||||
const storage = memoryStorage()
|
||||
const access = await jwtExpIn(3600)
|
||||
configureTokenStorage(storage)
|
||||
setSession('https://coop.example', { accessToken: access, refreshToken: 'r' })
|
||||
expect(Object.keys(storage.dump())).toHaveLength(1)
|
||||
clearSession()
|
||||
expect(Object.keys(storage.dump())).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { StorageAdapter } from '../src/wallet'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import {
|
||||
clearPinCache,
|
||||
DEFAULT_PIN,
|
||||
getWallet,
|
||||
hasPinCache,
|
||||
isWalletUnlocked,
|
||||
lockWallet,
|
||||
persistPinCache,
|
||||
unlockWithPin,
|
||||
} from '../src/wallet'
|
||||
import { storeUnlocked } from '../src/wallet/storage'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const ACCOUNT = 'ant'
|
||||
const PUB = 'PUB_K1_'
|
||||
|
||||
/** In-memory StorageAdapter (в браузере — localStorage/IndexedDB, тут — Map). */
|
||||
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
|
||||
const m = new Map<string, string>()
|
||||
return {
|
||||
get: async k => m.get(k) ?? null,
|
||||
set: async (k, v) => void m.set(k, v),
|
||||
remove: async k => void m.delete(k),
|
||||
dump: () => Object.fromEntries(m),
|
||||
}
|
||||
}
|
||||
|
||||
/** Кладёт ключ в RAM-keystore, как это делает unlockWallet после входа паролём. */
|
||||
function putUnlocked(): void {
|
||||
// publicKey деривуется внутри unlockWallet/unlockWithPin; здесь для setup
|
||||
// достаточно любого валидного вида — реальный pub проверяем после unlockWithPin.
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: 'PUB_K1_setup', privateKey: KEY })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
describe('pin-кэш: persist → unlock без пароля и round-trip к серверу', () => {
|
||||
it('кастомный ПИН: persist под ПИН, lock, unlock тем же ПИН → тот же account и валидный publicKey', async () => {
|
||||
const storage = memoryStorage()
|
||||
putUnlocked()
|
||||
await persistPinCache({ pin: '135790', storage })
|
||||
lockWallet()
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
|
||||
const wallet = await unlockWithPin({ pin: '135790', storage })
|
||||
expect(wallet).not.toBeNull()
|
||||
expect(wallet!.account).toBe(ACCOUNT)
|
||||
expect(wallet!.publicKey).toMatch(new RegExp(`^${PUB}`))
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
// keystore реально наполнен правильным ключом — getWallet отдаёт тот же pub
|
||||
expect((await getWallet()).publicKey).toBe(wallet!.publicKey)
|
||||
}, 30000)
|
||||
|
||||
it('дефолтный ПИН (000000): persist и unlock без аргумента pin — прозрачно', async () => {
|
||||
const storage = memoryStorage()
|
||||
putUnlocked()
|
||||
await persistPinCache({ storage }) // pin не задан → DEFAULT_PIN
|
||||
lockWallet()
|
||||
|
||||
const wallet = await unlockWithPin({ storage }) // pin не задан → DEFAULT_PIN
|
||||
expect(wallet).not.toBeNull()
|
||||
expect(wallet!.account).toBe(ACCOUNT)
|
||||
// тем же значением расшифровывается и явный DEFAULT_PIN
|
||||
lockWallet()
|
||||
expect(await unlockWithPin({ pin: DEFAULT_PIN, storage })).not.toBeNull()
|
||||
}, 30000)
|
||||
|
||||
it('неверный ПИН → VaultDecryptionFailed, keystore остаётся пуст', async () => {
|
||||
const storage = memoryStorage()
|
||||
putUnlocked()
|
||||
await persistPinCache({ pin: '111111', storage })
|
||||
lockWallet()
|
||||
|
||||
const err = await unlockWithPin({ pin: '999999', storage }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
describe('pin-кэш: отсутствие кэша и жизненный цикл', () => {
|
||||
it('нет кэша → unlockWithPin возвращает null (нужен полный вход паролём)', async () => {
|
||||
const storage = memoryStorage()
|
||||
expect(await unlockWithPin({ storage })).toBeNull()
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
})
|
||||
|
||||
it('hasPinCache: false → persist → true → clear → false', async () => {
|
||||
const storage = memoryStorage()
|
||||
expect(await hasPinCache(storage)).toBe(false)
|
||||
putUnlocked()
|
||||
await persistPinCache({ storage })
|
||||
expect(await hasPinCache(storage)).toBe(true)
|
||||
await clearPinCache(storage)
|
||||
expect(await hasPinCache(storage)).toBe(false)
|
||||
}, 30000)
|
||||
|
||||
it('persistPinCache при запертом кошельке → WalletLocked (нечего кэшировать)', async () => {
|
||||
const storage = memoryStorage()
|
||||
lockWallet()
|
||||
const err = await persistPinCache({ storage }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pin-кэш: at-rest — на диске только шифр', () => {
|
||||
it('сохранённая запись не содержит WIF в открытом виде', async () => {
|
||||
const storage = memoryStorage()
|
||||
putUnlocked()
|
||||
await persistPinCache({ pin: '424242', storage })
|
||||
expect(JSON.stringify(storage.dump())).not.toContain(KEY)
|
||||
}, 30000)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
compareSchemaVersions,
|
||||
type CoopIdSchemaPolicy,
|
||||
createSchemaPolicyCache,
|
||||
isSchemaVersionSupported,
|
||||
SCHEMA_POLICY_CACHE_TTL_MS,
|
||||
} from '../src/index'
|
||||
|
||||
const POLICY: CoopIdSchemaPolicy = { current_version: '1', min_supported_version: '1', deprecation: { 0: '2026-01-01' } }
|
||||
|
||||
describe('compareSchemaVersions (Story 4.10)', () => {
|
||||
it('числовое сравнение версий', () => {
|
||||
expect(compareSchemaVersions('0', '1')).toBeLessThan(0)
|
||||
expect(compareSchemaVersions('2', '1')).toBeGreaterThan(0)
|
||||
expect(compareSchemaVersions('1', '1')).toBe(0)
|
||||
})
|
||||
|
||||
it('нечисловая версия → лексикографический фолбэк', () => {
|
||||
expect(compareSchemaVersions('a', 'b')).toBeLessThan(0)
|
||||
expect(compareSchemaVersions('v2', 'v2')).toBe(0)
|
||||
})
|
||||
|
||||
it('isSchemaVersionSupported: не старее минимума', () => {
|
||||
expect(isSchemaVersionSupported('1', '1')).toBe(true)
|
||||
expect(isSchemaVersionSupported('2', '1')).toBe(true)
|
||||
expect(isSchemaVersionSupported('0', '1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSchemaPolicyCache (Story 4.10, FR72)', () => {
|
||||
it('в пределах TTL отдаёт кэш без повторного fetch', async () => {
|
||||
let t = 1000
|
||||
const fetchPolicy = vi.fn().mockResolvedValue(POLICY)
|
||||
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
|
||||
expect(await cache.getMinSupportedVersion()).toBe('1')
|
||||
t += SCHEMA_POLICY_CACHE_TTL_MS - 1
|
||||
await cache.getPolicy()
|
||||
expect(fetchPolicy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('после истечения TTL рефетчит политику', async () => {
|
||||
let t = 1000
|
||||
const fetchPolicy = vi.fn().mockResolvedValue(POLICY)
|
||||
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
|
||||
await cache.getPolicy()
|
||||
t += SCHEMA_POLICY_CACHE_TTL_MS + 1
|
||||
await cache.getPolicy()
|
||||
expect(fetchPolicy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('офлайн (fetch упал) после истечения TTL → отдаёт stale-кэш', async () => {
|
||||
let t = 1000
|
||||
const fetchPolicy = vi.fn()
|
||||
.mockResolvedValueOnce(POLICY)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const cache = createSchemaPolicyCache({ fetchPolicy, now: () => t })
|
||||
await cache.getPolicy()
|
||||
t += SCHEMA_POLICY_CACHE_TTL_MS + 1
|
||||
expect(await cache.getMinSupportedVersion()).toBe('1')
|
||||
})
|
||||
|
||||
it('нет кэша и fetch упал → пробрасывает ошибку', async () => {
|
||||
const fetchPolicy = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
const cache = createSchemaPolicyCache({ fetchPolicy })
|
||||
await expect(cache.getPolicy()).rejects.toThrow('offline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Checksum256, PrivateKey, PublicKey, Signature } from '@wharfkit/antelope'
|
||||
import { base64url, SignJWT } from 'jose'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from '../src/signing'
|
||||
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const ACCOUNT = 'ant'
|
||||
const PUB = PrivateKey.from(KEY).toPublic().toString()
|
||||
|
||||
/** Поддельный, но корректно сформированный session_binding_token (signTimestamp его только декодирует). */
|
||||
async function makeToken(sub = ACCOUNT, jti = 'jti-abc-123'): Promise<string> {
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(sub)
|
||||
.setJti(jti)
|
||||
.setExpirationTime('2m')
|
||||
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wipeKeystore()
|
||||
})
|
||||
|
||||
describe('signTimestamp: COOPOS-native recoverable подпись', () => {
|
||||
it('подписывает {ts,jti,sub}; recoverMessage канонического сообщения даёт тот же pubkey', async () => {
|
||||
const token = await makeToken(ACCOUNT, 'jti-abc-123')
|
||||
const res = await signTimestamp({ sessionBindingToken: token })
|
||||
|
||||
expect(res.sub).toBe(ACCOUNT)
|
||||
expect(res.binding_token_jti).toBe('jti-abc-123')
|
||||
expect(res.public_key).toBe(PUB)
|
||||
expect(res.signature).toMatch(/^SIG_K1_/)
|
||||
|
||||
const msg = new TextEncoder().encode(canonicalTimestampMessage({
|
||||
ts: res.ts,
|
||||
binding_token_jti: res.binding_token_jti,
|
||||
sub: res.sub,
|
||||
}))
|
||||
const recovered = Signature.from(res.signature).recoverMessage(msg).toString()
|
||||
expect(recovered).toBe(PUB)
|
||||
})
|
||||
|
||||
it('подмена сообщения ломает восстановление pubkey (integrity)', async () => {
|
||||
const res = await signTimestamp({ sessionBindingToken: await makeToken() })
|
||||
const tampered = new TextEncoder().encode(canonicalTimestampMessage({
|
||||
ts: res.ts,
|
||||
binding_token_jti: res.binding_token_jti,
|
||||
sub: 'someone-else',
|
||||
}))
|
||||
const recovered = Signature.from(res.signature).recoverMessage(tampered).toString()
|
||||
expect(recovered).not.toBe(PUB)
|
||||
})
|
||||
|
||||
it('запертый кошелёк → WalletLocked', async () => {
|
||||
wipeKeystore()
|
||||
const err = await signTimestamp({ sessionBindingToken: await makeToken() }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
|
||||
it('кошелёк не совпадает с субъектом токена → ClientWalletMismatch', async () => {
|
||||
const err = await signTimestamp({ sessionBindingToken: await makeToken('petrov') }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ClientWalletMismatch)
|
||||
})
|
||||
|
||||
it('битый токен → SessionBindingExpired', async () => {
|
||||
const err = await signTimestamp({ sessionBindingToken: 'not-a-jwt' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
|
||||
})
|
||||
|
||||
it('токен без sub/jti → SessionBindingExpired', async () => {
|
||||
const noClaims = await new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
|
||||
const err = await signTimestamp({ sessionBindingToken: noClaims }).then(() => null, e => e)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
|
||||
})
|
||||
})
|
||||
|
||||
describe('signChainDigest: подпись tx-дайджеста ключом из keystore (мост подписи CoopID)', () => {
|
||||
it('подписывает дайджест; recoverDigest даёт pubkey подписанта (ключ наружу не выходит — только подпись)', async () => {
|
||||
const digestHex = 'ab'.repeat(32) // 32 байта signing-дайджеста
|
||||
const sig = await signChainDigest(digestHex)
|
||||
expect(sig).toMatch(/^SIG_K1_/)
|
||||
const recovered = Signature.from(sig).recoverDigest(Checksum256.from(digestHex)).toString()
|
||||
expect(recovered).toBe(PUB)
|
||||
})
|
||||
|
||||
it('подмена дайджеста ломает восстановление pubkey (integrity)', async () => {
|
||||
const sig = await signChainDigest('ab'.repeat(32))
|
||||
const recovered = Signature.from(sig).recoverDigest(Checksum256.from('cd'.repeat(32))).toString()
|
||||
expect(recovered).not.toBe(PUB)
|
||||
})
|
||||
|
||||
it('запертый кошелёк → WalletLocked (подпись транзакции невозможна без unlock)', async () => {
|
||||
wipeKeystore()
|
||||
const err = await signChainDigest('ab'.repeat(32)).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canonicalTimestampMessage: детерминизм', () => {
|
||||
it('фиксированный алфавитный порядок ключей', () => {
|
||||
expect(canonicalTimestampMessage({ ts: 't', binding_token_jti: 'j', sub: 's' }))
|
||||
.toBe('{"binding_token_jti":"j","sub":"s","ts":"t"}')
|
||||
})
|
||||
})
|
||||
|
||||
/** Верифицирует compact JWS ES256K: разбирает подпись R||S и проверяет её ключом. */
|
||||
function verifyJws(jws: string, pub: string): boolean {
|
||||
const [h, p, s] = jws.split('.')
|
||||
const rs = base64url.decode(s)
|
||||
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
|
||||
return sig.verifyMessage(new TextEncoder().encode(`${h}.${p}`), PublicKey.from(pub))
|
||||
}
|
||||
|
||||
describe('signDocument: локальная подпись compact JWS (Story 2.3)', () => {
|
||||
it('возвращает JWS <header>.<payload>.<signature>; header alg/kid, payload round-trip, подпись валидна', async () => {
|
||||
const doc = new TextEncoder().encode('Протокол собрания №1')
|
||||
const jws = await signDocument({ payload: doc, alg: 'ES256K' })
|
||||
|
||||
const parts = jws.split('.')
|
||||
expect(parts).toHaveLength(3)
|
||||
|
||||
const header = JSON.parse(new TextDecoder().decode(base64url.decode(parts[0])))
|
||||
expect(header).toEqual({ alg: 'ES256K', kid: ACCOUNT })
|
||||
|
||||
// payload декодируется обратно в исходные байты
|
||||
expect(base64url.decode(parts[1])).toEqual(doc)
|
||||
|
||||
// подпись — ровно 64 байта (R||S, без recovery) и валидна для pubkey подписанта
|
||||
expect(base64url.decode(parts[2]).length).toBe(64)
|
||||
expect(verifyJws(jws, PUB)).toBe(true)
|
||||
})
|
||||
|
||||
it('строковый payload эквивалентен его UTF-8 байтам', async () => {
|
||||
const text = 'привет, кооператив'
|
||||
const fromString = await signDocument({ payload: text })
|
||||
const fromBytes = await signDocument({ payload: new TextEncoder().encode(text) })
|
||||
// RFC6979-детерминизм K1: одинаковый вход → одинаковый JWS
|
||||
expect(fromString).toBe(fromBytes)
|
||||
expect(verifyJws(fromString, PUB)).toBe(true)
|
||||
})
|
||||
|
||||
it('integrity: подделка payload в JWS ломает проверку подписи', async () => {
|
||||
const jws = await signDocument({ payload: 'исходный документ' })
|
||||
const [h, , s] = jws.split('.')
|
||||
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
|
||||
expect(verifyJws(tampered, PUB)).toBe(false)
|
||||
})
|
||||
|
||||
it('alg по умолчанию — ES256K', async () => {
|
||||
const jws = await signDocument({ payload: 'x' })
|
||||
const header = JSON.parse(new TextDecoder().decode(base64url.decode(jws.split('.')[0])))
|
||||
expect(header.alg).toBe('ES256K')
|
||||
})
|
||||
|
||||
it('неподдерживаемый alg → AuthV2Error', async () => {
|
||||
const err = await signDocument({ payload: 'x', alg: 'RS256' as 'ES256K' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
|
||||
it('запертый кошелёк → WalletLocked', async () => {
|
||||
wipeKeystore()
|
||||
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { StorageAdapter } from '../src/wallet'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { decryptPrivateKey } from '../src/vault'
|
||||
import { clearLocalVault, loadLocalVault, saveLocalVault, saveToVault, storeVaultBlob } from '../src/wallet'
|
||||
|
||||
const BLOB = {
|
||||
cipher_version: 'aes-256-gcm-v1',
|
||||
kdf_version: 'argon2id-v1',
|
||||
salt: 'c2FsdA',
|
||||
nonce: 'bm9uY2U',
|
||||
ciphertext: 'Y2lwaGVy',
|
||||
auth_tag: 'dGFn',
|
||||
}
|
||||
|
||||
/** In-memory StorageAdapter для теста локальной копии. */
|
||||
function memStorage(): StorageAdapter & { map: Map<string, string> } {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
map,
|
||||
get: async k => map.get(k) ?? null,
|
||||
set: async (k, v) => void map.set(k, v),
|
||||
remove: async k => void map.delete(k),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('storeVaultBlob (Story 11.3) — POST /coop/vault', () => {
|
||||
it('шлёт POST с плоскими subject_type/subject_id + полями blob; 201 ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB)
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/vault')
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ ...BLOB, subject_type: 'participant', subject_id: 'ant' })
|
||||
})
|
||||
|
||||
it('не-ok ответ → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }))
|
||||
await expect(storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'network_error' })
|
||||
})
|
||||
|
||||
it('сеть упала → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down')))
|
||||
await expect(storeVaultBlob('https://coop.example', { subject_type: 'participant', subject_id: 'ant' }, BLOB))
|
||||
.rejects
|
||||
.toMatchObject({ code: 'network_error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('локальная копия vault (Story 11.3)', () => {
|
||||
it('save → load возвращает blob того же аккаунта', async () => {
|
||||
const storage = memStorage()
|
||||
await saveLocalVault(storage, 'ant', BLOB)
|
||||
expect(await loadLocalVault(storage, 'ant')).toEqual(BLOB)
|
||||
})
|
||||
|
||||
it('load для другого аккаунта → null (чужой blob не отдаём)', async () => {
|
||||
const storage = memStorage()
|
||||
await saveLocalVault(storage, 'ant', BLOB)
|
||||
expect(await loadLocalVault(storage, 'someone-else')).toBeNull()
|
||||
})
|
||||
|
||||
it('нет записи → null; clear удаляет', async () => {
|
||||
const storage = memStorage()
|
||||
expect(await loadLocalVault(storage, 'ant')).toBeNull()
|
||||
await saveLocalVault(storage, 'ant', BLOB)
|
||||
await clearLocalVault(storage)
|
||||
expect(await loadLocalVault(storage, 'ant')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveToVault (Story 11.3) — encrypt → POST → локальная копия', () => {
|
||||
it('шифрует ключ паролём, шлёт на сервер и кладёт локально; blob расшифровывается обратно', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const storage = memStorage()
|
||||
const wif = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
|
||||
const blob = await saveToVault({ apiUrl: 'https://coop.example', account: 'ant', privateKey: wif, password: 'P@ss', storage })
|
||||
|
||||
// сервер получил blob с субъектом ant
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://coop.example/coop/vault')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ subject_type: 'participant', subject_id: 'ant', cipher_version: 'aes-256-gcm-v1' })
|
||||
// локальная копия сохранена
|
||||
expect(await loadLocalVault(storage, 'ant')).toEqual(blob)
|
||||
// round-trip: тем же паролём ключ восстанавливается (неверный пароль покрыт wallet.test.ts)
|
||||
expect(await decryptPrivateKey(blob, 'P@ss', { subject_type: 'participant', subject_id: 'ant' })).toBe(wif)
|
||||
}, 60000)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { decryptPrivateKey, encryptPrivateKey } from '../src/vault'
|
||||
import type { VaultSubject } from '../src/vault'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const SUBJECT: VaultSubject = { subject_type: 'participant', subject_id: 'ant' }
|
||||
|
||||
describe('vault: Argon2id + AES-256-GCM', () => {
|
||||
it('round-trip: encrypt → decrypt тем же паролем даёт исходный ключ', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
expect(blob.cipher_version).toBe('aes-256-gcm-v1')
|
||||
expect(blob.kdf_version).toBe('argon2id-v1')
|
||||
const restored = await decryptPrivateKey(blob, PW, SUBJECT)
|
||||
expect(restored).toBe(KEY)
|
||||
})
|
||||
|
||||
it('каждое шифрование — новые salt и nonce', async () => {
|
||||
const a = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const b = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
expect(a.salt).not.toBe(b.salt)
|
||||
expect(a.nonce).not.toBe(b.nonce)
|
||||
expect(a.ciphertext).not.toBe(b.ciphertext)
|
||||
})
|
||||
|
||||
it('неверный пароль → VaultDecryptionFailed', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const err = await decryptPrivateKey(blob, 'wrong-password-000000000000', SUBJECT).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
})
|
||||
|
||||
it('подмена subject_id НЕ ломает расшифровку: AAD не зависит от account (решение 2026-06-15)', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
// другой subject_id, тот же тип и пароль → расшифровка проходит (id не в AAD).
|
||||
const restored = await decryptPrivateKey(blob, PW, { subject_type: 'participant', subject_id: 'petrov' })
|
||||
expect(restored).toBe(KEY)
|
||||
})
|
||||
|
||||
it('подмена типа субъекта (AAD) ломает расшифровку', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const err = await decryptPrivateKey(blob, PW, { subject_type: 'coop', subject_id: 'ant' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
})
|
||||
}, 30000)
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PrivateKey } from '@wharfkit/antelope'
|
||||
import { base64url } from 'jose'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { type KeyValidityWindow, verifyDocumentOffline } from '../src/index'
|
||||
import { signDocument } from '../src/signing'
|
||||
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
|
||||
|
||||
const KEY1 = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PUB1 = PrivateKey.from(KEY1).toPublic().toString()
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
// Второй ключ — результат ротации (Story 3.3). Генерим, чтобы не хардкодить.
|
||||
const key2 = PrivateKey.generate('K1')
|
||||
const KEY2 = key2.toString()
|
||||
const PUB2 = key2.toPublic().toString()
|
||||
|
||||
// key1 активен в блоках [100,200], затем ротация на key2 [201, сейчас].
|
||||
const authority: KeyValidityWindow[] = [
|
||||
{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 },
|
||||
{ public_key: PUB2, valid_from_block: 201, valid_to_block: null },
|
||||
]
|
||||
|
||||
async function signWith(wif: string, payload: string): Promise<string> {
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: PrivateKey.from(wif).toPublic().toString(), privateKey: wif })
|
||||
const jws = await signDocument({ payload })
|
||||
wipeKeystore()
|
||||
return jws
|
||||
}
|
||||
|
||||
afterEach(() => wipeKeystore())
|
||||
|
||||
describe('verifyDocumentOffline: backward-валидность (Story 2.5)', () => {
|
||||
it('подпись старым ключом валидна, если ключ был активен на момент подписи (хотя сейчас ротирован)', async () => {
|
||||
const jws = await signWith(KEY1, 'протокол собрания №7')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.matched_key).toBe(PUB1)
|
||||
})
|
||||
|
||||
it('та же подпись на блоке ПОСЛЕ ротации → невалидна (key_not_active_at_signing)', async () => {
|
||||
const jws = await signWith(KEY1, 'протокол собрания №7')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 250, authority })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('key_not_active_at_signing')
|
||||
})
|
||||
|
||||
it('подпись текущим ключом в его окне → валидна', async () => {
|
||||
const jws = await signWith(KEY2, 'протокол собрания №8')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 300, authority })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.matched_key).toBe(PUB2)
|
||||
})
|
||||
|
||||
it('подделка payload → signature_mismatch', async () => {
|
||||
const jws = await signWith(KEY1, 'оригинальный документ')
|
||||
const [h, , s] = jws.split('.')
|
||||
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
|
||||
const res = await verifyDocumentOffline({ jws: tampered, signedAtBlock: 150, authority })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('signature_mismatch')
|
||||
})
|
||||
|
||||
it('чужой ключ (нет в хронологии подписанта) → signature_mismatch', async () => {
|
||||
const jws = await signWith(KEY2, 'документ')
|
||||
const onlyKey1: KeyValidityWindow[] = [{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 }]
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority: onlyKey1 })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('signature_mismatch')
|
||||
})
|
||||
|
||||
it('некорректный JWS → malformed_jws', async () => {
|
||||
expect((await verifyDocumentOffline({ jws: 'a.b', signedAtBlock: 1, authority })).reason).toBe('malformed_jws')
|
||||
})
|
||||
|
||||
it('неподдерживаемый alg → unsupported_alg', async () => {
|
||||
const h = base64url.encode(new TextEncoder().encode(JSON.stringify({ alg: 'RS256' })))
|
||||
const p = base64url.encode(new TextEncoder().encode('x'))
|
||||
const sig = base64url.encode(new Uint8Array(64))
|
||||
const res = await verifyDocumentOffline({ jws: `${h}.${p}.${sig}`, signedAtBlock: 1, authority })
|
||||
expect(res.reason).toBe('unsupported_alg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { PrivateKey as PrivateKeyType } from '@wharfkit/antelope'
|
||||
import { PrivateKey } from '@wharfkit/antelope'
|
||||
import { base64url } from 'jose'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type CoopChainLink, verifyOffline } from '../src/index'
|
||||
|
||||
// Цепь доверия ano → voskhod → vostok(issuer). Ключи генерим, чтобы не хардкодить.
|
||||
const anoKey = PrivateKey.generate('K1')
|
||||
const voskhodKey = PrivateKey.generate('K1')
|
||||
const issuerKey = PrivateKey.generate('K1') // vostok — издатель, его ключом подписан cert
|
||||
|
||||
const PUB_ANO = anoKey.toPublic().toString()
|
||||
const PUB_VOSKHOD = voskhodKey.toPublic().toString()
|
||||
const PUB_ISSUER = issuerKey.toPublic().toString()
|
||||
|
||||
const CHAIN: CoopChainLink[] = [
|
||||
{ account: 'ano', public_key: PUB_ANO },
|
||||
{ account: 'voskhod', public_key: PUB_VOSKHOD },
|
||||
{ account: 'vostok', public_key: PUB_ISSUER },
|
||||
]
|
||||
|
||||
// Полный доверенный кэш (chain_manifests_cache) — все звенья известны.
|
||||
const TRUSTED: Record<string, string> = { ano: PUB_ANO, voskhod: PUB_VOSKHOD, vostok: PUB_ISSUER }
|
||||
|
||||
const NOW = 1_900_000_000_000 // фиксированный «сейчас» (мс)
|
||||
const FUTURE_EXP = Math.floor(NOW / 1000) + 3600 // +1ч
|
||||
const PAST_EXP = Math.floor(NOW / 1000) - 10
|
||||
|
||||
interface CertOpts {
|
||||
chain?: CoopChainLink[]
|
||||
exp?: number
|
||||
alg?: string
|
||||
signWith?: PrivateKeyType
|
||||
schemaVersion?: string
|
||||
}
|
||||
|
||||
/** Собрать compact JWS-сертификат (формат CertificateService, Story 1.8). */
|
||||
function makeCert(opts: CertOpts = {}): string {
|
||||
const signer = opts.signWith ?? issuerKey
|
||||
const header = base64url.encode(JSON.stringify({ alg: opts.alg ?? 'ES256K', typ: 'JWT', kid: signer.toPublic().toString() }))
|
||||
const payload = base64url.encode(JSON.stringify({
|
||||
coopname: 'vostok',
|
||||
coop_chain: opts.chain ?? CHAIN,
|
||||
exp: opts.exp ?? FUTURE_EXP,
|
||||
sub: 'uuid-1',
|
||||
jti: 'serial-123',
|
||||
claim_schema_version: opts.schemaVersion ?? '1',
|
||||
}))
|
||||
const signingInput = `${header}.${payload}`
|
||||
const sig = signer.signMessage(new TextEncoder().encode(signingInput))
|
||||
const rs = sig.data.array.slice(1) // [recovery(1), r(32), s(32)] → R||S
|
||||
return `${signingInput}.${base64url.encode(rs)}`
|
||||
}
|
||||
|
||||
describe('verifyOffline: офлайн-проверка удостоверения (Story 4.4)', () => {
|
||||
it('валидный сертификат с доверенной цепью и якорем → valid, issuer', async () => {
|
||||
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.issuer).toBe('vostok')
|
||||
expect(res.reason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exp в прошлом → expired', async () => {
|
||||
const res = await verifyOffline(makeCert({ exp: PAST_EXP }), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('expired')
|
||||
})
|
||||
|
||||
it('цепь не укоренена в известном ano (чужой якорь) → untrusted_anchor', async () => {
|
||||
const other = PrivateKey.generate('K1').toPublic().toString()
|
||||
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, trustAnchor: other, now: NOW })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('untrusted_anchor')
|
||||
})
|
||||
|
||||
it('звено издателя не совпадает с доверенным кэшем → untrusted_issuer', async () => {
|
||||
const foreign = PrivateKey.generate('K1').toPublic().toString()
|
||||
const res = await verifyOffline(makeCert(), {
|
||||
trustedKeys: { ano: PUB_ANO, voskhod: PUB_VOSKHOD, vostok: foreign },
|
||||
now: NOW,
|
||||
})
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('untrusted_issuer')
|
||||
})
|
||||
|
||||
it('без доверенного кэша ключей → fail-closed (untrusted_issuer)', async () => {
|
||||
const res = await verifyOffline(makeCert(), { trustAnchor: PUB_ANO, now: NOW })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('untrusted_issuer')
|
||||
})
|
||||
|
||||
it('подпись чужим ключом при валидной цепи → signature_mismatch', async () => {
|
||||
// cert подписан foreignKey, но coop_chain заявляет издателя PUB_ISSUER;
|
||||
// verifyOffline сверяет подпись с ключом издателя из цепи (не с kid) → провал.
|
||||
const cert = makeCert({ signWith: PrivateKey.generate('K1') })
|
||||
const res = await verifyOffline(cert, { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('signature_mismatch')
|
||||
})
|
||||
|
||||
it('high-S подпись (не каноническая, как у jose/Node) → принимается через low-S нормализацию', async () => {
|
||||
const cert = makeCert()
|
||||
const [h, p, s] = cert.split('.')
|
||||
const rs = base64url.decode(s)
|
||||
// S → n − S (переводим каноническую low-S подпись wharfkit в high-S вариант).
|
||||
const n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
|
||||
let sVal = 0n
|
||||
for (const b of rs.slice(32, 64))
|
||||
sVal = (sVal << 8n) | BigInt(b)
|
||||
let high = n - sVal
|
||||
const highRs = new Uint8Array(rs)
|
||||
for (let i = 63; i >= 32; i--) {
|
||||
highRs[i] = Number(high & 0xFFn)
|
||||
high >>= 8n
|
||||
}
|
||||
const highCert = `${h}.${p}.${base64url.encode(highRs)}`
|
||||
const res = await verifyOffline(highCert, { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.issuer).toBe('vostok')
|
||||
})
|
||||
|
||||
it('некорректный JWS (две части) → malformed_certificate', async () => {
|
||||
const res = await verifyOffline('a.b', { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.reason).toBe('malformed_certificate')
|
||||
})
|
||||
|
||||
it('неподдерживаемый alg → unsupported_alg', async () => {
|
||||
const res = await verifyOffline(makeCert({ alg: 'RS256' }), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.reason).toBe('unsupported_alg')
|
||||
})
|
||||
|
||||
it('пустой coop_chain → malformed_certificate', async () => {
|
||||
const res = await verifyOffline(makeCert({ chain: [] }), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.reason).toBe('malformed_certificate')
|
||||
})
|
||||
|
||||
it('версия схемы старее min_supported_version → unsupported_schema_version (Story 4.10)', async () => {
|
||||
const res = await verifyOffline(makeCert({ schemaVersion: '0' }), { trustedKeys: TRUSTED, now: NOW, minSchemaVersion: '1' })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('unsupported_schema_version')
|
||||
})
|
||||
|
||||
it('версия схемы = min_supported_version → ось схемы пройдена, cert валиден (Story 4.10)', async () => {
|
||||
const res = await verifyOffline(makeCert({ schemaVersion: '1' }), { trustedKeys: TRUSTED, now: NOW, minSchemaVersion: '1' })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.issuer).toBe('vostok')
|
||||
})
|
||||
|
||||
it('без minSchemaVersion ось схемы не гейтит — даже старая версия проходит (Story 4.10)', async () => {
|
||||
const res = await verifyOffline(makeCert({ schemaVersion: '0' }), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('не делает сетевых запросов (fetch недоступен) → всё равно verdict', async () => {
|
||||
const orig = globalThis.fetch
|
||||
globalThis.fetch = (() => {
|
||||
throw new Error('сеть запрещена в офлайн-проверке')
|
||||
}) as typeof fetch
|
||||
try {
|
||||
const res = await verifyOffline(makeCert(), { trustedKeys: TRUSTED, now: NOW })
|
||||
expect(res.valid).toBe(true)
|
||||
}
|
||||
finally {
|
||||
globalThis.fetch = orig
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { encryptPrivateKey } from '../src/vault'
|
||||
import {
|
||||
getWallet,
|
||||
isWalletUnlocked,
|
||||
lockWallet,
|
||||
unlockWallet,
|
||||
Wallet,
|
||||
} from '../src/wallet'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const ACCOUNT = 'ant'
|
||||
const API = 'http://stub'
|
||||
|
||||
/** Подменяет fetch так, чтобы GET vault отдавал заранее зашифрованный blob. */
|
||||
async function stubVaultFetch(password = PW): Promise<void> {
|
||||
const blob = await encryptPrivateKey(KEY, password, { subject_type: 'participant', subject_id: ACCOUNT })
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => blob }) as any))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
describe('wallet: unlock + in-memory keystore + getWallet', () => {
|
||||
it('unlock → getWallet отдаёт account и деривованный publicKey', async () => {
|
||||
await stubVaultFetch()
|
||||
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
expect(wallet.account).toBe(ACCOUNT)
|
||||
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
|
||||
const again = await getWallet()
|
||||
expect(again.publicKey).toBe(wallet.publicKey)
|
||||
}, 30000)
|
||||
|
||||
it('неверный пароль → VaultDecryptionFailed, keystore остаётся пуст', async () => {
|
||||
await stubVaultFetch(PW)
|
||||
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: 'wrong-password-000000000000' })
|
||||
.then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
}, 30000)
|
||||
|
||||
it('getWallet при запертом кошельке → WalletLocked', async () => {
|
||||
const err = await getWallet().then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
|
||||
it('сеть недоступна → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) }) as any))
|
||||
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wallet: несериализуемость приватного ключа', () => {
|
||||
it('JSON.stringify(wallet) не раскрывает приватный ключ', async () => {
|
||||
await stubVaultFetch()
|
||||
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
const serialized = JSON.stringify(wallet)
|
||||
expect(serialized).not.toContain(KEY)
|
||||
expect(JSON.parse(serialized)).toEqual({ account: ACCOUNT, publicKey: wallet.publicKey })
|
||||
// приватного ключа нет ни в одном перечислимом свойстве обёртки
|
||||
expect(Object.values(wallet as unknown as Record<string, unknown>)).not.toContain(KEY)
|
||||
expect(wallet).toBeInstanceOf(Wallet)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
describe('wallet: lock/wipe', () => {
|
||||
it('lockWallet затирает keystore → getWallet бросает; идемпотентно', async () => {
|
||||
await stubVaultFetch()
|
||||
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
lockWallet()
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
lockWallet() // повторно — без ошибки
|
||||
const err = await getWallet().then(() => null, e => e)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
}, 30000)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Браузерный таргет cross-runtime smoke (Story 9.13): vitest browser mode,
|
||||
* playwright + chromium headless. Гоняет ТОЛЬКО test/cross-runtime/** —
|
||||
* остальные юнит-тесты пакета остаются Node-таргетом (`pnpm test`).
|
||||
*/
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/cross-runtime/**/*.test.ts'],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
name: 'chromium',
|
||||
headless: true,
|
||||
screenshotFailures: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.6.9-18",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -35,14 +35,7 @@ RUN npm install -g pnpm@10.33.0
|
||||
|
||||
COPY . .
|
||||
|
||||
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
|
||||
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
|
||||
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
|
||||
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
|
||||
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
|
||||
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
|
||||
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
|
||||
&& pnpm rebuild --filter "@coopenomics/boot..."
|
||||
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
|
||||
|
||||
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
|
||||
# свой bundle с inlined workspace-deps.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.6.9-18",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -76,7 +76,7 @@ export class CooperativeClass {
|
||||
await this.blockchain.createProgram({
|
||||
coopname,
|
||||
username: coopname,
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
title: 'Целевая потребительская программа "Благорост"',
|
||||
announce: '',
|
||||
description: '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
|
||||
* (id=4, type='capital') + capital::setconfig.
|
||||
* (id=4, type='blagorost') + capital::setconfig.
|
||||
*
|
||||
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
|
||||
* setconfig — только если `state` пуст.
|
||||
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
await blockchain.api.transact({
|
||||
actions: [{
|
||||
|
||||
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
|
||||
}
|
||||
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
|
||||
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
|
||||
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
|
||||
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
|
||||
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
|
||||
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
|
||||
@@ -12,6 +12,6 @@ export const capitalDraftId = 1000
|
||||
|
||||
export const ratePerHour = '1000.0000 RUB'
|
||||
export const sourceProgramName = 'generator'
|
||||
export const capitalProgramName = 'capital'
|
||||
export const capitalProgramName = 'blagorost'
|
||||
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
|
||||
export const circulationAccountId = 80_000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.6.9-18",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/binary_extension.hpp>
|
||||
|
||||
using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
@@ -41,18 +39,10 @@ namespace Capital {
|
||||
asset program_membership_distributed = asset(0, _root_govern_symbol); ///< Распределенная сумма членских взносов по программе
|
||||
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
|
||||
|
||||
config config; ///< Управляемая конфигурация контракта
|
||||
asset program_expense_pool = asset(0, _root_govern_symbol); ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
|
||||
asset program_expense_reserved = asset(0, _root_govern_symbol); ///< Сумма, зарезервированная под approved/authorized программные расходы
|
||||
|
||||
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ config и обёрнуты в
|
||||
// binary_extension. Поля в таблицу EOSIO можно дописывать только в ХВОСТ
|
||||
// struct и только через binary_extension — иначе ломается десериализация
|
||||
// уже записанного global_state. Инцидент 2026-06-16: эти поля были вставлены
|
||||
// перед config напрямую (без extension) и задеплоены на прод — таблица state
|
||||
// перестала читаться (get table → "Invalid symbol", action'ы падали на unpack).
|
||||
// Инвариант: оба поля материализуются вместе (всегда оба has_value или оба
|
||||
// пустые), чтобы порядок хвостовых extension оставался консистентным.
|
||||
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
|
||||
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
|
||||
config config; ///< Управляемая конфигурация контракта
|
||||
|
||||
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
|
||||
};
|
||||
@@ -63,16 +53,6 @@ namespace Capital {
|
||||
|
||||
|
||||
namespace State {
|
||||
|
||||
/**
|
||||
* @brief Значение binary_extension<asset> или ноль с символом @p sym, если поле
|
||||
* ещё не материализовано (старая запись, созданная до добавления
|
||||
* program_expense_* через binary_extension).
|
||||
*/
|
||||
inline asset ext_or_zero(const eosio::binary_extension<asset>& v, const eosio::symbol& sym) {
|
||||
return v.has_value() ? v.value() : asset(0, sym);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Обновляет глобальное состояние новыми значениями.
|
||||
*
|
||||
@@ -123,12 +103,8 @@ inline void topup_program_expense_pool(eosio::name coopname, const asset &amount
|
||||
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
s.global_available_invest_pool -= amount;
|
||||
pool += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
s.program_expense_pool += amount;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,16 +117,12 @@ inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
|
||||
eosio::check(itr->program_expense_pool >= amount,
|
||||
"Недостаточно средств в пуле программных расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
pool -= amount;
|
||||
reserved += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
s.program_expense_pool -= amount;
|
||||
s.program_expense_reserved += amount;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,16 +135,12 @@ inline void release_program_expense(eosio::name coopname, const asset &amount) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
|
||||
eosio::check(itr->program_expense_reserved >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
reserved -= amount;
|
||||
pool += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
s.program_expense_reserved -= amount;
|
||||
s.program_expense_pool += amount;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,15 +154,11 @@ inline void consume_program_expense(eosio::name coopname, const asset &amount) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
|
||||
eosio::check(itr->program_expense_reserved >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
reserved -= amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
s.program_expense_reserved -= amount;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,15 +172,11 @@ inline void spend_program_expense_pool(eosio::name coopname, const asset &amount
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
|
||||
eosio::check(itr->program_expense_pool >= amount,
|
||||
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
pool -= amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
s.program_expense_pool -= amount;
|
||||
});
|
||||
}
|
||||
}// namespace State
|
||||
|
||||
@@ -80,13 +80,12 @@ static constexpr eosio::name _wallet_program = "wallet"_n; ///< Главный
|
||||
static constexpr eosio::name _marketplace_program = "marketplace"_n; ///< Кошелёк программы "Маркетплейс"
|
||||
|
||||
static constexpr eosio::name _source_program = "generator"_n; ///< Кошелёк для генерации по договору УХД
|
||||
static constexpr eosio::name _capital_program = "capital"_n; ///< Кошелёк программы "Благорост"
|
||||
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
|
||||
|
||||
|
||||
static const std::set<eosio::name> soviet_actions = {
|
||||
"joincoop"_n, //регистрация пайщика
|
||||
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
|
||||
|
||||
|
||||
//MEET
|
||||
"creategm"_n,//предложение повестки планового общего собрание
|
||||
"completegm"_n, //решение общего собрания пайщиков
|
||||
|
||||
@@ -61,7 +61,6 @@ namespace operations {
|
||||
inline constexpr eosio::name SETTLE_MINSHARE = "o.reg.setmin"_n; ///< Зачисление минимального паевого по решению совета (Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND).
|
||||
inline constexpr eosio::name SETTLE_ENTRANCE = "o.reg.setent"_n; ///< Зачисление вступительного по решению совета (Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES).
|
||||
inline constexpr eosio::name REFUND = "o.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (Dr 76 / Cr 51, BURN REGISTRATION_PENDING — деньги уходят из системы банковским переводом кандидату).
|
||||
inline constexpr eosio::name MOVE_MINSHARE = "o.reg.mvmin"_n; ///< Перенос минимального паевого на главный паевой при выходе из кооператива (TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY, без Dr/Cr — оба кошелька на счёте 80). Готовит полный паевой к возврату.
|
||||
}
|
||||
|
||||
// wallet
|
||||
@@ -243,15 +242,6 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат регистрационного взноса при отказе совета" },
|
||||
|
||||
// 2e. Перенос минимального паевого на главный при выходе из кооператива:
|
||||
// TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY (без Dr/Cr — оба кошелька на счёте 80).
|
||||
// Консолидирует минимальный паевой на главный, чтобы вернуть его вместе с
|
||||
// основным паевым через wallet-withdraw (o.wal.wthcpl, Дт 80 / Кт 51).
|
||||
{ operations::registrator::MOVE_MINSHARE, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::MIN_SHARE_FUND, ledger2_wallets::SHARE_FUND_PAY,
|
||||
0, 0,
|
||||
"Перенос минимального паевого на главный при выходе из кооператива" },
|
||||
|
||||
// 3. Внесение паевого взноса: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
|
||||
{ operations::wallet::COMPLETE_DEPOSIT, processes::wallet::DEPOSIT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
|
||||
@@ -252,37 +252,6 @@ inline constexpr std::array<Ledger2WalletProgramMapping, 8> LEDGER2_USER_SHARED_
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
|
||||
}};
|
||||
|
||||
/**
|
||||
* @brief Сет «боевых» (паевых) кошельков пайщика, возвращаемых при выходе из
|
||||
* кооператива (заявление registry 200 → одобрение совета `confirmexit`).
|
||||
*
|
||||
* Единый источник истины для суммы возврата. При одобрении выхода советом
|
||||
* `registrator::confirmexit` обходит этот сет в цикле, аккумулирует доступный
|
||||
* L3-баланс пайщика по каждому кошельку (>0), консолидирует на главный паевой
|
||||
* (`w.wal.share`) и ставит всю сумму на возврат единым платежом. Тот же сет
|
||||
* генерируется в cooptypes (`gen:from-cpp` → `wallets.generated.ts`) и
|
||||
* используется backend-preview, поэтому расчёт на фронте всегда совпадает с
|
||||
* тем, что реально вернёт контракт.
|
||||
*
|
||||
* Состав — только паевые/возвратные USER_SHARED-кошельки:
|
||||
* w.reg.minshr — минимальный паевой взнос;
|
||||
* w.wal.share — целевой паевой взнос (ЦК);
|
||||
* w.cap.blago — паевой взнос в ЦПП «Благорост».
|
||||
*
|
||||
* НЕ входят: w.wal.member (членский — невозвратный), w.exp.adv (подотчёт под
|
||||
* расход), w.cap.gen (Генератор — COOPERATIVE, без L3-разреза по пайщику),
|
||||
* w.cap.preimp (пред-импорт-учёт РИД).
|
||||
*
|
||||
* Каждому не-главному кошельку сета должна соответствовать операция переноса
|
||||
* на `w.wal.share` в `Registrator::consolidate_share_to_main` (exit_helpers.hpp)
|
||||
* — иначе runtime упадёт с явным сообщением (защита от тихой потери средств).
|
||||
*/
|
||||
inline constexpr std::array<eosio::name, 3> LEDGER2_EXIT_REFUND_WALLETS = {{
|
||||
ledger2_wallets::MIN_SHARE_FUND,
|
||||
ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_wallets::BLAGOROST_FUND,
|
||||
}};
|
||||
|
||||
/**
|
||||
* @brief Возвращает required program_id для USER_SHARED-кошелька.
|
||||
*
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
#include "table_registrator_verification.hpp"
|
||||
#include "table_registrator_candidates.hpp"
|
||||
#include "table_registrator_candidates_legacy.hpp"
|
||||
#include "table_registrator_exits.hpp"
|
||||
|
||||
// coops (soviet)
|
||||
#include "coops_access_helpers.hpp"
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "document_core.hpp"
|
||||
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @ingroup public_tables
|
||||
* @ingroup public_registrator_tables
|
||||
* @par table: exits (registrator)
|
||||
*
|
||||
* @brief Заявление пайщика на выход из кооператива и сопровождающий его
|
||||
* процесс возврата паевого взноса.
|
||||
*
|
||||
* Жизненный цикл (зеркало вступления reguser→confirmreg и возврата
|
||||
* wallet::createwthd→authwthd→completewthd):
|
||||
* - exitcoop → запись со статусом `pending`, повестка совета `leavecoop`;
|
||||
* - confirmexit → совет одобрил: статус `authorized`, консолидация
|
||||
* минимального паевого на главный (o.reg.mvmin), резерв
|
||||
* суммы возврата (o.wal.wthreq) и исходящий платёж в gateway;
|
||||
* - completexit → кассир подтвердил выплату: проводка Дт80/Кт51
|
||||
* (o.wal.wthcpl), пайщик удаляется, аккаунт блокируется,
|
||||
* запись стирается;
|
||||
* - declinexit → отказ совета или отклонение платежа: снятие резерва
|
||||
* (o.wal.wthdec, если он был сделан) и стирание записи.
|
||||
*
|
||||
* `quantity` — итоговая сумма возврата, вычисляется контрактом в момент
|
||||
* одобрения советом (минимальный + целевой паевой пайщика по L3-балансам
|
||||
* ledger2), а не доверяется клиенту.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(REGISTRATOR)]] exit {
|
||||
name username;
|
||||
name coopname;
|
||||
name status; ///< pending | authorized
|
||||
time_point_sec created_at;
|
||||
document2 statement; ///< заявление о выходе (registry 200)
|
||||
document2 approved_statement; ///< решение совета о выходе (авторизация)
|
||||
checksum256 exit_hash;
|
||||
asset quantity; ///< итоговая сумма возврата (заполняется на confirmexit)
|
||||
|
||||
uint64_t primary_key() const { return username.value; }
|
||||
checksum256 by_hash() const { return exit_hash; }
|
||||
};
|
||||
|
||||
typedef multi_index<
|
||||
"exits"_n, exit,
|
||||
indexed_by<"byhash"_n, const_mem_fun<exit, checksum256, &exit::by_hash>>>
|
||||
exits_index;
|
||||
|
||||
inline std::optional<exit> get_exit_by_hash(name coopname, const checksum256 &hash) {
|
||||
exits_index primary_index(_registrator, coopname.value);
|
||||
auto secondary_index = primary_index.get_index<"byhash"_n>();
|
||||
|
||||
auto itr = secondary_index.find(hash);
|
||||
if (itr == secondary_index.end()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return *itr;
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -12,12 +12,6 @@
|
||||
#include "src/user/declinerfnd.cpp"
|
||||
#include "src/user/reguser.cpp"
|
||||
|
||||
#include "src/exit/exit_helpers.hpp"
|
||||
#include "src/exit/exitcoop.cpp"
|
||||
#include "src/exit/confirmexit.cpp"
|
||||
#include "src/exit/completexit.cpp"
|
||||
#include "src/exit/declinexit.cpp"
|
||||
|
||||
#include "src/account/createbranch.cpp"
|
||||
#include "src/account/newaccount.cpp"
|
||||
#include "src/account/updateaccnt.cpp"
|
||||
|
||||
@@ -85,12 +85,6 @@ public:
|
||||
[[eosio::action]] void changekey(eosio::name coopname, eosio::name changer, eosio::name username, eosio::public_key public_key);
|
||||
|
||||
[[eosio::action]] void confirmreg(eosio::name coopname, checksum256 registration_hash, document2 authorization);
|
||||
|
||||
// Выход пайщика из кооператива (возврат паевого взноса) — src/exit/*.cpp
|
||||
[[eosio::action]] void exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement);
|
||||
[[eosio::action]] void confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization);
|
||||
[[eosio::action]] void completexit(eosio::name coopname, checksum256 exit_hash);
|
||||
[[eosio::action]] void declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason);
|
||||
[[eosio::action]] void confirmpay(name coopname, checksum256 registration_hash);
|
||||
[[eosio::action]] void declinepay(name coopname, checksum256 registration_hash, std::string reason);
|
||||
[[eosio::action]] void declinereg(name coopname, checksum256 registration_hash, std::string reason);
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @brief Завершение возврата паевого взноса при выходе из кооператива.
|
||||
* Кассир подтвердил исходящий платёж — gateway вызывает этот коллбэк.
|
||||
* Списываем зарезервированную сумму (o.wal.wthcpl: Дт80/Кт51, BURN с
|
||||
* w.wal.wpend), удаляем пайщика из реестра совета и блокируем его аккаунт.
|
||||
* Возврат назад невозможен — повторное участие только через новую регистрацию.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::completexit(eosio::name coopname, checksum256 exit_hash) {
|
||||
require_auth(_gateway);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "authorized"_n, "Только одобренные заявления на выход могут быть завершены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Проводка возврата паевого: списываем резерв w.wal.wpend, Дт80/Кт51.
|
||||
std::string memo = "Возврат паевого взноса при выходе из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::COMPLETE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
|
||||
// Финализируем выход: удаляем пайщика и блокируем аккаунт.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* @brief Одобрение советом выхода пайщика из кооператива.
|
||||
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
|
||||
* обходит сет паевых кошельков LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
|
||||
* w.wal.share + w.cap.blago), собирает доступный L3-баланс каждого, консолидирует
|
||||
* на главный паевой (w.wal.share), резервирует всю сумму (o.wal.wthreq) и создаёт
|
||||
* исходящий платёж в gateway. Если возвращать нечего — выход завершается сразу
|
||||
* без платежа.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param authorization Документ-решение совета о выходе
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet
|
||||
*/
|
||||
void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "pending"_n, "Только ожидающие заявления на выход могут быть одобрены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
|
||||
// клиенту. Обходим сет паевых («боевых») кошельков LEDGER2_EXIT_REFUND_WALLETS
|
||||
// (источник истины — wallets.hpp; он же генерируется в cooptypes для
|
||||
// backend-preview, поэтому расчёт на фронте совпадает с этим): аккумулируем
|
||||
// доступный баланс каждого (>0) и тут же консолидируем его на главный паевой
|
||||
// (w.wal.share), чтобы единым платежом вернуть весь паевой через o.wal.*.
|
||||
eosio::asset total_return = eosio::asset(0, _root_govern_symbol);
|
||||
for (const auto &wallet_name : LEDGER2_EXIT_REFUND_WALLETS) {
|
||||
eosio::asset balance = Registrator::get_user_wallet_available(coopname, wallet_name, username);
|
||||
if (balance.amount <= 0) continue;
|
||||
total_return += balance;
|
||||
Registrator::consolidate_share_to_main(coopname, username, wallet_name, balance, exit_hash);
|
||||
}
|
||||
|
||||
exits.modify(e, _soviet, [&](auto &row) {
|
||||
row.status = "authorized"_n;
|
||||
row.approved_statement = authorization;
|
||||
row.quantity = total_return;
|
||||
});
|
||||
|
||||
if (total_return.amount > 0) {
|
||||
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
|
||||
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::REQUEST_WITHDRAW,
|
||||
total_return, username, exit_hash, memo_req);
|
||||
|
||||
// Создаём исходящий платёж в gateway с коллбэками completexit/declinexit.
|
||||
Action::send<createoutpay_interface>(
|
||||
_gateway,
|
||||
"createoutpay"_n,
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
exit_hash,
|
||||
total_return,
|
||||
_registrator,
|
||||
"completexit"_n,
|
||||
"declinexit"_n
|
||||
);
|
||||
} else {
|
||||
// Возвращать нечего — финализируем выход без платежа.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
exits.erase(e);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @brief Отказ в выходе из кооператива.
|
||||
* Вызывается либо советом при отклонении заявления (до резервирования средств),
|
||||
* либо gateway при отклонении исходящего платежа (после резервирования). Если
|
||||
* сумма возврата уже была зарезервирована — снимаем резерв (o.wal.wthdec:
|
||||
* w.wal.wpend → w.wal.share). Пайщик остаётся действующим членом кооператива.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param reason Причина отказа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet или @p gateway
|
||||
*/
|
||||
void registrator::declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason) {
|
||||
check_auth_and_get_payer_or_fail({_soviet, _gateway});
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Если резерв уже был сделан (отказ платежа после одобрения советом) —
|
||||
// снимаем его, возвращая средства пайщику на главный паевой кошелёк.
|
||||
// Минимальный паевой, ранее консолидированный на главный (o.reg.mvmin),
|
||||
// остаётся на w.wal.share: пайщик сохраняет полную сумму паевого, аналитика
|
||||
// минимального/целевого разреза при несостоявшемся выходе не восстанавливается.
|
||||
if (e->status == "authorized"_n && e->quantity.amount > 0) {
|
||||
std::string memo = "Снятие резерва паевого при отмене выхода из кооператива: " + reason;
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::DECLINE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../../../lib/index.hpp"
|
||||
|
||||
/**
|
||||
* @brief Вспомогательные функции процедуры выхода пайщика из кооператива.
|
||||
*
|
||||
* Вынесены в отдельный заголовок (а не в table_registrator_exits.hpp), т.к.
|
||||
* опираются на ledger2-кошельки и userwallets, которые подключаются позже в
|
||||
* domain/index.hpp. Подключается в registrator.cpp перед exit-экшенами, когда
|
||||
* весь lib/index.hpp уже доступен.
|
||||
*/
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @brief Доступный L3-баланс пайщика на USER_SHARED-кошельке ledger2.
|
||||
*
|
||||
* Возвращает available (без blocked) пары `(wallet_name, username)` из таблицы
|
||||
* userwallets контракта ledger2. Если записи нет — нулевая сумма в базовой
|
||||
* валюте управления.
|
||||
*/
|
||||
inline asset get_user_wallet_available(name coopname, name wallet_name, name username) {
|
||||
userwallets_index userwallets(_ledger2, coopname.value);
|
||||
auto idx = userwallets.get_index<"byuserwallet"_n>();
|
||||
auto it = idx.find(combine_ids(wallet_name.value, username.value));
|
||||
if (it == idx.end()) {
|
||||
return asset(0, _root_govern_symbol);
|
||||
}
|
||||
return it->available;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Консолидация доступного паевого кошелька пайщика на главный
|
||||
* (`w.wal.share`) перед резервом возврата при выходе.
|
||||
*
|
||||
* Главный кошелёк (`w.wal.share`) уже целевой — перенос не нужен. Для остальных
|
||||
* кошельков сета `LEDGER2_EXIT_REFUND_WALLETS` применяется операция переноса на
|
||||
* главный:
|
||||
* w.reg.minshr → o.reg.mvmin (MOVE_MINSHARE);
|
||||
* w.cap.blago → o.cap.wthcap (WITHDRAW_FROM_CAPITAL).
|
||||
*
|
||||
* Кошелёк сета без операции переноса (новый паевой кошелёк забыли смаппить)
|
||||
* валит транзакцию с явным сообщением — защита от тихой потери средств.
|
||||
*/
|
||||
inline void consolidate_share_to_main(name coopname, name username, name wallet_name, asset amount, checksum256 exit_hash) {
|
||||
if (wallet_name == ledger2_wallets::SHARE_FUND_PAY) return; // уже на главном паевом
|
||||
|
||||
eosio::name op;
|
||||
if (wallet_name == ledger2_wallets::MIN_SHARE_FUND) {
|
||||
op = operations::registrator::MOVE_MINSHARE; // w.reg.minshr → w.wal.share
|
||||
} else if (wallet_name == ledger2_wallets::BLAGOROST_FUND) {
|
||||
op = operations::capital::WITHDRAW_FROM_CAPITAL; // w.cap.blago → w.wal.share
|
||||
} else {
|
||||
eosio::check(false,
|
||||
std::string{"Нет операции консолидации паевого кошелька "} + wallet_name.to_string() +
|
||||
" на главный при выходе — добавьте маппинг в consolidate_share_to_main");
|
||||
}
|
||||
|
||||
std::string memo = "Консолидация паевого взноса при выходе, кошелёк=" +
|
||||
wallet_name.to_string() + ", username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, op, amount, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Финализация выхода: удаление пайщика из реестра совета и блокировка
|
||||
* аккаунта в registrator.
|
||||
*
|
||||
* Вызывается по завершении возврата паевого взноса (completexit) либо сразу,
|
||||
* если возвращать нечего (нулевой паевой). После этого `get_participant_or_fail`
|
||||
* для пайщика начинает падать — он лишён права подавать заявления.
|
||||
*/
|
||||
inline void finalize_member_exit(name coopname, name username) {
|
||||
// удаляем пайщика из реестра совета (уменьшит счётчик активных пайщиков)
|
||||
action(
|
||||
permission_level{_registrator, "active"_n},
|
||||
_soviet,
|
||||
"delpartcpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
|
||||
// блокируем аккаунт в картотеке registrator
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
auto account = accounts.find(username.value);
|
||||
eosio::check(account != accounts.end(), "Аккаунт не найден");
|
||||
accounts.modify(account, _registrator, [&](auto &a) {
|
||||
a.status = "blocked"_n;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* @brief Подача заявления на выход из кооператива.
|
||||
* Действующий пайщик подаёт подписанное заявление о выходе (registry 200).
|
||||
* Создаётся объект выхода и повестка совета `leavecoop` на рассмотрение.
|
||||
* Возврат паевого взноса произойдёт после одобрения советом (confirmexit).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Имя пайщика, выходящего из кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param statement Подписанное заявление о выходе (registry 200)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void registrator::exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
// выйти может только действующий пайщик (не заблокированный)
|
||||
get_participant_or_fail(coopname, username);
|
||||
|
||||
// повторная подача запрещена — у пайщика может быть только один процесс выхода
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto existing = exits.find(username.value);
|
||||
eosio::check(existing == exits.end(), "Заявление на выход уже подано");
|
||||
|
||||
auto by_hash = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(!by_hash.has_value(), "Объект выхода уже существует с указанным хэшем");
|
||||
|
||||
// проверяем подпись заявления о выходе
|
||||
verify_document_or_fail(statement);
|
||||
|
||||
exits.emplace(coopname, [&](auto &e) {
|
||||
e.username = username;
|
||||
e.coopname = coopname;
|
||||
e.status = "pending"_n;
|
||||
e.created_at = current_time_point();
|
||||
e.statement = statement;
|
||||
e.exit_hash = exit_hash;
|
||||
e.quantity = asset(0, _root_govern_symbol);
|
||||
});
|
||||
|
||||
// повестка совета на рассмотрение выхода с коллбэками confirmexit/declinexit
|
||||
::Soviet::create_agenda(
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
get_valid_soviet_action("leavecoop"_n),
|
||||
exit_hash,
|
||||
_registrator, // callback_contract
|
||||
"confirmexit"_n, // callback при одобрении
|
||||
"declinexit"_n, // callback при отказе
|
||||
statement,
|
||||
std::string("")
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,6 @@
|
||||
#include "src/fund/fundwithdraw.cpp"
|
||||
|
||||
#include "src/participant/addparticipant.cpp"
|
||||
#include "src/participant/delparticipant.cpp"
|
||||
#include "src/participant/setminamt.cpp"
|
||||
#include "src/participant/block.cpp"
|
||||
#include "src/participant/cancelreg.cpp"
|
||||
|
||||
@@ -139,9 +139,6 @@ public:
|
||||
//regaccount.cpp
|
||||
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
|
||||
|
||||
//delparticipant.cpp
|
||||
[[eosio::action]] void delpartcpnt(eosio::name coopname, eosio::name username);
|
||||
|
||||
//setminamt.cpp
|
||||
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @brief Удаление пайщика из кооператива при выходе
|
||||
* Стирает запись пайщика из реестра совета по завершении процедуры выхода
|
||||
* (после возврата паевого взноса). Зеркало `addpartcpnt`. Если выбывающий
|
||||
* пайщик был активным и имел право голоса — уменьшает счётчик активных
|
||||
* пайщиков в registrator (как это делает `block`).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Наименование выбывающего пайщика
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта в белом списке контрактов
|
||||
*/
|
||||
void soviet::delpartcpnt(eosio::name coopname, eosio::name username) {
|
||||
check_auth_and_get_payer_or_fail(contracts_whitelist);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
participants_index participants(_soviet, coopname.value);
|
||||
auto participant = participants.find(username.value);
|
||||
eosio::check(participant != participants.end(), "Пайщик не найден в кооперативе");
|
||||
|
||||
// Если пайщик был активным и имел право голоса — уменьшаем счётчик активных
|
||||
// пайщиков (счётчик живёт в registrator, см. block.cpp / decparticpnt.cpp).
|
||||
bool had_vote = participant->has_vote;
|
||||
bool was_active = participant->status == "accepted"_n;
|
||||
|
||||
if (was_active && had_vote) {
|
||||
action(
|
||||
permission_level{_soviet, "active"_n},
|
||||
_registrator,
|
||||
"decparticpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
}
|
||||
|
||||
participants.erase(participant);
|
||||
}
|
||||
@@ -12,22 +12,14 @@
|
||||
void soviet::migrate() {
|
||||
require_auth(_soviet); // Проверяем авторизацию
|
||||
|
||||
// Откат program_type для кооператива voskhod, программа id=4: blagorost -> capital.
|
||||
// Предыдущая версия этой миграции переименовывала programs.program_type в
|
||||
// 'blagorost', но coagreements.type для той же программы (program_id=4) всегда
|
||||
// оставался 'capital' и никогда не переименовывался — таблицы разъехались.
|
||||
// 'capital' — каноничное значение (используется coagreements.type, contract-
|
||||
// константой _capital_program и всем controller-кодом); эта миграция приводит
|
||||
// programs.program_type к нему же. Идемпотентно.
|
||||
{
|
||||
eosio::name coopname = "voskhod"_n;
|
||||
programs_index programs(_soviet, coopname.value);
|
||||
auto program_it = programs.find(4);
|
||||
if (program_it != programs.end() && program_it->program_type != "capital"_n) {
|
||||
programs.modify(program_it, _soviet, [&](auto &pr) {
|
||||
pr.program_type = "capital"_n;
|
||||
});
|
||||
}
|
||||
// Миграция program_type для кооператива voskhod, программа id=4 -> blagorost
|
||||
eosio::name coopname = "voskhod"_n;
|
||||
programs_index programs(_soviet, coopname.value);
|
||||
auto program_it = programs.find(4);
|
||||
if (program_it != programs.end() && program_it->program_type != "blagorost"_n) {
|
||||
programs.modify(program_it, _soviet, [&](auto &pr) {
|
||||
pr.program_type = "blagorost"_n;
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.6.9-18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
@@ -28,5 +33,114 @@
|
||||
"consistent-return": "off",
|
||||
"jest/expect-expect": "off",
|
||||
"security/detect-object-injection": "off"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"src/application/**/*.ts",
|
||||
"src/domain/**/*.ts"
|
||||
],
|
||||
"excludedFiles": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/application/agreement/use-cases/agreement.interactor.ts",
|
||||
"src/application/decision/use-cases/decision.interactor.ts",
|
||||
"src/application/cooplace/interactors/cooplace.interactor.ts",
|
||||
"src/application/participant/interactors/participant.interactor.ts",
|
||||
"src/application/system/interactors/load-contacts.interactor.ts",
|
||||
"src/application/system/dto/blockchain-info.dto.ts",
|
||||
"src/application/system/interactors/wif.interactor.ts",
|
||||
"src/domain/process-registry/services/process-registry.service.ts",
|
||||
"src/domain/branch/interfaces/branch-blockchain.port.ts",
|
||||
"src/domain/common/ports/soviet-blockchain.port.ts",
|
||||
"src/domain/common/ports/blockchain.port.ts",
|
||||
"src/domain/blockchain/types/transaction-result.type.ts",
|
||||
"src/domain/cooplace/interfaces/cooplace-blockchain.port.ts",
|
||||
"src/domain/auth/services/auth-domain.service.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"@wharfkit/*",
|
||||
"oidc-client-ts",
|
||||
"ioredis"
|
||||
],
|
||||
"message": "Гексагональный инвариант auth-v2: application/ и domain/ не импортируют wharfkit/oidc-client-ts/ioredis напрямую — только через порты (infrastructure-адаптеры). excludedFiles — легаси-долг до Эпика 7."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/application/auth-v2/**/*.ts"
|
||||
],
|
||||
"excludedFiles": [
|
||||
"src/**/*.spec.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "ImportSpecifier[imported.name='AuthRoles']",
|
||||
"message": "no-authroles-in-authv2: в auth-v2 запрещён роле-ориентированный @AuthRoles — используйте capability-ориентированный @CheckAbility + AuthorizationGuard (Эпик 6). Legacy auth/ сохраняет @AuthRoles до Phase-3 cleanup."
|
||||
},
|
||||
{
|
||||
"selector": "Decorator[expression.callee.name='AuthRoles']",
|
||||
"message": "no-authroles-in-authv2: в auth-v2 запрещён роле-ориентированный @AuthRoles — используйте capability-ориентированный @CheckAbility + AuthorizationGuard (Эпик 6). Legacy auth/ сохраняет @AuthRoles до Phase-3 cleanup."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"excludedFiles": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/application/auth-v2/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.object.name='console'][callee.property.name=/^(log|info|warn|error|debug)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > Identifier.arguments[name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать переменную с секрет-именем напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
},
|
||||
{
|
||||
"selector": "CallExpression[callee.property.name=/^(log|info|warn|error|debug|verbose)$/] > MemberExpression.arguments[property.name=/(password|passwd|privatekey|private_key|secret|token|signature|wif|mnemonic|seed|credential)/i]",
|
||||
"message": "no-sensitive-in-log (Story 8.7): нельзя логировать секрет-поле напрямую — секрет утечёт в production-логи. Не логируй секрет вовсе либо передавай объект (runtime-маскирование log-redaction замаскирует значение)."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import mongoose from 'mongoose';
|
||||
import config from '~/config/config';
|
||||
|
||||
type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
const CAPITAL_DOC_REGISTRY_ID = 994;
|
||||
const TARGET_COOPNAME = 'voskhod';
|
||||
const COLLECTION_NAME = 'doc_private_data';
|
||||
|
||||
const capitalProgramPrivateData = {
|
||||
generator_program_purpose:
|
||||
"развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации., на основе международных кооперативных принципов и законодательства Российской Федерации",
|
||||
eoap_definition:
|
||||
"информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”",
|
||||
generator_task_goal:
|
||||
"центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений",
|
||||
idea_unit_cost:
|
||||
"50",
|
||||
idea_unit_cost_words:
|
||||
"пятьдесят",
|
||||
blagorost_goal_expansion:
|
||||
"развитии информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации",
|
||||
blagorost_goal_reason:
|
||||
"вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате",
|
||||
blagorost_task_expansion:
|
||||
"расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате",
|
||||
blagorost_task_development:
|
||||
"развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений",
|
||||
return_source_description:
|
||||
"аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕАОП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий (подробнее на Сайте)",
|
||||
return_additional_source:
|
||||
"взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП"
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
|
||||
const entries = Object.keys(value as Record<string, unknown>)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const v = (value as Record<string, unknown>)[key];
|
||||
return `${JSON.stringify(key)}:${stableStringify(v)}`;
|
||||
});
|
||||
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
function calculateSha256(value: unknown): string {
|
||||
return createHash('sha256').update(Buffer.from(stableStringify(value), 'utf8')).digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Backfill capital_program_doc_data_hash for voskhod',
|
||||
validUntil: new Date('2027-05-01T00:00:00Z'),
|
||||
|
||||
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
if (config.coopname !== TARGET_COOPNAME) {
|
||||
logger.info(`COOPNAME=${config.coopname}: миграция предназначена только для ${TARGET_COOPNAME}, пропускаю.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const hash = calculateSha256(capitalProgramPrivateData);
|
||||
let mongo: mongoose.Connection | null = null;
|
||||
|
||||
try {
|
||||
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
|
||||
const db = mongo.db;
|
||||
if (!db) {
|
||||
throw new Error('Подключение к MongoDB установлено, но db не инициализирована');
|
||||
}
|
||||
const collection = db.collection(COLLECTION_NAME);
|
||||
await collection.createIndex({ hash: 1 }, { unique: true });
|
||||
await collection.updateOne(
|
||||
{ hash },
|
||||
{
|
||||
$setOnInsert: {
|
||||
hash,
|
||||
registry_id: CAPITAL_DOC_REGISTRY_ID,
|
||||
payload: capitalProgramPrivateData,
|
||||
_created_at: new Date(),
|
||||
},
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
const result = await dataSource.query(
|
||||
`UPDATE extensions
|
||||
SET config = jsonb_set(COALESCE(config, '{}'::jsonb), '{capital_program_doc_data_hash}', to_jsonb($1::text), true),
|
||||
updated_at = NOW()
|
||||
WHERE name = 'capital'`,
|
||||
[hash]
|
||||
);
|
||||
|
||||
logger.info(`capital_program_doc_data_hash=${hash} сохранён для ${TARGET_COOPNAME}; extensions updated=${result?.[1] ?? 'unknown'}.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Ошибка миграции capital_program_doc_data_hash для ${TARGET_COOPNAME}: ${message}`);
|
||||
return false;
|
||||
} finally {
|
||||
await mongo?.close();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
if (config.coopname !== TARGET_COOPNAME) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await dataSource.query(
|
||||
`UPDATE extensions
|
||||
SET config = COALESCE(config, '{}'::jsonb) - 'capital_program_doc_data_hash',
|
||||
updated_at = NOW()
|
||||
WHERE name = 'capital'`
|
||||
);
|
||||
logger.warn('capital_program_doc_data_hash удалён из config capital; запись doc_private_data оставлена как идемпотентный audit trail.');
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 1.4: начальные схемы coop_domain_db (отдельная БД в coop-postgres,
|
||||
* Story 1.1). Миграция открывает СОБСТВЕННОЕ подключение из config.coopDomainDb —
|
||||
* главный dataSource (voskhod) не используется и не передаётся дальше.
|
||||
*
|
||||
* Таблицы:
|
||||
* - vaults — зашифрованные блобы (subject_type-discriminated);
|
||||
* - audit_events — append-only журнал: месячные партиции + DEFAULT,
|
||||
* триггер запрещает UPDATE/DELETE даже владельцу;
|
||||
* - chain_manifests_cache — TTL-aware кеш authority из COOPOS;
|
||||
* - legacy_user_mapping — UUID ↔ Mongo ObjectID на миграционный период;
|
||||
* - access_rules — декларативная матрица CASL Layer 2 (семантика — Story 6.2).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: init coop_domain_db (vaults, audit_events, chain cache, mapping, access_rules)',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
|
||||
try {
|
||||
await db.initialize();
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS vaults (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject_type text NOT NULL CHECK (subject_type IN ('participant', 'coop', 'council_action')),
|
||||
subject_id text NOT NULL,
|
||||
cipher_version smallint NOT NULL DEFAULT 1,
|
||||
kdf_version smallint NOT NULL DEFAULT 1,
|
||||
salt bytea NOT NULL,
|
||||
nonce bytea NOT NULL,
|
||||
ciphertext bytea NOT NULL,
|
||||
auth_tag bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT vaults_subject_uq UNIQUE (subject_type, subject_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
event text NOT NULL,
|
||||
subject_id text,
|
||||
actor text,
|
||||
result text NOT NULL CHECK (result IN ('success', 'failure', 'degraded')),
|
||||
context jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ip inet,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (id, created_at)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
`);
|
||||
|
||||
// Партиции: текущий месяц, следующий и DEFAULT-страховка (создание
|
||||
// последующих помесячных — забота operational runbook, Stories 9.8/9.10).
|
||||
await db.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
m0 date := date_trunc('month', now())::date;
|
||||
m1 date := (date_trunc('month', now()) + interval '1 month')::date;
|
||||
m2 date := (date_trunc('month', now()) + interval '2 month')::date;
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
|
||||
to_char(m0, 'YYYY_MM'), m0, m1);
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
|
||||
to_char(m1, 'YYYY_MM'), m1, m2);
|
||||
EXECUTE 'CREATE TABLE IF NOT EXISTS audit_events_default PARTITION OF audit_events DEFAULT';
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Append-only: триггер держит инвариант даже для владельца таблицы
|
||||
// (GRANT/REVOKE ниже — декларация, заработает буквально при отдельной
|
||||
// migration-роли из prod-плейбука).
|
||||
await db.query(`
|
||||
CREATE OR REPLACE FUNCTION audit_events_append_only() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_events is append-only: % is forbidden', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`);
|
||||
await db.query(`
|
||||
DROP TRIGGER IF EXISTS audit_events_no_update_delete ON audit_events;
|
||||
CREATE TRIGGER audit_events_no_update_delete
|
||||
BEFORE UPDATE OR DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();
|
||||
`);
|
||||
// ROW-триггер не ловит TRUNCATE — закрываем statement-триггером.
|
||||
await db.query(`
|
||||
DROP TRIGGER IF EXISTS audit_events_no_truncate ON audit_events;
|
||||
CREATE TRIGGER audit_events_no_truncate
|
||||
BEFORE TRUNCATE ON audit_events
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION audit_events_append_only();
|
||||
`);
|
||||
// username — идентификатор в DDL, биндинг невозможен: валидируем строго.
|
||||
const role = config.coopDomainDb.username;
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(role))
|
||||
throw new Error(`Недопустимое имя роли COOP_DOMAIN_DB_USERNAME: ${role}`);
|
||||
await db.query(`
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ${role};
|
||||
GRANT INSERT, SELECT ON audit_events TO ${role};
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS chain_manifests_cache (
|
||||
account text PRIMARY KEY,
|
||||
manifest jsonb NOT NULL,
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS legacy_user_mapping (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mongo_object_id text NOT NULL UNIQUE,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_version text NOT NULL DEFAULT 'legacy' CHECK (auth_version IN ('legacy', 'oidc', 'dual')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS access_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
role text NOT NULL,
|
||||
action text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT access_rules_uq UNIQUE (role, action, subject)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events (subject_id, created_at);`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_events_event ON audit_events (event, created_at);`);
|
||||
|
||||
logger.info('coop_domain_db: vaults, audit_events(+партиции, append-only), chain_manifests_cache, legacy_user_mapping, access_rules — готовы');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`coop_domain_db init failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.0 не реализован намеренно: audit_events append-only, удаление схем — только вручную.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 4.7: manual revoke (compromised-key MVP). Таблица `revoked_keys` в
|
||||
* coop_domain_db фиксирует ручной отзыв ключа пайщика председателем: durable
|
||||
* pending-state (AC «или с переходом на pending state») — пока `recovered_at IS NULL`,
|
||||
* ключ пайщика считается отозванным и пайщик обязан пройти recovery (Эпик 3) для
|
||||
* получения нового. `reason`/`revoked_by` — для audit-трейла KeyRevokedManually.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: revoked_keys',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS revoked_keys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_id text NOT NULL,
|
||||
reason text NOT NULL,
|
||||
revoked_by text NOT NULL,
|
||||
revoked_at timestamptz NOT NULL DEFAULT now(),
|
||||
recovered_at timestamptz
|
||||
);
|
||||
`);
|
||||
// Активный отзыв (ожидает recovery) ищется по target_id + recovered_at IS NULL.
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_revoked_keys_target_active ON revoked_keys (target_id, recovered_at);`);
|
||||
logger.info('revoked_keys создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`revoked_keys migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.10 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 8.1: audit_events schema (append-only + помесячные партиции + триггеры +
|
||||
* GRANT) уже создан в V2.4.0. Эта миграция закрывает остаток AC, отложенный тогда:
|
||||
* - колонка `user_agent` (форензика, наполняет 8.2);
|
||||
* - выделенная read-only роль `coop_audit_reader` (AC: INSERT приложению, SELECT читателю
|
||||
* аудита) — в V2.4.0 разделение ролей было отложено в prod-плейбук;
|
||||
* - форвард-роллинг партиций на текущий+2 месяца (идемпотентно);
|
||||
* - re-assert append-only грантов приложению.
|
||||
* Имена колонок event/context/created_at сохранены намеренно (боевая партиционированная
|
||||
* append-only таблица, ~30 точек записи) — семантически = AC action/metadata/timestamp.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: audit_events user_agent + coop_audit_reader role',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
|
||||
// 1. user_agent — AC явно перечисляет в структуре; nullable (backfill невозможен).
|
||||
await db.query(`ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS user_agent text;`);
|
||||
|
||||
// 2. coop_audit_reader (NOLOGIN group-role) — SELECT-only для читателя аудита.
|
||||
// Создание под insufficient_privilege-страховкой: в рестриктед prod без CREATEROLE
|
||||
// роль заведёт плейбук, миграция не падает (как и декларация грантов в V2.4.0).
|
||||
await db.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coop_audit_reader') THEN
|
||||
BEGIN
|
||||
CREATE ROLE coop_audit_reader NOLOGIN;
|
||||
EXCEPTION WHEN insufficient_privilege THEN
|
||||
RAISE NOTICE 'coop_audit_reader не создан: нет CREATEROLE; роль заведёт prod-playbook';
|
||||
END;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await db.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coop_audit_reader') THEN
|
||||
EXECUTE 'GRANT SELECT ON audit_events TO coop_audit_reader';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// 3. Форвард-роллинг партиций: текущий + следующие 2 месяца (идемпотентно).
|
||||
await db.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
m date;
|
||||
m_next date;
|
||||
i int;
|
||||
BEGIN
|
||||
FOR i IN 0..2 LOOP
|
||||
m := (date_trunc('month', now()) + (i || ' month')::interval)::date;
|
||||
m_next := (date_trunc('month', now()) + ((i + 1) || ' month')::interval)::date;
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
|
||||
to_char(m, 'YYYY_MM'), m, m_next);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// 4. Re-assert append-only грантов приложению (идемпотентно; имя роли валидируем —
|
||||
// идентификатор в DDL, биндинг невозможен).
|
||||
const role = config.coopDomainDb.username;
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(role))
|
||||
throw new Error(`Недопустимое имя роли COOP_DOMAIN_DB_USERNAME: ${role}`);
|
||||
await db.query(`
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ${role};
|
||||
GRANT INSERT, SELECT ON audit_events TO ${role};
|
||||
`);
|
||||
|
||||
logger.info('audit_events: user_agent добавлен, coop_audit_reader + партиции + гранты выровнены');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`audit_events schema migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.11 не реализован: audit_events append-only, дроп колонки/роли — только вручную.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 6.11: назначаемые наборы возможностей (расширяемые роли). Председатель
|
||||
* назначает пайщику именованный НАБОР («бухгалтер»/«кассир»); правила набора живут в
|
||||
* `access_rules` (subject_type='capability_set', Story 6.2) — движок CASL переиспользуется.
|
||||
*
|
||||
* - `capability_sets` — реестр шаблонов (платформенные seed + кооп-кастом).
|
||||
* - `participant_capability_sets` — назначение пайщик→набор (UNIQUE username+set_key,
|
||||
* revoked_at для отзыва, expires_at для TTL).
|
||||
*
|
||||
* Seed: два платформенных набора (accountant/cashier) + их правила доступа к столам.
|
||||
* Имена subject доступа (`AccountingDesk`/`PaymentRegistry`) — grant-строки для desktop
|
||||
* meta.requires; уточняются при разводке desktop-gating.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: capability_sets',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS capability_sets (
|
||||
set_key text PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
builtin boolean NOT NULL DEFAULT false,
|
||||
coopname text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS participant_capability_sets (
|
||||
id bigserial PRIMARY KEY,
|
||||
username text NOT NULL,
|
||||
set_key text NOT NULL,
|
||||
granted_by text NOT NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
CONSTRAINT uq_participant_capability_set UNIQUE (username, set_key)
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_participant_capability_sets_username ON participant_capability_sets (username);`);
|
||||
|
||||
// --- Seed платформенных наборов (идемпотентно) ---
|
||||
await db.query(`
|
||||
INSERT INTO capability_sets (set_key, title, description, builtin, coopname) VALUES
|
||||
('accountant', 'Бухгалтер', 'Доступ к столу бухгалтера (чтение реестра и связанных данных кооператива).', true, NULL),
|
||||
('cashier', 'Кассир', 'Управление реестром платежей: подтверждение входящих и исходящих платежей.', true, NULL)
|
||||
ON CONFLICT (set_key) DO NOTHING;
|
||||
`);
|
||||
|
||||
// --- Seed правил наборов в access_rules (идемпотентно через NOT EXISTS) ---
|
||||
const seedRules: Array<{ set: string; action: string; resource: string }> = [
|
||||
{ set: 'accountant', action: 'read', resource: 'AccountingDesk' },
|
||||
{ set: 'cashier', action: 'read', resource: 'PaymentRegistry' },
|
||||
{ set: 'cashier', action: 'confirm', resource: 'PaymentRegistry' },
|
||||
];
|
||||
for (const r of seedRules) {
|
||||
await db.query(
|
||||
`INSERT INTO access_rules (subject_type, subject_id, effect, action, resource_type, conditions, expires_at, updated_at)
|
||||
SELECT 'capability_set', $1, 'allow', $2, $3, NULL, NULL, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM access_rules
|
||||
WHERE subject_type = 'capability_set' AND subject_id = $1
|
||||
AND action = $2 AND resource_type = $3
|
||||
)`,
|
||||
[r.set, r.action, r.resource],
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('capability_sets + participant_capability_sets созданы, seed accountant/cashier применён');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`capability_sets migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.12 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 2.1 (фикс схемы 1.4): cipher_version/kdf_version в `vaults` —
|
||||
* текстовые версии ('aes-256-gcm-v1' / 'argon2id-v1'), а не smallint.
|
||||
* V2.4.0 объявила их smallint по недосмотру; правим отдельной миграцией,
|
||||
* т.к. применённые миграции иммутабельны. Таблица на этом этапе пуста.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: vaults.cipher_version/kdf_version → text',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN cipher_version TYPE text USING cipher_version::text`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN kdf_version TYPE text USING kdf_version::text`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN cipher_version SET DEFAULT 'aes-256-gcm-v1'`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN kdf_version SET DEFAULT 'argon2id-v1'`);
|
||||
logger.info('vaults: cipher_version/kdf_version → text');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`vault version columns alter failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.1 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 3.6: второй фактор (TOTP / Google Authenticator). Таблица
|
||||
* `two_factor` в coop_domain_db — один секрет на пайщика (subject_id = user.id).
|
||||
* `secret_enc` — Base32-секрет, зашифрованный server-key (aes.ts); сервер обязан
|
||||
* его читать, чтобы проверять коды (это НЕ ключ пайщика — инвариант vault цел).
|
||||
* `enabled=false` — секрет выпущен, но enrollment ещё не подтверждён первым кодом.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: two_factor (TOTP secrets)',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS two_factor (
|
||||
subject_id text PRIMARY KEY,
|
||||
secret_enc text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
confirmed_at timestamptz
|
||||
);
|
||||
`);
|
||||
logger.info('two_factor создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`two_factor migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.2 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 3.4: альтернативный recovery по offline-коду. Таблица
|
||||
* `offline_recovery_code` в coop_domain_db — один код на пайщика (subject_id =
|
||||
* user.id). `code_hash` — keyed HMAC-SHA256(server_secret, code), детерминирован
|
||||
* для lookup по точному хешу; сырой код не хранится. Single-use: строка удаляется
|
||||
* после выдачи recovery-токена (см. OfflineRecoveryService).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: offline_recovery_code',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS offline_recovery_code (
|
||||
subject_id text PRIMARY KEY,
|
||||
code_hash text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
logger.info('offline_recovery_code создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`offline_recovery_code migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.3 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 3.5: стратегия восстановления пайщика. Таблица `recovery_strategy`
|
||||
* в coop_domain_db — одна запись на пайщика (subject_id = user.id). `strategy` —
|
||||
* один из email_magic_link|offline_code|council; отсутствие записи = дефолт
|
||||
* email_magic_link (обратная совместимость с 3.1).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: recovery_strategy',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS recovery_strategy (
|
||||
subject_id text PRIMARY KEY,
|
||||
strategy text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
logger.info('recovery_strategy создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`recovery_strategy migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.4 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 4.2: per-coop правила применения типов верификации. Таблица
|
||||
* `verification_rules` в coop_domain_db — одна запись на действие (action_code =
|
||||
* открытый идентификатор, PRIMARY KEY). `required_types` — обязательные типы
|
||||
* верификации (`text[]`). Отсутствие записи = действие без ограничений по верификации.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: verification_rules',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS verification_rules (
|
||||
action_code text PRIMARY KEY,
|
||||
required_types text[] NOT NULL DEFAULT '{}',
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
logger.info('verification_rules создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`verification_rules migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.5 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 4.6: настройки кооператива в coop_domain_db (таблица `coop_settings`).
|
||||
* Singleton-строка (`id = 1`) — coop_domain_db обслуживает один кооператив, поэтому
|
||||
* глобальные настройки = одна запись; новые настройки добавляются колонками.
|
||||
* `cert_ttl_seconds` — срок жизни participant_certificate (default 3600 = 1ч);
|
||||
* короткий TTL ограничивает окно атаки при компрометации ключа (Story 4.6 AC).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: coop_settings',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS coop_settings (
|
||||
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
cert_ttl_seconds integer NOT NULL DEFAULT 3600,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await db.query(`INSERT INTO coop_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;`);
|
||||
logger.info('coop_settings создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`coop_settings migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.6 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 6.2: CASL Layer 2 — декларативные точечные права. Таблица
|
||||
* `access_rules` в coop_domain_db: правило навешивается на роль (`subject_type='role'`,
|
||||
* `subject_id` = имя core-роли) либо на конкретного пайщика (`subject_type='participant'`,
|
||||
* `subject_id` = username). `effect` allow/deny, `action`/`resource_type` — CASL-словарь,
|
||||
* `conditions` — CASL-условия (jsonb). `expires_at` — TTL точечных capabilities (Story 6.7).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: access_rules',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
// V2.4.0 init создал плейсхолдер access_rules со старой схемой (role/action/subject)
|
||||
// без колонки subject_type. На такой БД `CREATE TABLE IF NOT EXISTS` ниже = no-op,
|
||||
// а индекс по subject_type упал бы. Сносим пустой плейсхолдер (Layer 2 ещё ниоткуда
|
||||
// не пишется → DROP безопасен). Идемпотентно: при правильной схеме DROP не выполняется.
|
||||
await db.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'access_rules'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'access_rules' AND column_name = 'subject_type'
|
||||
) THEN
|
||||
DROP TABLE access_rules;
|
||||
RAISE NOTICE 'access_rules: снесён плейсхолдер V2.4.0';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS access_rules (
|
||||
id bigserial PRIMARY KEY,
|
||||
subject_type text NOT NULL,
|
||||
subject_id text NOT NULL,
|
||||
effect text NOT NULL DEFAULT 'allow',
|
||||
action text NOT NULL,
|
||||
resource_type text NOT NULL,
|
||||
conditions jsonb,
|
||||
expires_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_access_rules_principal ON access_rules (subject_type, subject_id);`);
|
||||
logger.info('access_rules создана');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`access_rules migration failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.7 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 6.3: реконсиляция таблицы `access_rules`. В init-миграции V2.4.0
|
||||
* `access_rules` была создана плейсхолдером со старой схемой (`role/action/subject/
|
||||
* conditions`), а Story 6.2 (V2.4.7) добавила правильную схему через
|
||||
* `CREATE TABLE IF NOT EXISTS` — но на любой БД, где V2.4.0 отработала раньше, V2.4.7
|
||||
* становилась no-op, и таблица оставалась со старой схемой. Из-за этого репозиторий
|
||||
* Layer 2 (`PostgresAccessRulesRepository`, колонки `subject_type/subject_id/effect/
|
||||
* resource_type/expires_at`) упал бы в рантайме на `column "subject_type" does not exist`.
|
||||
*
|
||||
* Фикс forward-миграцией (канон append-only, без правки применённых V2.4.0/V2.4.7):
|
||||
* если таблица существует, но без колонки `subject_type` (= старый плейсхолдер) —
|
||||
* пересоздаём с правильной схемой. Данных в плейсхолдере нет (CoopID не в проде,
|
||||
* Layer 2 ни из какого UI ещё не пишет), поэтому DROP безопасен. Идемпотентно:
|
||||
* на БД с уже правильной схемой DROP не выполняется, CREATE IF NOT EXISTS — no-op.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: reconcile access_rules schema (Layer 2)',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
// Старый плейсхолдер из V2.4.0 (нет колонки subject_type) — снести.
|
||||
await db.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'access_rules'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'access_rules' AND column_name = 'subject_type'
|
||||
) THEN
|
||||
DROP TABLE access_rules;
|
||||
RAISE NOTICE 'access_rules: снесён плейсхолдер V2.4.0';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
// Правильная схема Layer 2 (идентична V2.4.7 — единый источник).
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS access_rules (
|
||||
id bigserial PRIMARY KEY,
|
||||
subject_type text NOT NULL,
|
||||
subject_id text NOT NULL,
|
||||
effect text NOT NULL DEFAULT 'allow',
|
||||
action text NOT NULL,
|
||||
resource_type text NOT NULL,
|
||||
conditions jsonb,
|
||||
expires_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_access_rules_principal ON access_rules (subject_type, subject_id);`);
|
||||
logger.info('access_rules: схема Layer 2 реконсилирована');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`access_rules reconcile failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.8 не реализован: реконсиляция схемы необратима без потери данных.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user