Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
@@ -25,3 +25,6 @@ components/contracts/cpp/ledger2/scripts/out/
|
||||
.pnpm-store
|
||||
schema.gql
|
||||
.claude
|
||||
|
||||
# CoopID: file-based Docker Secrets (генерируются scripts/coopid-gen-secrets.sh)
|
||||
infra/coopid/secrets/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineBuildConfig } from 'unbuild'
|
||||
|
||||
// Один dual-выход (ESM .mjs + CJS .cjs + .d.ts) обслуживает все три target'а:
|
||||
// браузер и desktop-runtime берут import→.mjs, Node — require→.cjs (exports-map).
|
||||
export default defineBuildConfig({
|
||||
entries: ['src/index'],
|
||||
declaration: true,
|
||||
clean: true,
|
||||
rollup: {
|
||||
emitCJS: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// @ts-check
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
type: 'lib',
|
||||
rules: {
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
'no-console': 'off',
|
||||
'ts/explicit-function-return-type': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@coopenomics/auth",
|
||||
"type": "module",
|
||||
"version": "2026.6.9-18",
|
||||
"private": false,
|
||||
"packageManager": "pnpm@9.9.0",
|
||||
"description": "SDK аутентификации CoopID: OIDC, vault, кошелёк и подпись для браузера, Node и desktop",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"keywords": [],
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"./dist/*",
|
||||
"./dist/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "pnpm run typecheck",
|
||||
"build": "unbuild",
|
||||
"dev": "unbuild --stub",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@wharfkit/antelope": "^1.1.1",
|
||||
"@wharfkit/session": "^1.4.0",
|
||||
"cooptypes": "workspace:*",
|
||||
"jose": "^6.2.2",
|
||||
"oidc-client-ts": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.3.2",
|
||||
"@types/node": "^22.5.4",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4",
|
||||
"unbuild": "^2.0.0",
|
||||
"vite": "^5.4.3",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* participant_certificate: чтение claims на клиенте (ЛК, Story 1.9) и offline-
|
||||
* верификация (Story 4.4). Здесь — только ДЕКОДИРОВАНИЕ payload и производные
|
||||
* (статус, человекочитаемые типы верификации). Проверка подписи против trust
|
||||
* anchor — отдельно (verifyOffline, chain/), для отображения в ЛК не требуется.
|
||||
*/
|
||||
import { decodeJwt } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
|
||||
export interface CoopChainLink {
|
||||
account: string
|
||||
public_key: string
|
||||
}
|
||||
|
||||
/** Claims participant_certificate (зеркало payload контроллера, Story 1.8). */
|
||||
export interface ParticipantCertificateClaims {
|
||||
iss: string
|
||||
/** UUID пайщика */
|
||||
sub: string
|
||||
/** серийный номер удостоверения */
|
||||
jti: string
|
||||
iat: number
|
||||
exp: number
|
||||
coopname: string
|
||||
coop_chain: CoopChainLink[]
|
||||
verification_types: string[]
|
||||
identification: Record<string, unknown> | null
|
||||
claim_schema_version: string
|
||||
}
|
||||
|
||||
export type CertificateStatus = 'active' | 'expiring' | 'expired'
|
||||
|
||||
/** Окно «истекает» до exp (24ч по умолчанию у сертификата; здесь — последний час). */
|
||||
export const CERTIFICATE_EXPIRING_WINDOW_MS = 60 * 60 * 1000
|
||||
|
||||
/** Человекочитаемые описания типов верификации (claim `verification_types`). */
|
||||
export const VERIFICATION_TYPE_LABELS: Record<string, string> = {
|
||||
coop_baseline: 'Базовое подтверждение кооперативом',
|
||||
}
|
||||
|
||||
/** Описание типа верификации; неизвестный — отдаём как есть (forward-compat). */
|
||||
export function verificationTypeLabel(type: string): string {
|
||||
return VERIFICATION_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
/**
|
||||
* Декодировать compact JWS в claims БЕЗ проверки подписи (для отображения в ЛК).
|
||||
* Бросает AuthV2Error при структурно некорректном сертификате.
|
||||
*/
|
||||
export function decodeParticipantCertificate(jws: string): ParticipantCertificateClaims {
|
||||
let raw: Record<string, unknown>
|
||||
try {
|
||||
raw = decodeJwt(jws) as Record<string, unknown>
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Некорректный participant_certificate: не удалось прочитать claims')
|
||||
}
|
||||
if (typeof raw.jti !== 'string' || typeof raw.exp !== 'number' || typeof raw.sub !== 'string')
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'participant_certificate без обязательных claims (jti/exp/sub)')
|
||||
|
||||
return {
|
||||
iss: String(raw.iss ?? ''),
|
||||
sub: raw.sub,
|
||||
jti: raw.jti,
|
||||
iat: Number(raw.iat ?? 0),
|
||||
exp: raw.exp,
|
||||
coopname: String(raw.coopname ?? ''),
|
||||
coop_chain: Array.isArray(raw.coop_chain) ? (raw.coop_chain as CoopChainLink[]) : [],
|
||||
verification_types: Array.isArray(raw.verification_types) ? (raw.verification_types as string[]) : [],
|
||||
identification: (raw.identification as Record<string, unknown> | null) ?? null,
|
||||
claim_schema_version: String(raw.claim_schema_version ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
/** Статус по сроку действия: expired / expiring (близко к exp) / active. */
|
||||
export function certificateStatus(claims: Pick<ParticipantCertificateClaims, 'exp'>, nowMs: number = Date.now()): CertificateStatus {
|
||||
const expMs = claims.exp * 1000
|
||||
if (nowMs >= expMs)
|
||||
return 'expired'
|
||||
if (expMs - nowMs <= CERTIFICATE_EXPIRING_WINDOW_MS)
|
||||
return 'expiring'
|
||||
return 'active'
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Цепочка доверия: офлайн-проверка participant_certificate walk'ом
|
||||
* `ano → voskhod → vostok → participant` от embedded trust anchor.
|
||||
* Реализация verify — Story 4.4; чтение ключей `cert` из COOPOS — здесь.
|
||||
*/
|
||||
import { APIClient } from '@wharfkit/antelope'
|
||||
import { base64url } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
|
||||
|
||||
/**
|
||||
* Публичный ключ permission `cert` аккаунта в COOPOS (Story 1.3).
|
||||
* Возвращает строку в формате Antelope (`PUB_K1_...`).
|
||||
*/
|
||||
export async function readCertPublicKey(rpcUrl: string, account: string): Promise<string> {
|
||||
const client = new APIClient({ url: rpcUrl })
|
||||
const acc = await client.v1.chain.get_account(account).catch((e: unknown) => {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.NetworkError,
|
||||
`COOPOS недоступен или аккаунт ${account} не найден: ${e instanceof Error ? e.message : String(e)}`,
|
||||
)
|
||||
})
|
||||
const cert = acc.permissions.find(p => String(p.perm_name) === 'cert')
|
||||
const key = cert?.required_auth.keys[0]?.key
|
||||
if (!cert || !key) {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.ChainVerificationFailed,
|
||||
`На аккаунте ${account} нет permission cert с ключом — цепочка доверия не настроена (Story 1.3).`,
|
||||
)
|
||||
}
|
||||
// MVP-инвариант: cert — строго single-key (multi-sig для ano — Growth).
|
||||
// Молча взять keys[0] у multi-sig значило бы проверять подпись против
|
||||
// одного из N ключей — это дыра в цепочке доверия.
|
||||
const auth = cert.required_auth
|
||||
if (Number(auth.threshold) !== 1 || auth.keys.length !== 1 || auth.accounts.length > 0 || auth.waits.length > 0) {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.ChainVerificationFailed,
|
||||
`Permission cert на ${account} не является single-key (threshold=${auth.threshold}, keys=${auth.keys.length}) — не поддерживается MVP-верификацией.`,
|
||||
)
|
||||
}
|
||||
return key.toString()
|
||||
}
|
||||
|
||||
export interface VerifyOfflineResult {
|
||||
valid: boolean
|
||||
/** Причина отказа, если valid=false (expired / revoked / chain broken) */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** Офлайн-валидация удостоверения без обращения к сети. Story 4.4. */
|
||||
export async function verifyOffline(_certificate: string): Promise<VerifyOfflineResult> {
|
||||
notImplemented('verifyOffline')
|
||||
}
|
||||
|
||||
/**
|
||||
* Окно активности одного публичного ключа подписанта в истории его аккаунта.
|
||||
* Блоки, не время: смена authority в COOPOS привязана к блоку.
|
||||
*/
|
||||
export interface KeyValidityWindow {
|
||||
/** публичный ключ в формате Antelope (`PUB_K1_...`) */
|
||||
public_key: string
|
||||
/** блок, с которого ключ стал активным (включительно) */
|
||||
valid_from_block: number
|
||||
/** блок, по который ключ был активным (включительно); null = активен сейчас */
|
||||
valid_to_block: number | null
|
||||
}
|
||||
|
||||
export type VerifyDocumentReason =
|
||||
| 'malformed_jws'
|
||||
| 'unsupported_alg'
|
||||
| 'signature_mismatch' // ни один исторический ключ не даёт валидную подпись (подделка/чужой)
|
||||
| 'key_not_active_at_signing' // подпись валидна под ключом, но он НЕ был активен на signedAtBlock
|
||||
|
||||
export interface VerifyDocumentParams {
|
||||
/** compact JWS из `signDocument` (Story 2.3) */
|
||||
jws: string
|
||||
/**
|
||||
* Блок, на который проверяется активность ключа — `last_irreversible_block_num`
|
||||
* на момент `iat` подписи. Известен держателю документа (метаданные подписи).
|
||||
*/
|
||||
signedAtBlock: number
|
||||
/**
|
||||
* Историческая хронология ключей подписанта (offline-снимок из
|
||||
* `chain_manifests_cache`). Наполнение кэша (ротация ключей / manifest-sync) —
|
||||
* отдельная задача; здесь — чистая проверка по уже полученной хронологии.
|
||||
*/
|
||||
authority: KeyValidityWindow[]
|
||||
}
|
||||
|
||||
export interface VerifyDocumentResult {
|
||||
valid: boolean
|
||||
reason?: VerifyDocumentReason
|
||||
/** ключ, под которым подпись подтвердилась (если valid) */
|
||||
matched_key?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-валидность подписи документа через chain-walk (Story 2.5). Подпись,
|
||||
* сделанная ключом пайщика ДО ротации, остаётся валидной: проверяем, что ключ был
|
||||
* активен в момент подписи (`signedAtBlock` ∈ окно ключа), а не «активен сейчас».
|
||||
* Так ротация ключа не аннулирует юридически значимые документы.
|
||||
*
|
||||
* Полностью офлайн: работает по переданной хронологии `authority` (кэш манифеста),
|
||||
* без сети. Верификация — `verifyMessage` против ключей-кандидатов (а НЕ recover:
|
||||
* `signDocument` отбрасывает recovery-байт, оставляя JWS-формат R||S).
|
||||
*/
|
||||
export async function verifyDocumentOffline(params: VerifyDocumentParams): Promise<VerifyDocumentResult> {
|
||||
const parts = params.jws.split('.')
|
||||
if (parts.length !== 3)
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
const [h, p, s] = parts
|
||||
|
||||
let header: { alg?: string }
|
||||
try {
|
||||
header = JSON.parse(new TextDecoder().decode(base64url.decode(h)))
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
}
|
||||
if (header.alg !== 'ES256K')
|
||||
return { valid: false, reason: 'unsupported_alg' }
|
||||
|
||||
let rs: Uint8Array
|
||||
try {
|
||||
rs = base64url.decode(s)
|
||||
}
|
||||
catch {
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
}
|
||||
if (rs.length !== 64)
|
||||
return { valid: false, reason: 'malformed_jws' }
|
||||
|
||||
const { PublicKey, Signature } = await import('@wharfkit/antelope')
|
||||
const signingInput = new TextEncoder().encode(`${h}.${p}`)
|
||||
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
|
||||
|
||||
let signedUnderRotatedKey = false
|
||||
for (const w of params.authority) {
|
||||
let ok = false
|
||||
try {
|
||||
ok = sig.verifyMessage(signingInput, PublicKey.from(w.public_key))
|
||||
}
|
||||
catch {
|
||||
ok = false // некорректный ключ в хронологии — пропускаем, не валим всю проверку
|
||||
}
|
||||
if (!ok)
|
||||
continue
|
||||
const active = params.signedAtBlock >= w.valid_from_block
|
||||
&& (w.valid_to_block === null || params.signedAtBlock <= w.valid_to_block)
|
||||
if (active)
|
||||
return { valid: true, matched_key: w.public_key }
|
||||
// подпись валидна, но этот ключ не был активен в момент подписи — продолжаем
|
||||
// искать (вдруг другой ключ окна подходит); запоминаем для точной причины.
|
||||
signedUnderRotatedKey = true
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
reason: signedUnderRotatedKey ? 'key_not_active_at_signing' : 'signature_mismatch',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Публичные RPC-ноды COOPOS — fallback для чтения ключей/manifest'ов,
|
||||
* когда нода кооператива недоступна (используется начиная со Story 9.5).
|
||||
*
|
||||
* TODO(Story 9.5): наполнить актуальным списком публичных нод сети.
|
||||
*/
|
||||
export const COOPOS_PUBLIC_NODES: readonly string[] = []
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Embedded trust anchor цепочки доверия: публичный ключ `ano.cert` (ES256K).
|
||||
* Якорь вшивается в пакет, чтобы verifyOffline() работал без сети.
|
||||
*
|
||||
* TODO(Story 1.3): подставить реальный JWK после создания permission `cert`
|
||||
* на аккаунте `ano` в COOPOS.
|
||||
*/
|
||||
export const TRUST_ANCHOR_ANO_CERT_JWK: JsonWebKey | null = null
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Зеркало enum'а ошибок auth-v2 контроллера (источник истины — controller,
|
||||
* появится в Story 1.11). Расширяется синхронно с серверной стороной.
|
||||
*/
|
||||
export enum AuthV2ErrorCode {
|
||||
NotImplemented = 'not_implemented',
|
||||
InvalidCredentials = 'invalid_credentials',
|
||||
WeakPassword = 'weak_password',
|
||||
SessionBindingReused = 'session_binding_reused',
|
||||
SessionBindingExpired = 'session_binding_expired',
|
||||
TimestampTooOld = 'timestamp_too_old',
|
||||
VaultDecryptionFailed = 'vault_decryption_failed',
|
||||
CertificateExpired = 'certificate_expired',
|
||||
CertificateRevoked = 'certificate_revoked',
|
||||
ChainVerificationFailed = 'chain_verification_failed',
|
||||
CooposDegraded = 'coopos_degraded',
|
||||
TooManyAttempts = 'too_many_attempts',
|
||||
NetworkError = 'network_error',
|
||||
WalletLocked = 'wallet_locked',
|
||||
ClientWalletMismatch = 'client_wallet_mismatch',
|
||||
}
|
||||
|
||||
/**
|
||||
* Ошибки SDK в формате OAuth 2.0 ({ error, error_description }): код — машинный,
|
||||
* description — человеко-читаемое сообщение для UI.
|
||||
*/
|
||||
export class AuthV2Error extends Error {
|
||||
readonly code: AuthV2ErrorCode
|
||||
|
||||
constructor(code: AuthV2ErrorCode, description: string) {
|
||||
super(description)
|
||||
this.name = 'AuthV2Error'
|
||||
this.code = code
|
||||
}
|
||||
|
||||
toJSON(): { error: AuthV2ErrorCode, error_description: string } {
|
||||
return { error: this.code, error_description: this.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Внутренний помощник для каркаса: единообразный отказ нереализованных методов. */
|
||||
export function notImplemented(method: string): never {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.NotImplemented,
|
||||
`Метод ${method}() ещё не реализован: скелет SDK (Story 1.2), реализация приходит историями Эпиков 1–2.`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекомендованное действие для UI при ошибке — определяет, какую кнопку/подсказку
|
||||
* показать пайщику (Story 1.11).
|
||||
*/
|
||||
export type AuthV2ErrorAction =
|
||||
| 'retry' // повторить ввод/вход
|
||||
| 'recover' // предложить восстановление доступа («Восстановить доступ»)
|
||||
| 'check_connection' // проверить интернет
|
||||
| 'contact_support' // обратиться в кооператив
|
||||
| 'none'
|
||||
|
||||
/** Описание ошибки для отображения: сообщение + действие + судьба сессии. */
|
||||
export interface AuthV2ErrorView {
|
||||
/** машинный код (null — неожиданная, не-типизированная ошибка) */
|
||||
code: AuthV2ErrorCode | null
|
||||
/** человеко-читаемое сообщение для UI */
|
||||
message: string
|
||||
/** что предложить пайщику */
|
||||
action: AuthV2ErrorAction
|
||||
/**
|
||||
* Сохранять ли текущую сессию. `true` — НЕ разлогинивать (сбой временный/внешний:
|
||||
* нет связи, кооператив недоступен, кошелёк заперт) — NFR20/FR50: оффлайн не
|
||||
* выкидывает пайщика. `false` — сбой требует повторного входа.
|
||||
*/
|
||||
keepSession: boolean
|
||||
}
|
||||
|
||||
type AuthV2ErrorViewBody = Omit<AuthV2ErrorView, 'code'>
|
||||
|
||||
/**
|
||||
* Каталог человеко-читаемых сообщений по кодам ошибок (Story 1.11). Источник
|
||||
* UI-текстов — здесь; контроллер отдаёт только машинный `error`, клиент по нему
|
||||
* выбирает сообщение и actionable-подсказку. Тексты — по-русски, без раскрытия
|
||||
* технических деталей (например, какое именно поле логина неверно — security).
|
||||
*/
|
||||
export const AUTH_V2_ERROR_VIEWS: Record<AuthV2ErrorCode, AuthV2ErrorViewBody> = {
|
||||
[AuthV2ErrorCode.NotImplemented]: {
|
||||
message: 'Функция пока недоступна.',
|
||||
action: 'none',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.InvalidCredentials]: {
|
||||
message: 'Неверный email или пароль.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.WeakPassword]: {
|
||||
message: 'Пароль слишком простой. Используйте не менее 12 символов и не повторяйте типичные сочетания.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.SessionBindingReused]: {
|
||||
message: 'Сессия входа уже использована. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.SessionBindingExpired]: {
|
||||
message: 'Время на подтверждение входа истекло. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.TimestampTooOld]: {
|
||||
message: 'Истекло время на подтверждение входа. Повторите попытку.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.VaultDecryptionFailed]: {
|
||||
message: 'Не удалось расшифровать кошелёк. Попробуйте восстановить доступ.',
|
||||
action: 'recover',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CertificateExpired]: {
|
||||
message: 'Срок действия удостоверения истёк. Войдите заново, чтобы обновить его.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CertificateRevoked]: {
|
||||
message: 'Удостоверение отозвано. Обратитесь в кооператив.',
|
||||
action: 'contact_support',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.ChainVerificationFailed]: {
|
||||
message: 'Не удалось подтвердить подпись. Обратитесь в поддержку кооператива.',
|
||||
action: 'contact_support',
|
||||
keepSession: false,
|
||||
},
|
||||
[AuthV2ErrorCode.CooposDegraded]: {
|
||||
message: 'Кооператив временно недоступен. Повторите попытку позже.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.TooManyAttempts]: {
|
||||
message: 'Слишком много попыток. Подождите немного и попробуйте снова.',
|
||||
action: 'retry',
|
||||
// временный троттлинг — не разлогиниваем пайщика, просто просим подождать.
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.NetworkError]: {
|
||||
message: 'Нет связи с кооперативом. Проверьте интернет.',
|
||||
action: 'check_connection',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.WalletLocked]: {
|
||||
message: 'Кошелёк заблокирован. Введите пароль для доступа к ключу.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
},
|
||||
[AuthV2ErrorCode.ClientWalletMismatch]: {
|
||||
message: 'Ключ в этом браузере не соответствует аккаунту. Войдите заново.',
|
||||
action: 'retry',
|
||||
keepSession: false,
|
||||
},
|
||||
}
|
||||
|
||||
/** Безопасный фолбэк для неожиданной (не-AuthV2) ошибки — без утечки технических деталей. */
|
||||
const GENERIC_ERROR_VIEW: AuthV2ErrorViewBody = {
|
||||
message: 'Не удалось выполнить операцию. Попробуйте ещё раз.',
|
||||
action: 'retry',
|
||||
keepSession: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Превратить любую брошенную SDK ошибку в готовое к показу описание (Story 1.11):
|
||||
* человеко-читаемое сообщение, рекомендованное действие и флаг сохранения сессии.
|
||||
* Не-типизированные ошибки сводятся к безопасному фолбэку (не показываем `e.message`).
|
||||
*/
|
||||
export function describeAuthV2Error(error: unknown): AuthV2ErrorView {
|
||||
if (error instanceof AuthV2Error)
|
||||
return { code: error.code, ...AUTH_V2_ERROR_VIEWS[error.code] }
|
||||
return { code: null, ...GENERIC_ERROR_VIEW }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Экспорт удостоверения. QR — Vision-stub: фиксирует публичную поверхность,
|
||||
* реализация за пределами MVP.
|
||||
*/
|
||||
import { notImplemented } from '../errors'
|
||||
|
||||
/** Экспорт participant_certificate в QR (Vision). */
|
||||
export async function exportToQR(_certificate: string): Promise<Uint8Array> {
|
||||
notImplemented('exportToQR')
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type { CertificateStatus, CoopChainLink, ParticipantCertificateClaims } from './certificate'
|
||||
export {
|
||||
CERTIFICATE_EXPIRING_WINDOW_MS,
|
||||
certificateStatus,
|
||||
decodeParticipantCertificate,
|
||||
VERIFICATION_TYPE_LABELS,
|
||||
verificationTypeLabel,
|
||||
} from './certificate'
|
||||
|
||||
export type { KeyValidityWindow, VerifyDocumentParams, VerifyDocumentReason, VerifyDocumentResult, VerifyOfflineResult } from './chain'
|
||||
|
||||
export { readCertPublicKey, verifyDocumentOffline, verifyOffline } from './chain'
|
||||
/**
|
||||
* @coopenomics/auth — SDK аутентификации CoopID.
|
||||
*
|
||||
* Публичная поверхность зафиксирована скелетом (Story 1.2): все методы
|
||||
* типизированы и бросают AuthV2Error(not_implemented) до своей реализации.
|
||||
*/
|
||||
export { COOPOS_PUBLIC_NODES } from './config/coopos-public-nodes'
|
||||
|
||||
export { TRUST_ANCHOR_ANO_CERT_JWK } from './config/trust-anchor'
|
||||
export { AUTH_V2_ERROR_VIEWS, AuthV2Error, AuthV2ErrorCode, describeAuthV2Error } from './errors'
|
||||
export type { AuthV2ErrorAction, AuthV2ErrorView } from './errors'
|
||||
|
||||
export { exportToQR } from './export'
|
||||
export type { LoginParams, LoginResult, LogoutParams } from './oidc'
|
||||
|
||||
export { getAccessToken, getParticipantCertificate, login, loginWithMagicLink, logout, recover } from './oidc'
|
||||
export type { SignDocumentParams, TimestampSignature } from './signing'
|
||||
|
||||
export { canonicalTimestampMessage, signDocument, signTimestamp } from './signing'
|
||||
export type { EncryptedVaultBlob, VaultSubject } from './vault'
|
||||
|
||||
export { ARGON2ID_PARAMS, decryptPrivateKey, deriveKey, encryptPrivateKey } from './vault'
|
||||
export type { StorageAdapter } from './wallet'
|
||||
|
||||
export { clearPinProtected, fetchVaultBlob, getWallet, isWalletUnlocked, lockWallet, rotateKey, unlockWallet, unlockWithPin, Wallet } from './wallet'
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* OIDC-слой: вход через authentik (password + timestamp-signature),
|
||||
* magic-link, recovery и работа с токенами (oidc-client-ts).
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
|
||||
import { clearPinProtected, lockWallet, type StorageAdapter } from '../wallet'
|
||||
|
||||
export interface LoginParams {
|
||||
/** Issuer кооператива, например `https://coop.example/application/o/coopid/` */
|
||||
issuer: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string
|
||||
idToken: string
|
||||
/** compact JWS ES256K, выпускается controller'ом */
|
||||
participantCertificate: string
|
||||
}
|
||||
|
||||
/** Двухэтапный вход: password (authentik) → timestamp-signature (controller). Story 1.7. */
|
||||
export async function login(_params: LoginParams): Promise<LoginResult> {
|
||||
notImplemented('login')
|
||||
}
|
||||
|
||||
/** Вход по magic-link (восстановление доступа). Story 3.1. */
|
||||
export async function loginWithMagicLink(_link: string): Promise<LoginResult> {
|
||||
notImplemented('loginWithMagicLink')
|
||||
}
|
||||
|
||||
/** Восстановление доступа (magic-link / offline-код, по стратегии кооператива). Эпик 3. */
|
||||
export async function recover(_email: string): Promise<void> {
|
||||
notImplemented('recover')
|
||||
}
|
||||
|
||||
/** Текущий access_token (с автообновлением через refresh). Story 1.7. */
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
notImplemented('getAccessToken')
|
||||
}
|
||||
|
||||
/**
|
||||
* Актуальное participant_certificate текущей сессии — compact JWS из
|
||||
* `GET /coop/certificate` (Story 1.8). `accessToken` — платформенный токен входа
|
||||
* (Bearer). Декодирование claims — `decodeParticipantCertificate` (certificate/).
|
||||
*/
|
||||
export async function getParticipantCertificate(apiUrl: string, accessToken: string): Promise<string> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/certificate`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе удостоверения: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить удостоверение (HTTP ${res.status})`)
|
||||
const body = (await res.json()) as { participant_certificate: string }
|
||||
return body.participant_certificate
|
||||
}
|
||||
|
||||
export interface LogoutParams {
|
||||
/** Базовый URL controller'а кооператива (например `https://coop.example`). */
|
||||
apiUrl: string
|
||||
/** refresh_token текущей сессии — отзывается на сервере. */
|
||||
refreshToken: string
|
||||
/** access_token (опционально) — тоже отзывается. */
|
||||
accessToken?: string
|
||||
/** Хранилище PIN-protected ключа — стирается, если вход по PIN был включён. */
|
||||
pinStorage?: StorageAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* RP-initiated logout (Story 1.10): отзыв токенов на сервере + затирание локального
|
||||
* keystore. Серверный вызов — best-effort; локальное затирание ключа выполняется
|
||||
* ВСЕГДА (в `finally`), даже если сервер недоступен — безопасность важнее «чистого»
|
||||
* logout: расшифрованный ключ не должен остаться в памяти браузера при сетевом сбое.
|
||||
* Редирект на login — на стороне вызывающего. Стандартный OIDC end-session — Story 5.1.
|
||||
*/
|
||||
export async function logout(params: LogoutParams): Promise<void> {
|
||||
try {
|
||||
await fetch(`${params.apiUrl.replace(/\/$/, '')}/coop/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: params.refreshToken, access_token: params.accessToken }),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// best-effort: недоступность сервера не должна блокировать локальное затирание ключа
|
||||
}
|
||||
finally {
|
||||
lockWallet()
|
||||
if (params.pinStorage)
|
||||
await clearPinProtected(params.pinStorage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Подпись: документы (Story 2.3) и timestamp-метка второго этапа аутентификации
|
||||
* (Story 2.4). Полностью локальные операции (без сети); подпись timestamp НЕ
|
||||
* создаёт audit_events.
|
||||
*/
|
||||
import { base64url, decodeJwt } from 'jose'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { currentView, readUnlockedKey } from '../wallet/storage'
|
||||
|
||||
export interface SignDocumentParams {
|
||||
/** Содержимое документа: байты или строка (кодируется UTF-8). */
|
||||
payload: Uint8Array | string
|
||||
/** Алгоритм подписи; поддерживается ES256K (COOPOS-кривая secp256k1). */
|
||||
alg?: 'ES256K'
|
||||
}
|
||||
|
||||
export interface TimestampSignature {
|
||||
ts: string
|
||||
binding_token_jti: string
|
||||
sub: string
|
||||
signature: string
|
||||
public_key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Каноническое сообщение, которое подписывается и которое сервер обязан собрать
|
||||
* байт-в-байт для `recoverMessage` (`/coop/verify/timestamp`, Story 1.7). Ключи в
|
||||
* фиксированном алфавитном порядке — детерминизм между клиентом и сервером.
|
||||
* Экспортируется, чтобы controller переиспользовал ту же канонизацию.
|
||||
*/
|
||||
export function canonicalTimestampMessage(payload: { ts: string, binding_token_jti: string, sub: string }): string {
|
||||
return JSON.stringify({ binding_token_jti: payload.binding_token_jti, sub: payload.sub, ts: payload.ts })
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная подпись документа ключом пайщика (Story 2.3). Возвращает compact JWS
|
||||
* `<header>.<payload>.<signature>` (alg=ES256K). Полностью офлайн: без сети и без
|
||||
* `audit_events` — сервер о подписи не знает (AC: подпись неоспорима и не зависит
|
||||
* от состояния сервера).
|
||||
*
|
||||
* Подпись делается тем же кросс-рантайм secp256k1-примитивом, что и `signTimestamp`
|
||||
* (`@wharfkit/antelope`): jose в браузере ES256K не умеет (WebCrypto без secp256k1),
|
||||
* поэтому compact JWS собирается вручную, а K1-подпись сериализуется в JWS-формат
|
||||
* R||S (recovery-байт отбрасывается — верификация по pubkey, не recover). `kid` в
|
||||
* заголовке = COOPOS-аккаунт подписанта (разрешение ключа при верификации, Story 2.5).
|
||||
*/
|
||||
export async function signDocument(params: SignDocumentParams): Promise<string> {
|
||||
const alg = params.alg ?? 'ES256K'
|
||||
if (alg !== 'ES256K')
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `Неподдерживаемый алгоритм подписи: ${alg}`)
|
||||
|
||||
const { account } = currentView() // бросает WalletLocked, если заперт
|
||||
const wif = readUnlockedKey()
|
||||
|
||||
const payloadBytes = typeof params.payload === 'string'
|
||||
? new TextEncoder().encode(params.payload)
|
||||
: params.payload
|
||||
|
||||
const header = base64url.encode(JSON.stringify({ alg, kid: account }))
|
||||
const payloadB64 = base64url.encode(payloadBytes)
|
||||
const signingInput = `${header}.${payloadB64}`
|
||||
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const sig = PrivateKey.from(wif).signMessage(new TextEncoder().encode(signingInput))
|
||||
// K1-подпись сериализуется как [recovery(1), r(32), s(32)] = 65б; JWS ES256K = R||S
|
||||
// (64б) — recovery-байт не нужен (верификация по pubkey, не recover).
|
||||
const rs = sig.data.array.slice(1)
|
||||
return `${signingInput}.${base64url.encode(rs)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись метки времени для второго этапа auth (Story 2.4). Берёт `sub`/`jti` из
|
||||
* активного `session_binding_token` (Эпик 1: читаем claims без верификации —
|
||||
* подпись делает локальный ключ, доверие даёт сервер при verify), подписывает
|
||||
* каноническое `{ ts, binding_token_jti, sub }` COOPOS-native recoverable
|
||||
* подписью (SIG_K1_..., секрет — из keystore 2.2). Схема самоописана префиксом
|
||||
* SIG_K1_ — alg не хардкодим. Результат уходит в `/coop/verify/timestamp`, где
|
||||
* сервер `recoverMessage` → сверяет pubkey с COOPOS `get_account`.
|
||||
*/
|
||||
export async function signTimestamp(params: { sessionBindingToken: string }): Promise<TimestampSignature> {
|
||||
let claims: { sub?: string, jti?: string }
|
||||
try {
|
||||
claims = decodeJwt(params.sessionBindingToken)
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'Некорректный session_binding_token: не удалось прочитать claims')
|
||||
}
|
||||
const sub = claims.sub
|
||||
const jti = claims.jti
|
||||
if (!sub || !jti)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'session_binding_token без обязательных claims sub/jti')
|
||||
|
||||
// Кошелёк должен принадлежать тому же субъекту, что и токен (иначе подпишем
|
||||
// метку чужим ключом — сервер всё равно отвергнет, но ловим раньше и понятнее).
|
||||
const wallet = currentView() // бросает WalletLocked, если заперт
|
||||
if (wallet.account !== sub)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ClientWalletMismatch, `Разблокированный кошелёк (${wallet.account}) не совпадает с субъектом токена (${sub})`)
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const message = canonicalTimestampMessage({ ts, binding_token_jti: jti, sub })
|
||||
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
const pk = PrivateKey.from(readUnlockedKey())
|
||||
const signature = pk.signMessage(new TextEncoder().encode(message))
|
||||
|
||||
return {
|
||||
ts,
|
||||
binding_token_jti: jti,
|
||||
sub,
|
||||
signature: signature.toString(),
|
||||
public_key: pk.toPublic().toString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../errors'
|
||||
import { deriveKey } from './kdf'
|
||||
import type { EncryptedVaultBlob, VaultSubject } from './types'
|
||||
|
||||
const CIPHER_VERSION = 'aes-256-gcm-v1'
|
||||
const KDF_VERSION = 'argon2id-v1'
|
||||
const SALT_LEN = 16
|
||||
const NONCE_LEN = 12
|
||||
|
||||
function toB64Url(bytes: Uint8Array): string {
|
||||
let bin = ''
|
||||
for (const b of bytes) bin += String.fromCharCode(b)
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export function fromB64Url(s: string): Uint8Array {
|
||||
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const out = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
||||
return out
|
||||
}
|
||||
|
||||
/** AAD GCM привязывает шифртекст к субъекту: подмена subject_id ломает расшифровку. */
|
||||
function aad(subject: VaultSubject): string {
|
||||
return `${subject.subject_type}|${subject.subject_id}`
|
||||
}
|
||||
|
||||
/** WebCrypto в strict-TS требует ArrayBuffer-backed view; нормализуем Uint8Array. */
|
||||
function buf(u: Uint8Array): ArrayBuffer {
|
||||
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject-агностичное ядро (Story 2.2): Argon2id(пароль, salt) → AES-256-GCM с
|
||||
* произвольной AAD-строкой. Поверх него работают и vault приватного ключа
|
||||
* (AAD=субъект), и PIN-storage (AAD=`pin|<account>`) — без дублирования крипто.
|
||||
*/
|
||||
export async function encryptWithPassword(
|
||||
plaintext: string,
|
||||
password: string,
|
||||
additionalData: string,
|
||||
): Promise<EncryptedVaultBlob> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN))
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN))
|
||||
const keyBytes = deriveKey(password, salt)
|
||||
|
||||
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['encrypt'])
|
||||
const sealed = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: buf(nonce), additionalData: buf(new TextEncoder().encode(additionalData)) },
|
||||
key,
|
||||
buf(new TextEncoder().encode(plaintext)),
|
||||
),
|
||||
)
|
||||
// WebCrypto склеивает ciphertext+tag; tag GCM — последние 16 байт.
|
||||
const tagLen = 16
|
||||
const ciphertext = sealed.slice(0, sealed.length - tagLen)
|
||||
const authTag = sealed.slice(sealed.length - tagLen)
|
||||
|
||||
return {
|
||||
cipher_version: CIPHER_VERSION,
|
||||
kdf_version: KDF_VERSION,
|
||||
salt: toB64Url(salt),
|
||||
nonce: toB64Url(nonce),
|
||||
ciphertext: toB64Url(ciphertext),
|
||||
auth_tag: toB64Url(authTag),
|
||||
}
|
||||
}
|
||||
|
||||
/** Парная расшифровка ядра; неверный пароль/AAD → `VaultDecryptionFailed`. */
|
||||
export async function decryptWithPassword(
|
||||
blob: EncryptedVaultBlob,
|
||||
password: string,
|
||||
additionalData: string,
|
||||
): Promise<string> {
|
||||
const keyBytes = deriveKey(password, fromB64Url(blob.salt))
|
||||
const key = await crypto.subtle.importKey('raw', buf(keyBytes), { name: 'AES-GCM' }, false, ['decrypt'])
|
||||
const sealed = new Uint8Array([...fromB64Url(blob.ciphertext), ...fromB64Url(blob.auth_tag)])
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: buf(fromB64Url(blob.nonce)), additionalData: buf(new TextEncoder().encode(additionalData)) },
|
||||
key,
|
||||
buf(sealed),
|
||||
)
|
||||
return new TextDecoder().decode(plain)
|
||||
} catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Не удалось расшифровать: неверный пароль или повреждённые данные')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентское шифрование приватного ключа пайщика (Story 2.1). Сервер получает
|
||||
* только результат — расшифровать может лишь владелец пароля (AAD=субъект).
|
||||
*/
|
||||
export async function encryptPrivateKey(
|
||||
privateKey: string,
|
||||
password: string,
|
||||
subject: VaultSubject,
|
||||
): Promise<EncryptedVaultBlob> {
|
||||
return encryptWithPassword(privateKey, password, aad(subject))
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная расшифровка (round-trip тесты и keystore 2.2). Серверу недоступна —
|
||||
* type-driven ban (см. controller VaultService).
|
||||
*/
|
||||
export async function decryptPrivateKey(
|
||||
blob: EncryptedVaultBlob,
|
||||
password: string,
|
||||
subject: VaultSubject,
|
||||
): Promise<string> {
|
||||
return decryptWithPassword(blob, password, aad(subject))
|
||||
}
|
||||
@@ -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,117 @@
|
||||
/**
|
||||
* Кошелёк (Story 2.2): разблокировка после логина, доступ к публичному «виду»
|
||||
* ключа в памяти, запирание на logout. Приватный ключ живёт ТОЛЬКО в keystore
|
||||
* (storage.ts) и наружу не выходит — `Wallet` его не содержит и не сериализует.
|
||||
* Desktop-кошелёк переезжает на этот модуль (миграция — Эпик 7).
|
||||
*/
|
||||
import { AuthV2Error, AuthV2ErrorCode, notImplemented } from '../errors'
|
||||
import { decryptPrivateKey } from '../vault/encrypt'
|
||||
import type { EncryptedVaultBlob } from '../vault/types'
|
||||
import { loadPinProtected, savePinProtected, type StorageAdapter } from './pin'
|
||||
import { currentView, isUnlocked, storeUnlocked, wipeKeystore } from './storage'
|
||||
|
||||
/**
|
||||
* Несериализуемая обёртка кошелька: отдаёт только аккаунт и публичный ключ.
|
||||
* Приватного ключа в объекте НЕТ — `JSON.stringify(wallet)` физически не может
|
||||
* его раскрыть. Подпись (Stories 2.3/2.4) берёт ключ из keystore, не из Wallet.
|
||||
*/
|
||||
export class Wallet {
|
||||
readonly account: string
|
||||
readonly publicKey: string
|
||||
|
||||
constructor(view: { account: string, publicKey: string }) {
|
||||
this.account = view.account
|
||||
this.publicKey = view.publicKey
|
||||
}
|
||||
|
||||
toJSON(): { account: string, publicKey: string } {
|
||||
return { account: this.account, publicKey: this.publicKey }
|
||||
}
|
||||
}
|
||||
|
||||
/** Деривует публичный ключ из приватного (WIF K1) через WharfKit antelope. */
|
||||
async function derivePublicKey(privateKey: string): Promise<string> {
|
||||
// Ленивый импорт: тяжёлый antelope не тянем в bundle, пока кошелёк не разблокируют.
|
||||
const { PrivateKey } = await import('@wharfkit/antelope')
|
||||
try {
|
||||
return PrivateKey.from(privateKey).toPublic().toString()
|
||||
}
|
||||
catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.VaultDecryptionFailed, 'Расшифрованное значение не является валидным приватным ключом')
|
||||
}
|
||||
}
|
||||
|
||||
/** GET зашифрованного blob'а пайщика с контроллера (blob-only, см. 2.2). */
|
||||
export async function fetchVaultBlob(apiUrl: string, subjectId: string): Promise<EncryptedVaultBlob> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${apiUrl.replace(/\/$/, '')}/coop/vault/participant/${encodeURIComponent(subjectId)}`)
|
||||
}
|
||||
catch (e) {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Сеть недоступна при запросе vault: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.NetworkError, `Не удалось получить vault (HTTP ${res.status})`)
|
||||
return res.json() as Promise<EncryptedVaultBlob>
|
||||
}
|
||||
|
||||
interface UnlockParams {
|
||||
apiUrl: string
|
||||
account: string
|
||||
password: string
|
||||
/** Опционально сохранить ключ под PIN для быстрой разблокировки на устройстве. */
|
||||
persistPin?: { pin: string, storage: StorageAdapter }
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная разблокировка: забрать blob → расшифровать паролём на клиенте →
|
||||
* положить ключ в keystore. При неверном пароле keystore остаётся пуст.
|
||||
*/
|
||||
export async function unlockWallet(params: UnlockParams): Promise<Wallet> {
|
||||
const blob = await fetchVaultBlob(params.apiUrl, params.account)
|
||||
const privateKey = await decryptPrivateKey(blob, params.password, {
|
||||
subject_type: 'participant',
|
||||
subject_id: params.account,
|
||||
})
|
||||
const publicKey = await derivePublicKey(privateKey)
|
||||
storeUnlocked({ account: params.account, publicKey, privateKey })
|
||||
if (params.persistPin)
|
||||
await savePinProtected(privateKey, params.persistPin.pin, params.account, params.persistPin.storage)
|
||||
return new Wallet({ account: params.account, publicKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* Быстрая разблокировка ранее сохранённым PIN (без обращения к серверу/паролю).
|
||||
* `null` — на устройстве нет PIN-записи. Неверный PIN → `VaultDecryptionFailed`.
|
||||
*/
|
||||
export async function unlockWithPin(pin: string, storage: StorageAdapter): Promise<Wallet | null> {
|
||||
const loaded = await loadPinProtected(pin, storage)
|
||||
if (!loaded)
|
||||
return null
|
||||
const publicKey = await derivePublicKey(loaded.privateKey)
|
||||
storeUnlocked({ account: loaded.account, publicKey, privateKey: loaded.privateKey })
|
||||
return new Wallet({ account: loaded.account, publicKey })
|
||||
}
|
||||
|
||||
/** Текущий разблокированный кошелёк. Бросает `WalletLocked`, если заперт. */
|
||||
export async function getWallet(): Promise<Wallet> {
|
||||
return new Wallet(currentView())
|
||||
}
|
||||
|
||||
/** Заперт ли кошелёк (нет ключа в памяти). */
|
||||
export function isWalletUnlocked(): boolean {
|
||||
return isUnlocked()
|
||||
}
|
||||
|
||||
/** Затирает ключ из памяти (logout). Идемпотентно. PIN-запись не трогает. */
|
||||
export function lockWallet(): void {
|
||||
wipeKeystore()
|
||||
}
|
||||
|
||||
/** Ротация ключа пайщика (updateauth + перешифровка vault). Story 3.3. */
|
||||
export async function rotateKey(): Promise<void> {
|
||||
notImplemented('rotateKey')
|
||||
}
|
||||
|
||||
export type { StorageAdapter } from './pin'
|
||||
export { clearPinProtected } from './pin'
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Опциональный PIN-слой (Story 2.2): обёртка приватного ключа коротким PIN
|
||||
* поверх того же Argon2id+AES-256-GCM, что и vault. Хранится в подключаемом
|
||||
* `StorageAdapter` (в браузере — localStorage/IndexedDB, в тестах/Node — память),
|
||||
* чтобы крипто-логика не зависела от среды и тестировалась без браузера.
|
||||
*
|
||||
* Модель угроз: PIN слабее пароля, но Argon2id держит перебор медленным, а AAD
|
||||
* `pin|<account>` привязывает обёртку к аккаунту. Защита от компрометации самого
|
||||
* устройства — вне scope этого слоя.
|
||||
*/
|
||||
import { decryptWithPassword, encryptWithPassword } from '../vault/encrypt'
|
||||
import type { EncryptedVaultBlob } from '../vault/types'
|
||||
|
||||
/** Минимальный async key-value стор. Браузер: обёртка над localStorage/IndexedDB. */
|
||||
export interface StorageAdapter {
|
||||
get: (key: string) => Promise<string | null>
|
||||
set: (key: string, value: string) => Promise<void>
|
||||
remove: (key: string) => Promise<void>
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'coopid.wallet.pin-vault'
|
||||
|
||||
function aad(account: string): string {
|
||||
return `pin|${account}`
|
||||
}
|
||||
|
||||
interface PinRecord {
|
||||
account: string
|
||||
blob: EncryptedVaultBlob
|
||||
}
|
||||
|
||||
/** Сохраняет ключ под PIN в storage (перезаписывает предыдущую запись). */
|
||||
export async function savePinProtected(
|
||||
privateKey: string,
|
||||
pin: string,
|
||||
account: string,
|
||||
storage: StorageAdapter,
|
||||
): Promise<void> {
|
||||
const blob = await encryptWithPassword(privateKey, pin, aad(account))
|
||||
const record: PinRecord = { account, blob }
|
||||
await storage.set(STORAGE_KEY, JSON.stringify(record))
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает ключ из storage по PIN. `null` — записи нет. Неверный PIN →
|
||||
* `VaultDecryptionFailed` (из ядра). Возвращает `{account, privateKey}`.
|
||||
*/
|
||||
export async function loadPinProtected(
|
||||
pin: string,
|
||||
storage: StorageAdapter,
|
||||
): Promise<{ account: string, privateKey: string } | null> {
|
||||
const raw = await storage.get(STORAGE_KEY)
|
||||
if (!raw)
|
||||
return null
|
||||
const record = JSON.parse(raw) as PinRecord
|
||||
const privateKey = await decryptWithPassword(record.blob, pin, aad(record.account))
|
||||
return { account: record.account, privateKey }
|
||||
}
|
||||
|
||||
/** Удаляет PIN-запись (например, при logout с забыванием устройства). */
|
||||
export async function clearPinProtected(storage: StorageAdapter): Promise<void> {
|
||||
await storage.remove(STORAGE_KEY)
|
||||
}
|
||||
@@ -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,90 @@
|
||||
import { SignJWT } from 'jose'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import {
|
||||
CERTIFICATE_EXPIRING_WINDOW_MS,
|
||||
certificateStatus,
|
||||
decodeParticipantCertificate,
|
||||
verificationTypeLabel,
|
||||
} from '../src/certificate'
|
||||
|
||||
async function makeCert(overrides: Record<string, unknown> = {}, opts: { jti?: string, exp?: string } = {}): Promise<string> {
|
||||
let b = new SignJWT({
|
||||
coopname: 'voskhod',
|
||||
coop_chain: [
|
||||
{ account: 'ano', public_key: 'PUB_K1_ano' },
|
||||
{ account: 'voskhod', public_key: 'PUB_K1_vos' },
|
||||
{ account: 'vostok', public_key: 'PUB_K1_vostok' },
|
||||
],
|
||||
verification_types: ['coop_baseline'],
|
||||
identification: { type: 'individual', username: 'ant', first_name: 'Иван' },
|
||||
claim_schema_version: '1',
|
||||
...overrides,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuer('https://voskhod.coop')
|
||||
.setSubject('uuid-1')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(opts.exp ?? '24h')
|
||||
if (opts.jti !== undefined || overrides.jti === undefined) b = b.setJti(opts.jti ?? 'serial-123')
|
||||
return b.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
|
||||
}
|
||||
|
||||
describe('decodeParticipantCertificate', () => {
|
||||
it('декодирует все claims в типизированный объект', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert())
|
||||
expect(claims.sub).toBe('uuid-1')
|
||||
expect(claims.jti).toBe('serial-123')
|
||||
expect(claims.coopname).toBe('voskhod')
|
||||
expect(claims.claim_schema_version).toBe('1')
|
||||
expect(claims.verification_types).toEqual(['coop_baseline'])
|
||||
expect(claims.coop_chain.map(l => l.account)).toEqual(['ano', 'voskhod', 'vostok'])
|
||||
expect(claims.identification).toMatchObject({ type: 'individual', username: 'ant' })
|
||||
expect(claims.exp).toBeGreaterThan(claims.iat)
|
||||
})
|
||||
|
||||
it('identification может быть null', async () => {
|
||||
const claims = decodeParticipantCertificate(await makeCert({ identification: null }))
|
||||
expect(claims.identification).toBeNull()
|
||||
})
|
||||
|
||||
it('не-JWT строка → AuthV2Error(chain_verification_failed)', () => {
|
||||
const err = (() => { try { decodeParticipantCertificate('garbage'); return null } catch (e) { return e } })()
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ChainVerificationFailed)
|
||||
})
|
||||
|
||||
it('без обязательных claims (нет jti) → AuthV2Error', async () => {
|
||||
// токен без jti
|
||||
const noJti = await new SignJWT({ coopname: 'voskhod' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject('uuid-1')
|
||||
.setExpirationTime('24h')
|
||||
.sign(new TextEncoder().encode('test-secret-padding-000000000000000000'))
|
||||
expect(() => decodeParticipantCertificate(noJti)).toThrow(AuthV2Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('certificateStatus', () => {
|
||||
const now = 1_900_000_000_000 // фиксированный «сейчас» (мс)
|
||||
it('exp в прошлом → expired', () => {
|
||||
expect(certificateStatus({ exp: now / 1000 - 10 }, now)).toBe('expired')
|
||||
})
|
||||
it('exp в окне «истекает» → expiring', () => {
|
||||
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS - 1000) / 1000
|
||||
expect(certificateStatus({ exp }, now)).toBe('expiring')
|
||||
})
|
||||
it('exp далеко → active', () => {
|
||||
const exp = (now + CERTIFICATE_EXPIRING_WINDOW_MS + 10 * 60 * 1000) / 1000
|
||||
expect(certificateStatus({ exp }, now)).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verificationTypeLabel', () => {
|
||||
it('известный тип → человекочитаемое описание', () => {
|
||||
expect(verificationTypeLabel('coop_baseline')).toBe('Базовое подтверждение кооперативом')
|
||||
})
|
||||
it('неизвестный тип → возвращается как есть', () => {
|
||||
expect(verificationTypeLabel('future_kyc_x')).toBe('future_kyc_x')
|
||||
})
|
||||
})
|
||||
@@ -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,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,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as api from '../src/index'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/index'
|
||||
|
||||
const PUBLIC_API = [
|
||||
'login',
|
||||
'loginWithMagicLink',
|
||||
'recover',
|
||||
'getAccessToken',
|
||||
'getParticipantCertificate',
|
||||
'logout',
|
||||
'verifyOffline',
|
||||
'signDocument',
|
||||
'signTimestamp',
|
||||
'getWallet',
|
||||
'rotateKey',
|
||||
'exportToQR',
|
||||
] as const
|
||||
|
||||
// Методы, ещё не реализованные (бросают not_implemented). По мере реализации
|
||||
// историй метод уходит отсюда: getWallet — 2.2, signTimestamp — 2.4 (реализованы).
|
||||
const IMPLEMENTED = new Set(['getWallet', 'signTimestamp', 'getParticipantCertificate', 'logout', 'signDocument'])
|
||||
const STILL_STUBBED = PUBLIC_API.filter(m => !IMPLEMENTED.has(m))
|
||||
|
||||
describe('@coopenomics/auth — скелет SDK', () => {
|
||||
it('экспортирует всю публичную поверхность', () => {
|
||||
for (const method of PUBLIC_API)
|
||||
expect(api[method], method).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('stub\'ы отказывают типизированной AuthV2Error(not_implemented), а не молчат', async () => {
|
||||
for (const method of STILL_STUBBED) {
|
||||
const err = await (api[method] as () => Promise<unknown>)().then(
|
||||
() => null,
|
||||
e => e,
|
||||
)
|
||||
expect(err, method).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code, method).toBe(AuthV2ErrorCode.NotImplemented)
|
||||
}
|
||||
})
|
||||
|
||||
it('ошибка сериализуется в формат OAuth 2.0', () => {
|
||||
const err = new AuthV2Error(AuthV2ErrorCode.InvalidCredentials, 'Неверный email или пароль')
|
||||
expect(err.toJSON()).toEqual({
|
||||
error: 'invalid_credentials',
|
||||
error_description: 'Неверный email или пароль',
|
||||
})
|
||||
})
|
||||
|
||||
it('trust anchor и список нод доступны (placeholder до Stories 1.3/9.5)', () => {
|
||||
expect(api.TRUST_ANCHOR_ANO_CERT_JWK).toBeNull()
|
||||
expect(api.COOPOS_PUBLIC_NODES).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const lockWallet = vi.fn()
|
||||
const clearPinProtected = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
// keystore-затирание мокаем на уровне модуля wallet — logout обязан вызвать его
|
||||
// независимо от исхода сетевого запроса.
|
||||
vi.mock('../src/wallet', () => ({
|
||||
lockWallet,
|
||||
clearPinProtected,
|
||||
}))
|
||||
|
||||
const { logout } = await import('../src/oidc')
|
||||
|
||||
describe('logout (Story 1.10)', () => {
|
||||
beforeEach(() => {
|
||||
lockWallet.mockClear()
|
||||
clearPinProtected.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('шлёт POST /coop/logout с refresh/access и затирает keystore', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await logout({ apiUrl: 'https://coop.example/', refreshToken: 'r1', accessToken: 'a1' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, opts] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://coop.example/coop/logout')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ refresh_token: 'r1', access_token: 'a1' })
|
||||
expect(lockWallet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('затирает keystore ДАЖЕ при недоступности сервера (finally)', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(logout({ apiUrl: 'https://coop.example', refreshToken: 'r1' })).resolves.toBeUndefined()
|
||||
expect(lockWallet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('стирает PIN-protected ключ, если pinStorage передан', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const storage = { get: vi.fn(), set: vi.fn(), remove: vi.fn() }
|
||||
|
||||
await logout({ apiUrl: 'https://coop.example', refreshToken: 'r1', pinStorage: storage })
|
||||
|
||||
expect(clearPinProtected).toHaveBeenCalledWith(storage)
|
||||
})
|
||||
|
||||
it('не трогает PIN-хранилище, если pinStorage не передан', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await logout({ apiUrl: 'https://coop.example', refreshToken: 'r1' })
|
||||
|
||||
expect(clearPinProtected).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { PrivateKey, PublicKey, Signature } from '@wharfkit/antelope'
|
||||
import { base64url, SignJWT } from 'jose'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { canonicalTimestampMessage, signDocument, signTimestamp } from '../src/signing'
|
||||
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const ACCOUNT = 'ant'
|
||||
const PUB = PrivateKey.from(KEY).toPublic().toString()
|
||||
|
||||
/** Поддельный, но корректно сформированный session_binding_token (signTimestamp его только декодирует). */
|
||||
async function makeToken(sub = ACCOUNT, jti = 'jti-abc-123'): Promise<string> {
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(sub)
|
||||
.setJti(jti)
|
||||
.setExpirationTime('2m')
|
||||
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: PUB, privateKey: KEY })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wipeKeystore()
|
||||
})
|
||||
|
||||
describe('signTimestamp: COOPOS-native recoverable подпись', () => {
|
||||
it('подписывает {ts,jti,sub}; recoverMessage канонического сообщения даёт тот же pubkey', async () => {
|
||||
const token = await makeToken(ACCOUNT, 'jti-abc-123')
|
||||
const res = await signTimestamp({ sessionBindingToken: token })
|
||||
|
||||
expect(res.sub).toBe(ACCOUNT)
|
||||
expect(res.binding_token_jti).toBe('jti-abc-123')
|
||||
expect(res.public_key).toBe(PUB)
|
||||
expect(res.signature).toMatch(/^SIG_K1_/)
|
||||
|
||||
const msg = new TextEncoder().encode(canonicalTimestampMessage({
|
||||
ts: res.ts,
|
||||
binding_token_jti: res.binding_token_jti,
|
||||
sub: res.sub,
|
||||
}))
|
||||
const recovered = Signature.from(res.signature).recoverMessage(msg).toString()
|
||||
expect(recovered).toBe(PUB)
|
||||
})
|
||||
|
||||
it('подмена сообщения ломает восстановление pubkey (integrity)', async () => {
|
||||
const res = await signTimestamp({ sessionBindingToken: await makeToken() })
|
||||
const tampered = new TextEncoder().encode(canonicalTimestampMessage({
|
||||
ts: res.ts,
|
||||
binding_token_jti: res.binding_token_jti,
|
||||
sub: 'someone-else',
|
||||
}))
|
||||
const recovered = Signature.from(res.signature).recoverMessage(tampered).toString()
|
||||
expect(recovered).not.toBe(PUB)
|
||||
})
|
||||
|
||||
it('запертый кошелёк → WalletLocked', async () => {
|
||||
wipeKeystore()
|
||||
const err = await signTimestamp({ sessionBindingToken: await makeToken() }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
|
||||
it('кошелёк не совпадает с субъектом токена → ClientWalletMismatch', async () => {
|
||||
const err = await signTimestamp({ sessionBindingToken: await makeToken('petrov') }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.ClientWalletMismatch)
|
||||
})
|
||||
|
||||
it('битый токен → SessionBindingExpired', async () => {
|
||||
const err = await signTimestamp({ sessionBindingToken: 'not-a-jwt' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
|
||||
})
|
||||
|
||||
it('токен без sub/jti → SessionBindingExpired', async () => {
|
||||
const noClaims = await new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(new TextEncoder().encode('test-secret-padding-0000000000000000'))
|
||||
const err = await signTimestamp({ sessionBindingToken: noClaims }).then(() => null, e => e)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.SessionBindingExpired)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canonicalTimestampMessage: детерминизм', () => {
|
||||
it('фиксированный алфавитный порядок ключей', () => {
|
||||
expect(canonicalTimestampMessage({ ts: 't', binding_token_jti: 'j', sub: 's' }))
|
||||
.toBe('{"binding_token_jti":"j","sub":"s","ts":"t"}')
|
||||
})
|
||||
})
|
||||
|
||||
/** Верифицирует compact JWS ES256K: разбирает подпись R||S и проверяет её ключом. */
|
||||
function verifyJws(jws: string, pub: string): boolean {
|
||||
const [h, p, s] = jws.split('.')
|
||||
const rs = base64url.decode(s)
|
||||
const sig = Signature.from({ type: 'K1', r: rs.slice(0, 32), s: rs.slice(32, 64), recid: 0 })
|
||||
return sig.verifyMessage(new TextEncoder().encode(`${h}.${p}`), PublicKey.from(pub))
|
||||
}
|
||||
|
||||
describe('signDocument: локальная подпись compact JWS (Story 2.3)', () => {
|
||||
it('возвращает JWS <header>.<payload>.<signature>; header alg/kid, payload round-trip, подпись валидна', async () => {
|
||||
const doc = new TextEncoder().encode('Протокол собрания №1')
|
||||
const jws = await signDocument({ payload: doc, alg: 'ES256K' })
|
||||
|
||||
const parts = jws.split('.')
|
||||
expect(parts).toHaveLength(3)
|
||||
|
||||
const header = JSON.parse(new TextDecoder().decode(base64url.decode(parts[0])))
|
||||
expect(header).toEqual({ alg: 'ES256K', kid: ACCOUNT })
|
||||
|
||||
// payload декодируется обратно в исходные байты
|
||||
expect(base64url.decode(parts[1])).toEqual(doc)
|
||||
|
||||
// подпись — ровно 64 байта (R||S, без recovery) и валидна для pubkey подписанта
|
||||
expect(base64url.decode(parts[2]).length).toBe(64)
|
||||
expect(verifyJws(jws, PUB)).toBe(true)
|
||||
})
|
||||
|
||||
it('строковый payload эквивалентен его UTF-8 байтам', async () => {
|
||||
const text = 'привет, кооператив'
|
||||
const fromString = await signDocument({ payload: text })
|
||||
const fromBytes = await signDocument({ payload: new TextEncoder().encode(text) })
|
||||
// RFC6979-детерминизм K1: одинаковый вход → одинаковый JWS
|
||||
expect(fromString).toBe(fromBytes)
|
||||
expect(verifyJws(fromString, PUB)).toBe(true)
|
||||
})
|
||||
|
||||
it('integrity: подделка payload в JWS ломает проверку подписи', async () => {
|
||||
const jws = await signDocument({ payload: 'исходный документ' })
|
||||
const [h, , s] = jws.split('.')
|
||||
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
|
||||
expect(verifyJws(tampered, PUB)).toBe(false)
|
||||
})
|
||||
|
||||
it('alg по умолчанию — ES256K', async () => {
|
||||
const jws = await signDocument({ payload: 'x' })
|
||||
const header = JSON.parse(new TextDecoder().decode(base64url.decode(jws.split('.')[0])))
|
||||
expect(header.alg).toBe('ES256K')
|
||||
})
|
||||
|
||||
it('неподдерживаемый alg → AuthV2Error', async () => {
|
||||
const err = await signDocument({ payload: 'x', alg: 'RS256' as 'ES256K' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
})
|
||||
|
||||
it('запертый кошелёк → WalletLocked', async () => {
|
||||
wipeKeystore()
|
||||
const err = await signDocument({ payload: 'x' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { decryptPrivateKey, encryptPrivateKey } from '../src/vault'
|
||||
import type { VaultSubject } from '../src/vault'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const SUBJECT: VaultSubject = { subject_type: 'participant', subject_id: 'ant' }
|
||||
|
||||
describe('vault: Argon2id + AES-256-GCM', () => {
|
||||
it('round-trip: encrypt → decrypt тем же паролем даёт исходный ключ', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
expect(blob.cipher_version).toBe('aes-256-gcm-v1')
|
||||
expect(blob.kdf_version).toBe('argon2id-v1')
|
||||
const restored = await decryptPrivateKey(blob, PW, SUBJECT)
|
||||
expect(restored).toBe(KEY)
|
||||
})
|
||||
|
||||
it('каждое шифрование — новые salt и nonce', async () => {
|
||||
const a = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const b = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
expect(a.salt).not.toBe(b.salt)
|
||||
expect(a.nonce).not.toBe(b.nonce)
|
||||
expect(a.ciphertext).not.toBe(b.ciphertext)
|
||||
})
|
||||
|
||||
it('неверный пароль → VaultDecryptionFailed', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const err = await decryptPrivateKey(blob, 'wrong-password-000000000000', SUBJECT).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
})
|
||||
|
||||
it('подмена subject_id (AAD) ломает расшифровку', async () => {
|
||||
const blob = await encryptPrivateKey(KEY, PW, SUBJECT)
|
||||
const err = await decryptPrivateKey(blob, PW, { subject_type: 'participant', subject_id: 'petrov' }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
})
|
||||
}, 30000)
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PrivateKey } from '@wharfkit/antelope'
|
||||
import { base64url } from 'jose'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { type KeyValidityWindow, verifyDocumentOffline } from '../src/index'
|
||||
import { signDocument } from '../src/signing'
|
||||
import { storeUnlocked, wipeKeystore } from '../src/wallet/storage'
|
||||
|
||||
const KEY1 = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PUB1 = PrivateKey.from(KEY1).toPublic().toString()
|
||||
const ACCOUNT = 'ant'
|
||||
|
||||
// Второй ключ — результат ротации (Story 3.3). Генерим, чтобы не хардкодить.
|
||||
const key2 = PrivateKey.generate('K1')
|
||||
const KEY2 = key2.toString()
|
||||
const PUB2 = key2.toPublic().toString()
|
||||
|
||||
// key1 активен в блоках [100,200], затем ротация на key2 [201, сейчас].
|
||||
const authority: KeyValidityWindow[] = [
|
||||
{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 },
|
||||
{ public_key: PUB2, valid_from_block: 201, valid_to_block: null },
|
||||
]
|
||||
|
||||
async function signWith(wif: string, payload: string): Promise<string> {
|
||||
storeUnlocked({ account: ACCOUNT, publicKey: PrivateKey.from(wif).toPublic().toString(), privateKey: wif })
|
||||
const jws = await signDocument({ payload })
|
||||
wipeKeystore()
|
||||
return jws
|
||||
}
|
||||
|
||||
afterEach(() => wipeKeystore())
|
||||
|
||||
describe('verifyDocumentOffline: backward-валидность (Story 2.5)', () => {
|
||||
it('подпись старым ключом валидна, если ключ был активен на момент подписи (хотя сейчас ротирован)', async () => {
|
||||
const jws = await signWith(KEY1, 'протокол собрания №7')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.matched_key).toBe(PUB1)
|
||||
})
|
||||
|
||||
it('та же подпись на блоке ПОСЛЕ ротации → невалидна (key_not_active_at_signing)', async () => {
|
||||
const jws = await signWith(KEY1, 'протокол собрания №7')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 250, authority })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('key_not_active_at_signing')
|
||||
})
|
||||
|
||||
it('подпись текущим ключом в его окне → валидна', async () => {
|
||||
const jws = await signWith(KEY2, 'протокол собрания №8')
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 300, authority })
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.matched_key).toBe(PUB2)
|
||||
})
|
||||
|
||||
it('подделка payload → signature_mismatch', async () => {
|
||||
const jws = await signWith(KEY1, 'оригинальный документ')
|
||||
const [h, , s] = jws.split('.')
|
||||
const tampered = `${h}.${base64url.encode(new TextEncoder().encode('подменённый документ'))}.${s}`
|
||||
const res = await verifyDocumentOffline({ jws: tampered, signedAtBlock: 150, authority })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('signature_mismatch')
|
||||
})
|
||||
|
||||
it('чужой ключ (нет в хронологии подписанта) → signature_mismatch', async () => {
|
||||
const jws = await signWith(KEY2, 'документ')
|
||||
const onlyKey1: KeyValidityWindow[] = [{ public_key: PUB1, valid_from_block: 100, valid_to_block: 200 }]
|
||||
const res = await verifyDocumentOffline({ jws, signedAtBlock: 150, authority: onlyKey1 })
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.reason).toBe('signature_mismatch')
|
||||
})
|
||||
|
||||
it('некорректный JWS → malformed_jws', async () => {
|
||||
expect((await verifyDocumentOffline({ jws: 'a.b', signedAtBlock: 1, authority })).reason).toBe('malformed_jws')
|
||||
})
|
||||
|
||||
it('неподдерживаемый alg → unsupported_alg', async () => {
|
||||
const h = base64url.encode(new TextEncoder().encode(JSON.stringify({ alg: 'RS256' })))
|
||||
const p = base64url.encode(new TextEncoder().encode('x'))
|
||||
const sig = base64url.encode(new Uint8Array(64))
|
||||
const res = await verifyDocumentOffline({ jws: `${h}.${p}.${sig}`, signedAtBlock: 1, authority })
|
||||
expect(res.reason).toBe('unsupported_alg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '../src/errors'
|
||||
import { encryptPrivateKey } from '../src/vault'
|
||||
import {
|
||||
clearPinProtected,
|
||||
getWallet,
|
||||
isWalletUnlocked,
|
||||
lockWallet,
|
||||
type StorageAdapter,
|
||||
unlockWallet,
|
||||
unlockWithPin,
|
||||
Wallet,
|
||||
} from '../src/wallet'
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
const PW = 'correct-horse-battery-staple-12'
|
||||
const ACCOUNT = 'ant'
|
||||
const API = 'http://stub'
|
||||
|
||||
/** Память-адаптер для тестов PIN-хранилища (в браузере — localStorage). */
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
get: async k => map.get(k) ?? null,
|
||||
set: async (k, v) => void map.set(k, v),
|
||||
remove: async k => void map.delete(k),
|
||||
}
|
||||
}
|
||||
|
||||
/** Подменяет fetch так, чтобы GET vault отдавал заранее зашифрованный blob. */
|
||||
async function stubVaultFetch(password = PW): Promise<void> {
|
||||
const blob = await encryptPrivateKey(KEY, password, { subject_type: 'participant', subject_id: ACCOUNT })
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => blob }) as any))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
lockWallet()
|
||||
})
|
||||
|
||||
describe('wallet: unlock + in-memory keystore + getWallet', () => {
|
||||
it('unlock → getWallet отдаёт account и деривованный publicKey', async () => {
|
||||
await stubVaultFetch()
|
||||
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
expect(wallet.account).toBe(ACCOUNT)
|
||||
expect(wallet.publicKey).toMatch(/^PUB_K1_/)
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
|
||||
const again = await getWallet()
|
||||
expect(again.publicKey).toBe(wallet.publicKey)
|
||||
}, 30000)
|
||||
|
||||
it('неверный пароль → VaultDecryptionFailed, keystore остаётся пуст', async () => {
|
||||
await stubVaultFetch(PW)
|
||||
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: 'wrong-password-000000000000' })
|
||||
.then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
}, 30000)
|
||||
|
||||
it('getWallet при запертом кошельке → WalletLocked', async () => {
|
||||
const err = await getWallet().then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
})
|
||||
|
||||
it('сеть недоступна → NetworkError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) }) as any))
|
||||
const err = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW }).then(() => null, e => e)
|
||||
expect(err).toBeInstanceOf(AuthV2Error)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.NetworkError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wallet: несериализуемость приватного ключа', () => {
|
||||
it('JSON.stringify(wallet) не раскрывает приватный ключ', async () => {
|
||||
await stubVaultFetch()
|
||||
const wallet = await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
const serialized = JSON.stringify(wallet)
|
||||
expect(serialized).not.toContain(KEY)
|
||||
expect(JSON.parse(serialized)).toEqual({ account: ACCOUNT, publicKey: wallet.publicKey })
|
||||
// приватного ключа нет ни в одном перечислимом свойстве обёртки
|
||||
expect(Object.values(wallet as unknown as Record<string, unknown>)).not.toContain(KEY)
|
||||
expect(wallet).toBeInstanceOf(Wallet)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
describe('wallet: lock/wipe', () => {
|
||||
it('lockWallet затирает keystore → getWallet бросает; идемпотентно', async () => {
|
||||
await stubVaultFetch()
|
||||
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW })
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
lockWallet()
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
lockWallet() // повторно — без ошибки
|
||||
const err = await getWallet().then(() => null, e => e)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.WalletLocked)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
describe('wallet: опциональный PIN-storage', () => {
|
||||
it('unlock с persistPin → unlockWithPin восстанавливает кошелёк', async () => {
|
||||
await stubVaultFetch()
|
||||
const storage = memoryStorage()
|
||||
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW, persistPin: { pin: '4729', storage } })
|
||||
lockWallet()
|
||||
expect(isWalletUnlocked()).toBe(false)
|
||||
|
||||
const restored = await unlockWithPin('4729', storage)
|
||||
expect(restored).not.toBeNull()
|
||||
expect(restored!.account).toBe(ACCOUNT)
|
||||
expect(isWalletUnlocked()).toBe(true)
|
||||
}, 30000)
|
||||
|
||||
it('неверный PIN → VaultDecryptionFailed', async () => {
|
||||
await stubVaultFetch()
|
||||
const storage = memoryStorage()
|
||||
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW, persistPin: { pin: '4729', storage } })
|
||||
const err = await unlockWithPin('0000', storage).then(() => null, e => e)
|
||||
expect((err as AuthV2Error).code).toBe(AuthV2ErrorCode.VaultDecryptionFailed)
|
||||
}, 30000)
|
||||
|
||||
it('нет PIN-записи → unlockWithPin отдаёт null', async () => {
|
||||
const storage = memoryStorage()
|
||||
expect(await unlockWithPin('4729', storage)).toBeNull()
|
||||
})
|
||||
|
||||
it('clearPinProtected удаляет запись', async () => {
|
||||
await stubVaultFetch()
|
||||
const storage = memoryStorage()
|
||||
await unlockWallet({ apiUrl: API, account: ACCOUNT, password: PW, persistPin: { pin: '4729', storage } })
|
||||
await clearPinProtected(storage)
|
||||
expect(await unlockWithPin('4729', storage)).toBeNull()
|
||||
}, 30000)
|
||||
})
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -5,8 +5,13 @@
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
@@ -28,5 +33,47 @@
|
||||
"consistent-return": "off",
|
||||
"jest/expect-expect": "off",
|
||||
"security/detect-object-injection": "off"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"src/application/**/*.ts",
|
||||
"src/domain/**/*.ts"
|
||||
],
|
||||
"excludedFiles": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/application/agreement/use-cases/agreement.interactor.ts",
|
||||
"src/application/decision/use-cases/decision.interactor.ts",
|
||||
"src/application/cooplace/interactors/cooplace.interactor.ts",
|
||||
"src/application/participant/interactors/participant.interactor.ts",
|
||||
"src/application/system/interactors/load-contacts.interactor.ts",
|
||||
"src/application/system/dto/blockchain-info.dto.ts",
|
||||
"src/application/system/interactors/wif.interactor.ts",
|
||||
"src/domain/process-registry/services/process-registry.service.ts",
|
||||
"src/domain/branch/interfaces/branch-blockchain.port.ts",
|
||||
"src/domain/common/ports/soviet-blockchain.port.ts",
|
||||
"src/domain/common/ports/blockchain.port.ts",
|
||||
"src/domain/blockchain/types/transaction-result.type.ts",
|
||||
"src/domain/cooplace/interfaces/cooplace-blockchain.port.ts",
|
||||
"src/domain/auth/services/auth-domain.service.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"@wharfkit/*",
|
||||
"oidc-client-ts",
|
||||
"ioredis"
|
||||
],
|
||||
"message": "Гексагональный инвариант auth-v2: application/ и domain/ не импортируют wharfkit/oidc-client-ts/ioredis напрямую — только через порты (infrastructure-адаптеры). excludedFiles — легаси-долг до Эпика 7."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 1.4: начальные схемы coop_domain_db (отдельная БД в coop-postgres,
|
||||
* Story 1.1). Миграция открывает СОБСТВЕННОЕ подключение из config.coopDomainDb —
|
||||
* главный dataSource (voskhod) не используется и не передаётся дальше.
|
||||
*
|
||||
* Таблицы:
|
||||
* - vaults — зашифрованные блобы (subject_type-discriminated);
|
||||
* - audit_events — append-only журнал: месячные партиции + DEFAULT,
|
||||
* триггер запрещает UPDATE/DELETE даже владельцу;
|
||||
* - chain_manifests_cache — TTL-aware кеш authority из COOPOS;
|
||||
* - legacy_user_mapping — UUID ↔ Mongo ObjectID на миграционный период;
|
||||
* - access_rules — декларативная матрица CASL Layer 2 (семантика — Story 6.2).
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: init coop_domain_db (vaults, audit_events, chain cache, mapping, access_rules)',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
|
||||
try {
|
||||
await db.initialize();
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS vaults (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject_type text NOT NULL CHECK (subject_type IN ('participant', 'coop', 'council_action')),
|
||||
subject_id text NOT NULL,
|
||||
cipher_version smallint NOT NULL DEFAULT 1,
|
||||
kdf_version smallint NOT NULL DEFAULT 1,
|
||||
salt bytea NOT NULL,
|
||||
nonce bytea NOT NULL,
|
||||
ciphertext bytea NOT NULL,
|
||||
auth_tag bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT vaults_subject_uq UNIQUE (subject_type, subject_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
event text NOT NULL,
|
||||
subject_id text,
|
||||
actor text,
|
||||
result text NOT NULL CHECK (result IN ('success', 'failure', 'degraded')),
|
||||
context jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ip inet,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (id, created_at)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
`);
|
||||
|
||||
// Партиции: текущий месяц, следующий и DEFAULT-страховка (создание
|
||||
// последующих помесячных — забота operational runbook, Stories 9.8/9.10).
|
||||
await db.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
m0 date := date_trunc('month', now())::date;
|
||||
m1 date := (date_trunc('month', now()) + interval '1 month')::date;
|
||||
m2 date := (date_trunc('month', now()) + interval '2 month')::date;
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
|
||||
to_char(m0, 'YYYY_MM'), m0, m1);
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS audit_events_%s PARTITION OF audit_events FOR VALUES FROM (%L) TO (%L)',
|
||||
to_char(m1, 'YYYY_MM'), m1, m2);
|
||||
EXECUTE 'CREATE TABLE IF NOT EXISTS audit_events_default PARTITION OF audit_events DEFAULT';
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Append-only: триггер держит инвариант даже для владельца таблицы
|
||||
// (GRANT/REVOKE ниже — декларация, заработает буквально при отдельной
|
||||
// migration-роли из prod-плейбука).
|
||||
await db.query(`
|
||||
CREATE OR REPLACE FUNCTION audit_events_append_only() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_events is append-only: % is forbidden', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`);
|
||||
await db.query(`
|
||||
DROP TRIGGER IF EXISTS audit_events_no_update_delete ON audit_events;
|
||||
CREATE TRIGGER audit_events_no_update_delete
|
||||
BEFORE UPDATE OR DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();
|
||||
`);
|
||||
// ROW-триггер не ловит TRUNCATE — закрываем statement-триггером.
|
||||
await db.query(`
|
||||
DROP TRIGGER IF EXISTS audit_events_no_truncate ON audit_events;
|
||||
CREATE TRIGGER audit_events_no_truncate
|
||||
BEFORE TRUNCATE ON audit_events
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION audit_events_append_only();
|
||||
`);
|
||||
// username — идентификатор в DDL, биндинг невозможен: валидируем строго.
|
||||
const role = config.coopDomainDb.username;
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(role))
|
||||
throw new Error(`Недопустимое имя роли COOP_DOMAIN_DB_USERNAME: ${role}`);
|
||||
await db.query(`
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ${role};
|
||||
GRANT INSERT, SELECT ON audit_events TO ${role};
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS chain_manifests_cache (
|
||||
account text PRIMARY KEY,
|
||||
manifest jsonb NOT NULL,
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS legacy_user_mapping (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mongo_object_id text NOT NULL UNIQUE,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_version text NOT NULL DEFAULT 'legacy' CHECK (auth_version IN ('legacy', 'oidc', 'dual')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS access_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
role text NOT NULL,
|
||||
action text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT access_rules_uq UNIQUE (role, action, subject)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events (subject_id, created_at);`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_events_event ON audit_events (event, created_at);`);
|
||||
|
||||
logger.info('coop_domain_db: vaults, audit_events(+партиции, append-only), chain_manifests_cache, legacy_user_mapping, access_rules — готовы');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`coop_domain_db init failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.0 не реализован намеренно: audit_events append-only, удаление схем — только вручную.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../src/config/config';
|
||||
|
||||
type MigrationLogger = { info: (message: string) => void; error: (message: string) => void; warn: (message: string) => void };
|
||||
|
||||
/**
|
||||
* CoopID Story 2.1 (фикс схемы 1.4): cipher_version/kdf_version в `vaults` —
|
||||
* текстовые версии ('aes-256-gcm-v1' / 'argon2id-v1'), а не smallint.
|
||||
* V2.4.0 объявила их smallint по недосмотру; правим отдельной миграцией,
|
||||
* т.к. применённые миграции иммутабельны. Таблица на этом этапе пуста.
|
||||
*/
|
||||
export default {
|
||||
name: 'coopid: vaults.cipher_version/kdf_version → text',
|
||||
|
||||
async up({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
});
|
||||
try {
|
||||
await db.initialize();
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN cipher_version TYPE text USING cipher_version::text`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN kdf_version TYPE text USING kdf_version::text`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN cipher_version SET DEFAULT 'aes-256-gcm-v1'`);
|
||||
await db.query(`ALTER TABLE vaults ALTER COLUMN kdf_version SET DEFAULT 'argon2id-v1'`);
|
||||
logger.info('vaults: cipher_version/kdf_version → text');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`vault version columns alter failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (db.isInitialized) await db.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn('Откат V2.4.1 не реализован.');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -144,6 +144,7 @@
|
||||
"joi": "^17.13.1",
|
||||
"joi-to-json": "^4.3.0",
|
||||
"joi-to-typescript": "^4.13.0",
|
||||
"jose": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jspdf": "^4.2.0",
|
||||
"libxmljs2": "^0.37.0",
|
||||
|
||||
@@ -29,6 +29,7 @@ import { FreeDecisionDomainModule } from './domain/free-decision/free-decision.m
|
||||
import { AgreementDomainModule } from './domain/agreement/agreement-domain.module';
|
||||
import { ParticipantDomainModule } from './domain/participant/participant-domain.module';
|
||||
import { AuthDomainModule } from './domain/auth/auth.module';
|
||||
import { AuthV2Module } from './application/auth-v2/auth-v2.module';
|
||||
import { AgendaDomainModule } from './domain/agenda/agenda-domain.module';
|
||||
import { CooplaceDomainModule } from './domain/cooplace/cooplace.module';
|
||||
import { DesktopDomainModule } from './domain/desktop/desktop-domain.module';
|
||||
@@ -113,6 +114,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
}),
|
||||
// Domain modules
|
||||
AuthDomainModule,
|
||||
AuthV2Module,
|
||||
RegistrationDomainModule,
|
||||
OnboardingDomainModule,
|
||||
AgendaDomainModule,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '~/config/config';
|
||||
|
||||
export type AuditResult = 'success' | 'failure' | 'degraded';
|
||||
|
||||
export interface AuditRecord {
|
||||
event: string;
|
||||
subjectId?: string | null;
|
||||
actor?: string | null;
|
||||
result: AuditResult;
|
||||
context?: Record<string, unknown>;
|
||||
ip?: string | null;
|
||||
}
|
||||
|
||||
/** Ключи, которым нечего делать в audit-контексте ни на каком уровне вложенности. */
|
||||
const SECRET_KEY_PATTERNS = ['password', 'private_key', 'token', 'secret', 'signature'];
|
||||
|
||||
/**
|
||||
* Blacklist-инвариант аудита (архитектура CoopID): попытка записать секрет
|
||||
* в context — программная ошибка, бросаем сразу, а не маскируем.
|
||||
*/
|
||||
export function assertContextHasNoSecrets(context: Record<string, unknown>, path = ''): void {
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (SECRET_KEY_PATTERNS.some((p) => lower.includes(p)))
|
||||
throw new Error(`AuditService: ключ «${path}${key}» похож на секрет — запись в audit_events запрещена`);
|
||||
if (value && typeof value === 'object' && !Array.isArray(value))
|
||||
assertContextHasNoSecrets(value as Record<string, unknown>, `${path}${key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись в append-only audit_events (coop_domain_db). Подключение ленивое:
|
||||
* coop-postgres недоступен → ошибка только у вызывающего, запуск coopback
|
||||
* не зависит от этого сервиса.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
private ds: DataSource | null = null;
|
||||
private initializing: Promise<DataSource> | null = null;
|
||||
|
||||
private getDataSource(): Promise<DataSource> {
|
||||
if (this.ds?.isInitialized) return Promise.resolve(this.ds);
|
||||
if (!this.initializing) {
|
||||
this.initializing = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
})
|
||||
.initialize()
|
||||
.then((ds) => {
|
||||
this.ds = ds;
|
||||
return ds;
|
||||
})
|
||||
.finally(() => {
|
||||
this.initializing = null;
|
||||
});
|
||||
}
|
||||
return this.initializing;
|
||||
}
|
||||
|
||||
async record(record: AuditRecord): Promise<void> {
|
||||
const context = record.context ?? {};
|
||||
assertContextHasNoSecrets(context);
|
||||
const ds = await this.getDataSource();
|
||||
await ds.query(
|
||||
`INSERT INTO audit_events (event, subject_id, actor, result, context, ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[record.event, record.subjectId ?? null, record.actor ?? null, record.result, JSON.stringify(context), record.ip ?? null],
|
||||
);
|
||||
this.logger.log(`audit: ${record.event} subject=${record.subjectId ?? '-'} result=${record.result}`);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.ds?.isInitialized) await this.ds.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
import { AuthV2InfrastructureModule } from '~/infrastructure/auth-v2/auth-v2-infrastructure.module';
|
||||
import { TokenApplicationModule } from '~/application/token/token-application.module';
|
||||
import { AuditService } from './audit/audit.service';
|
||||
import { AuthentikEventsController } from './authentik-events.controller';
|
||||
import { SessionBindingService } from './session-binding/session-binding.service';
|
||||
import { SessionBindingController } from './session-binding/session-binding.controller';
|
||||
import { VaultService } from './vault/vault.service';
|
||||
import { VaultController } from './vault/vault.controller';
|
||||
import { VerifyTimestampService } from './verify-timestamp/verify-timestamp.service';
|
||||
import { VerifyTimestampController } from './verify-timestamp/verify-timestamp.controller';
|
||||
import { CertificateService } from './certificate/certificate.service';
|
||||
import { CertificateController } from './certificate/certificate.controller';
|
||||
import { LogoutService } from './logout/logout.service';
|
||||
import { LogoutController } from './logout/logout.controller';
|
||||
import { AuthRateLimitGuard } from './rate-limit/auth-rate-limit.guard';
|
||||
|
||||
/**
|
||||
* auth-v2 (CoopID): новый контур аутентификации. Живёт рядом с legacy `auth/`
|
||||
* до Phase 3 миграции (Эпик 7). Здесь: аудит, приёмник событий authentik,
|
||||
* session_binding_token-мост, vault-хранилище и второй этап (timestamp-verify);
|
||||
* certificate/id_token — Story 1.8. BLOCKCHAIN_PORT/USER_DOMAIN_SERVICE — @Global.
|
||||
*/
|
||||
@Module({
|
||||
imports: [RedisModule, AuthV2InfrastructureModule, TokenApplicationModule],
|
||||
controllers: [AuthentikEventsController, SessionBindingController, VaultController, VerifyTimestampController, CertificateController, LogoutController],
|
||||
providers: [AuditService, SessionBindingService, VaultService, VerifyTimestampService, CertificateService, LogoutService, AuthRateLimitGuard],
|
||||
exports: [AuditService, SessionBindingService, VaultService, VerifyTimestampService, CertificateService, LogoutService],
|
||||
})
|
||||
export class AuthV2Module {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Logger,
|
||||
Post,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import config from '~/config/config';
|
||||
import { AuditService, AuditRecord } from './audit/audit.service';
|
||||
|
||||
/** Payload, который формирует webhook-mapping coopid-webhook-body в authentik. */
|
||||
export interface AuthentikWebhookBody {
|
||||
severity?: string;
|
||||
event_action?: string;
|
||||
event_user?: string | null;
|
||||
passing?: boolean | null;
|
||||
messages?: string[] | null;
|
||||
created?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг webhook-события authentik в audit-запись. Чистая функция (юнит-тесты).
|
||||
* execution_logging включён ТОЛЬКО у парольной политики CoopID (см. blueprint),
|
||||
* поэтому policy_execution + passing=false ⇒ отказ по слабому паролю.
|
||||
*/
|
||||
export function mapAuthentikEvent(body: AuthentikWebhookBody): AuditRecord | null {
|
||||
if (body?.event_action !== 'policy_execution' || body?.passing !== false) return null;
|
||||
return {
|
||||
event: 'WeakPasswordRejected',
|
||||
subjectId: body.event_user ?? null,
|
||||
actor: body.event_user ?? null,
|
||||
result: 'failure',
|
||||
context: {
|
||||
messages: body.messages ?? [],
|
||||
authentik_created: body.created ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function tokenMatches(provided: string | undefined, expected: string): boolean {
|
||||
if (!provided || !expected) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal-приёмник событий authentik (Story 1.5). Не маршрутизируется caddy —
|
||||
* достижим только из docker-сети; дополнительно защищён shared-токеном.
|
||||
*/
|
||||
@Controller('coop/internal')
|
||||
export class AuthentikEventsController {
|
||||
private readonly logger = new Logger(AuthentikEventsController.name);
|
||||
|
||||
constructor(private readonly audit: AuditService) {}
|
||||
|
||||
@Post('authentik-events')
|
||||
@HttpCode(204)
|
||||
async handle(
|
||||
@Headers('x-authentik-webhook-token') token: string | undefined,
|
||||
@Body() body: AuthentikWebhookBody,
|
||||
): Promise<void> {
|
||||
if (!tokenMatches(token, config.authV2.webhookToken)) throw new UnauthorizedException();
|
||||
|
||||
const record = mapAuthentikEvent(body);
|
||||
if (!record) return; // не наш кейс — молча принимаем, authentik не должен ретраить
|
||||
|
||||
try {
|
||||
await this.audit.record(record);
|
||||
} catch (e) {
|
||||
this.logger.error(`Не удалось записать audit ${record.event}: ${e instanceof Error ? e.message : e}`);
|
||||
throw new ServiceUnavailableException('audit storage unavailable');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { HttpJwtAuthGuard } from '~/application/auth/guards/http-jwt-auth.guard';
|
||||
import { AuthV2ExceptionFilter } from '../exceptions/auth-v2-exception.filter';
|
||||
import { CertificateService } from './certificate.service';
|
||||
|
||||
interface AuthedRequest extends Request {
|
||||
user?: { id: string; username: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Выдача participant_certificate (CoopID, Story 1.8). Доступен после успешного
|
||||
* логина: защищён платформенным access-token (HttpJwtAuthGuard → req.user).
|
||||
* Сертификат выпускается для текущего пайщика (req.user.username).
|
||||
*/
|
||||
@Controller('coop/certificate')
|
||||
@UseFilters(AuthV2ExceptionFilter)
|
||||
export class CertificateController {
|
||||
constructor(private readonly certificateService: CertificateService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(HttpJwtAuthGuard)
|
||||
async getCertificate(@Req() req: AuthedRequest): Promise<{ participant_certificate: string }> {
|
||||
const username = req.user?.username;
|
||||
if (!username) throw new UnauthorizedException();
|
||||
// AuthV2Error пробрасывается контурному AuthV2ExceptionFilter (Story 1.11).
|
||||
const participant_certificate = await this.certificateService.issueForUsername(username);
|
||||
return { participant_certificate };
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
||||
import { decodeProtectedHeader, jwtVerify } from 'jose';
|
||||
import config from '~/config/config';
|
||||
import { AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
import { UserCertificateDomainService } from '~/domain/user/services/user-certificate-domain.service';
|
||||
import { CertificateService } from './certificate.service';
|
||||
|
||||
const COOPNAME = config.coopname;
|
||||
const VOSTOK_KEY = 'PUB_K1_vostokcertkey00000000000000000000000000000000000';
|
||||
const CHAIN: Record<string, string> = {
|
||||
ano: 'PUB_K1_anocertkey0000000000000000000000000000000000000000',
|
||||
voskhod: 'PUB_K1_voskhodcertkey0000000000000000000000000000000000',
|
||||
vostok: VOSTOK_KEY,
|
||||
};
|
||||
|
||||
let pubKey: KeyObject;
|
||||
let pemPriv: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const kp = generateKeyPairSync('ec', { namedCurve: 'secp256k1' });
|
||||
pemPriv = kp.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
|
||||
pubKey = kp.publicKey;
|
||||
});
|
||||
|
||||
function makeService(opts: {
|
||||
certKey?: string;
|
||||
identification?: any;
|
||||
certKeyFor?: (acc: string) => string | null;
|
||||
} = {}) {
|
||||
jest.spyOn(config.authV2, 'certKey', 'get').mockReturnValue(opts.certKey ?? pemPriv);
|
||||
const blockchain = {
|
||||
getCertPublicKey: jest.fn(async (acc: string) => (opts.certKeyFor ? opts.certKeyFor(acc) : CHAIN[acc])),
|
||||
};
|
||||
const user = { getUserByUsername: jest.fn().mockResolvedValue({ id: 'uuid-participant-1', username: 'ant' }) };
|
||||
const account = {
|
||||
getPrivateAccount: jest.fn().mockResolvedValue(
|
||||
'identification' in opts
|
||||
? opts.identification
|
||||
: { username: 'ant', first_name: 'Иван', last_name: 'Петров', middle_name: 'Сергеевич' },
|
||||
),
|
||||
};
|
||||
const certDomain = new UserCertificateDomainService();
|
||||
const service = new CertificateService(blockchain as any, user as any, account as any, certDomain as any);
|
||||
return { service, blockchain, user, account };
|
||||
}
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('CertificateService.issueForUsername', () => {
|
||||
it('выпускает compact JWS ES256K, верифицируемый ключом подписи, со всеми обязательными claims', async () => {
|
||||
const { service } = makeService();
|
||||
const jws = await service.issueForUsername('ant');
|
||||
|
||||
expect(jws.split('.')).toHaveLength(3);
|
||||
|
||||
const header = decodeProtectedHeader(jws);
|
||||
expect(header.alg).toBe('ES256K');
|
||||
expect(header.kid).toBe(VOSTOK_KEY);
|
||||
|
||||
const { payload } = await jwtVerify(jws, pubKey, { algorithms: ['ES256K'] });
|
||||
expect(payload.iss).toBe(`https://${COOPNAME}.coop`);
|
||||
expect(payload.sub).toBe('uuid-participant-1');
|
||||
expect(payload.jti).toMatch(/^[0-9a-f-]{36}$/); // серийный номер (UUID)
|
||||
expect(payload.coopname).toBe(COOPNAME);
|
||||
expect(payload.iat).toBeDefined();
|
||||
expect(payload.exp).toBeGreaterThan(payload.iat as number);
|
||||
expect(payload.claim_schema_version).toBe('1');
|
||||
expect(payload.verification_types).toEqual(['coop_baseline']);
|
||||
expect(payload.coop_chain).toEqual([
|
||||
{ account: 'ano', public_key: CHAIN.ano },
|
||||
{ account: 'voskhod', public_key: CHAIN.voskhod },
|
||||
{ account: 'vostok', public_key: VOSTOK_KEY },
|
||||
]);
|
||||
expect(payload.identification).toMatchObject({ type: 'individual', username: 'ant', first_name: 'Иван' });
|
||||
});
|
||||
|
||||
it('размер JWS ≤ 5 КБ (Vision/MIFARE)', async () => {
|
||||
const { service } = makeService();
|
||||
const jws = await service.issueForUsername('ant');
|
||||
expect(Buffer.byteLength(jws, 'utf8')).toBeLessThanOrEqual(5 * 1024);
|
||||
});
|
||||
|
||||
it('identification = null при отсутствии приватных данных аккаунта', async () => {
|
||||
const { service } = makeService({ identification: null });
|
||||
const jws = await service.issueForUsername('ant');
|
||||
const { payload } = await jwtVerify(jws, pubKey, { algorithms: ['ES256K'] });
|
||||
expect(payload.identification).toBeNull();
|
||||
});
|
||||
|
||||
it('маппит organization identification из доменных данных', async () => {
|
||||
const org = {
|
||||
username: 'cooporg',
|
||||
short_name: 'ООО Ромашка',
|
||||
full_name: 'Общество с ограниченной ответственностью «Ромашка»',
|
||||
details: { inn: '7700000000', ogrn: '1027700000000' },
|
||||
represented_by: { first_name: 'Анна', last_name: 'Иванова', middle_name: 'П', position: 'Директор' },
|
||||
};
|
||||
const { service } = makeService({ identification: org });
|
||||
const { payload } = await jwtVerify(await service.issueForUsername('cooporg'), pubKey, { algorithms: ['ES256K'] });
|
||||
expect(payload.identification).toMatchObject({ type: 'organization', inn: '7700000000', ogrn: '1027700000000' });
|
||||
});
|
||||
|
||||
it('нет cert-ключа в конфиге → ошибка конфигурации', async () => {
|
||||
const { service } = makeService({ certKey: '' });
|
||||
await expect(service.issueForUsername('ant')).rejects.toThrow(/COOP_CERT_KEY/);
|
||||
});
|
||||
|
||||
it('разрыв cert-цепи (нет ключа звена) → ChainVerificationFailed', async () => {
|
||||
const { service } = makeService({ certKeyFor: (acc) => (acc === 'voskhod' ? null : CHAIN[acc]) });
|
||||
await expect(service.issueForUsername('ant')).rejects.toMatchObject({
|
||||
code: AuthV2ErrorCode.ChainVerificationFailed,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createPrivateKey, randomUUID, type KeyObject } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SignJWT } from 'jose';
|
||||
import config from '~/config/config';
|
||||
import { BLOCKCHAIN_PORT } from '~/domain/common/ports/blockchain.port';
|
||||
import type { BlockchainPort } from '~/domain/common/ports/blockchain.port';
|
||||
import { USER_DOMAIN_SERVICE } from '~/domain/user/services/user-domain.service';
|
||||
import type { UserDomainService } from '~/domain/user/services/user-domain.service';
|
||||
import { ACCOUNT_DOMAIN_SERVICE } from '~/domain/account/services/account-domain.service';
|
||||
import type { AccountDomainService } from '~/domain/account/services/account-domain.service';
|
||||
import { USER_CERTIFICATE_DOMAIN_SERVICE } from '~/domain/user/services/user-certificate-domain.service';
|
||||
import type { UserCertificateDomainService } from '~/domain/user/services/user-certificate-domain.service';
|
||||
import type { UserCertificateDomainInterface } from '~/domain/user/interfaces/user-certificate-domain.interface';
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
|
||||
/** Звенья cert-цепи доверия CoopID (миграция 052): ano → voskhod → vostok. */
|
||||
const CERT_CHAIN_ACCOUNTS = ['ano', 'voskhod', 'vostok'] as const;
|
||||
/** Базовый уровень верификации — присутствует у всех (минимум). */
|
||||
const VERIFICATION_BASELINE = ['coop_baseline'] as const;
|
||||
const CLAIM_SCHEMA_VERSION = '1';
|
||||
/** Срок жизни сертификата. Технический дефолт (24ч) — уточняется продуктовой политикой. */
|
||||
const CERTIFICATE_TTL = '24h';
|
||||
/** Лимит размера JWS — Vision/MIFARE DESFire EV3 (AC Story 1.8). */
|
||||
const MAX_CERT_BYTES = 5 * 1024;
|
||||
|
||||
export interface CoopChainLink {
|
||||
account: string;
|
||||
public_key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выпуск participant_certificate (CoopID, Story 1.8): compact JWS alg=ES256K,
|
||||
* подписанный ключом permission `cert` аккаунта vostok (PEM из Docker Secret
|
||||
* `coop_cert_key`). Сертификат самодостаточен — несёт цепь доверия `coop_chain`
|
||||
* [ano,voskhod,vostok] для offline-верификации от вшитого trust anchor (ano).
|
||||
* Подпись ES256K через jose + Node KeyObject — wharfkit в application не нужен.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CertificateService {
|
||||
private signingKey: KeyObject | null = null;
|
||||
private coopChain: CoopChainLink[] | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(BLOCKCHAIN_PORT) private readonly blockchainPort: BlockchainPort,
|
||||
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService,
|
||||
@Inject(ACCOUNT_DOMAIN_SERVICE) private readonly accountDomainService: AccountDomainService,
|
||||
@Inject(USER_CERTIFICATE_DOMAIN_SERVICE) private readonly certDomainService: UserCertificateDomainService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Выпустить сертификат для пайщика по имени блокчейн-аккаунта (username = sub
|
||||
* второго этапа auth). `sub` сертификата — UUID пайщика (user.id), не аккаунт.
|
||||
*/
|
||||
async issueForUsername(username: string): Promise<string> {
|
||||
const user = await this.userDomainService.getUserByUsername(username);
|
||||
const identification = await this.resolveIdentification(username);
|
||||
const coopname = config.coopname;
|
||||
const coopChain = await this.getCoopChain();
|
||||
const vostokKey = coopChain[coopChain.length - 1]?.public_key;
|
||||
|
||||
const jws = await new SignJWT({
|
||||
coopname,
|
||||
coop_chain: coopChain,
|
||||
verification_types: [...VERIFICATION_BASELINE],
|
||||
identification: identification ?? null,
|
||||
claim_schema_version: CLAIM_SCHEMA_VERSION,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'ES256K', typ: 'JWT', kid: vostokKey })
|
||||
.setIssuer(`https://${coopname}.coop`)
|
||||
.setSubject(user.id)
|
||||
.setJti(randomUUID()) // серийный номер удостоверения (ЛК показывает его, Story 1.9)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(CERTIFICATE_TTL)
|
||||
.sign(this.getSigningKey());
|
||||
|
||||
const size = Buffer.byteLength(jws, 'utf8');
|
||||
if (size > MAX_CERT_BYTES)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `participant_certificate превышает ${MAX_CERT_BYTES} байт (${size})`);
|
||||
|
||||
return jws;
|
||||
}
|
||||
|
||||
private async resolveIdentification(username: string): Promise<UserCertificateDomainInterface | null> {
|
||||
const account = await this.accountDomainService.getPrivateAccount(username);
|
||||
return this.certDomainService.createCertificateFromUserData(account);
|
||||
}
|
||||
|
||||
/** PEM cert-ключа → Node KeyObject (ES256K). Кэшируется на жизнь процесса. */
|
||||
private getSigningKey(): KeyObject {
|
||||
if (this.signingKey) return this.signingKey;
|
||||
const pem = config.authV2.certKey;
|
||||
if (!pem)
|
||||
throw new Error('COOP_CERT_KEY(_FILE) не сконфигурирован: невозможно подписать participant_certificate');
|
||||
this.signingKey = createPrivateKey(pem);
|
||||
return this.signingKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Цепь cert-ключей [ano,voskhod,vostok] из COOPOS. Кэшируется (ключи cert
|
||||
* меняются крайне редко; ротация — рестарт/инвалидция в будущих историях).
|
||||
*/
|
||||
private async getCoopChain(): Promise<CoopChainLink[]> {
|
||||
if (this.coopChain) return this.coopChain;
|
||||
const links: CoopChainLink[] = [];
|
||||
for (const account of CERT_CHAIN_ACCOUNTS) {
|
||||
let key: string | null;
|
||||
try {
|
||||
key = await this.blockchainPort.getCertPublicKey(account);
|
||||
} catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.CooposDegraded, `COOPOS недоступен: cert-ключ ${account} не получен`);
|
||||
}
|
||||
if (!key)
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, `cert-permission аккаунта ${account} отсутствует или не single-key`);
|
||||
links.push({ account, public_key: key });
|
||||
}
|
||||
this.coopChain = links;
|
||||
return links;
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import type { ArgumentsHost } from '@nestjs/common';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { AuthV2Error, AuthV2ErrorCode, VaultServerDecryptionForbiddenError } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
import { AuthV2ExceptionFilter } from './auth-v2-exception.filter';
|
||||
|
||||
function mockHost(): { host: ArgumentsHost; res: { status: jest.Mock; json: jest.Mock } } {
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
const host = {
|
||||
switchToHttp: () => ({ getResponse: () => res }),
|
||||
} as unknown as ArgumentsHost;
|
||||
return { host, res };
|
||||
}
|
||||
|
||||
describe('AuthV2ExceptionFilter (Story 1.11)', () => {
|
||||
const filter = new AuthV2ExceptionFilter();
|
||||
|
||||
it.each([
|
||||
[AuthV2ErrorCode.CooposDegraded, HttpStatus.SERVICE_UNAVAILABLE],
|
||||
[AuthV2ErrorCode.VaultServerDecryptionForbidden, HttpStatus.FORBIDDEN],
|
||||
[AuthV2ErrorCode.InvalidCredentials, HttpStatus.BAD_REQUEST],
|
||||
[AuthV2ErrorCode.VaultDecryptionFailed, HttpStatus.BAD_REQUEST],
|
||||
[AuthV2ErrorCode.TimestampTooOld, HttpStatus.UNAUTHORIZED],
|
||||
[AuthV2ErrorCode.SessionBindingReused, HttpStatus.UNAUTHORIZED],
|
||||
[AuthV2ErrorCode.SessionBindingExpired, HttpStatus.UNAUTHORIZED],
|
||||
[AuthV2ErrorCode.ChainVerificationFailed, HttpStatus.UNAUTHORIZED],
|
||||
[AuthV2ErrorCode.TooManyAttempts, HttpStatus.TOO_MANY_REQUESTS],
|
||||
])('маппит %s → HTTP %d', (code, status) => {
|
||||
const { host, res } = mockHost();
|
||||
filter.catch(new AuthV2Error(code, 'описание'), host);
|
||||
expect(res.status).toHaveBeenCalledWith(status);
|
||||
});
|
||||
|
||||
it('тело ответа — формат OAuth 2.0 { error, error_description }', () => {
|
||||
const { host, res } = mockHost();
|
||||
filter.catch(new AuthV2Error(AuthV2ErrorCode.TimestampTooOld, 'Метка времени вне окна'), host);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: AuthV2ErrorCode.TimestampTooOld,
|
||||
error_description: 'Метка времени вне окна',
|
||||
});
|
||||
});
|
||||
|
||||
it('подкласс VaultServerDecryptionForbiddenError маппится в 403', () => {
|
||||
const { host, res } = mockHost();
|
||||
filter.catch(new VaultServerDecryptionForbiddenError(), host);
|
||||
expect(res.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: AuthV2ErrorCode.VaultServerDecryptionForbidden }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
|
||||
/**
|
||||
* Единый маппинг типизированной ошибки контура auth-v2 (CoopID) в HTTP-статус.
|
||||
* Тело ответа — формат OAuth 2.0 `{ error, error_description }` (см. AuthV2Error).
|
||||
* Зеркало кодов держит SDK `@coopenomics/auth` (errors.ts) — клиент по `error`
|
||||
* выбирает человеко-читаемое сообщение и actionable-подсказку.
|
||||
*
|
||||
* До Story 1.11 маппинг дублировался inline в каждом контроллере (`toHttp`).
|
||||
* Здесь он сведён в один источник: контроллеры контура просто пробрасывают
|
||||
* AuthV2Error, фильтр (`@Catch(AuthV2Error)`) единообразно переводит его в HTTP.
|
||||
*
|
||||
* Подключается через controller-scoped `@UseFilters` (НЕ APP_FILTER): в приложении
|
||||
* есть глобальный catch-all `GraphQLExceptionFilter` (useGlobalFilters), который
|
||||
* перехватывает AuthV2Error раньше любого APP_FILTER и переформатирует ответ в
|
||||
* `{statusCode, message, error}` со статусом 500. Controller-scoped фильтр имеет
|
||||
* приоритет над глобальным и пишет OAuth2-тело напрямую в response — глобальный
|
||||
* фильтр для этой ошибки уже не вызывается (проверено живьём, Story 1.11).
|
||||
*/
|
||||
const STATUS_BY_CODE: Record<AuthV2ErrorCode, number> = {
|
||||
// 503: блокчейн/инфраструктура временно недоступна — клиенту «повторить позже».
|
||||
[AuthV2ErrorCode.CooposDegraded]: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
// 429: сработал rate-limit контура входа (Story 9.1) — слишком много попыток.
|
||||
[AuthV2ErrorCode.TooManyAttempts]: HttpStatus.TOO_MANY_REQUESTS,
|
||||
// 403: серверная расшифровка ключа запрещена инвариантом (не «не авторизован»).
|
||||
[AuthV2ErrorCode.VaultServerDecryptionForbidden]: HttpStatus.FORBIDDEN,
|
||||
// 400: некорректный ввод/данные клиента (AC Story 1.11 — invalid_credentials → 400).
|
||||
[AuthV2ErrorCode.InvalidCredentials]: HttpStatus.BAD_REQUEST,
|
||||
[AuthV2ErrorCode.VaultDecryptionFailed]: HttpStatus.BAD_REQUEST,
|
||||
// 401: провал второго этапа аутентификации (владение ключом не доказано).
|
||||
[AuthV2ErrorCode.TimestampTooOld]: HttpStatus.UNAUTHORIZED,
|
||||
[AuthV2ErrorCode.SessionBindingReused]: HttpStatus.UNAUTHORIZED,
|
||||
[AuthV2ErrorCode.SessionBindingExpired]: HttpStatus.UNAUTHORIZED,
|
||||
[AuthV2ErrorCode.ChainVerificationFailed]: HttpStatus.UNAUTHORIZED,
|
||||
};
|
||||
|
||||
@Catch(AuthV2Error)
|
||||
export class AuthV2ExceptionFilter implements ExceptionFilter<AuthV2Error> {
|
||||
catch(exception: AuthV2Error, host: ArgumentsHost): void {
|
||||
const res = host.switchToHttp().getResponse<Response>();
|
||||
const status = STATUS_BY_CODE[exception.code] ?? HttpStatus.UNAUTHORIZED;
|
||||
res.status(status).json(exception.toResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, HttpCode, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { LogoutService } from './logout.service';
|
||||
|
||||
const BINDING_COOKIE_NAME = 'coop_session_binding';
|
||||
|
||||
interface LogoutBody {
|
||||
refresh_token?: string;
|
||||
access_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RP-initiated logout (Story 1.10): закрывает сессию пайщика в этом браузере —
|
||||
* отзывает refresh/access токены, защитно гасит cookie session_binding и пишет
|
||||
* audit_events. Без guard и идемпотентен: выйти можно и с истёкшим access-токеном,
|
||||
* повторный вызов не ошибка. OIDC `end_session_endpoint` с `id_token_hint` — Story 5.1.
|
||||
*/
|
||||
@Controller('coop/logout')
|
||||
export class LogoutController {
|
||||
constructor(private readonly logoutService: LogoutService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(204)
|
||||
async logout(@Body() body: LogoutBody, @Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
|
||||
await this.logoutService.logout({
|
||||
refreshToken: body?.refresh_token ?? null,
|
||||
accessToken: body?.access_token ?? null,
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
|
||||
res.clearCookie(BINDING_COOKIE_NAME, { httpOnly: true, secure: true, sameSite: 'strict', path: '/' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { LogoutService } from './logout.service';
|
||||
import { tokenTypes } from '~/types/token.types';
|
||||
|
||||
describe('LogoutService (Story 1.10)', () => {
|
||||
function setup() {
|
||||
const tokens = {
|
||||
findOneAndDelete: jest.fn(),
|
||||
};
|
||||
const audit = {
|
||||
record: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new LogoutService(tokens as never, audit as never);
|
||||
return { service, tokens, audit };
|
||||
}
|
||||
|
||||
it('отзывает refresh-токен и возвращает userId как субъект', async () => {
|
||||
const { service, tokens } = setup();
|
||||
tokens.findOneAndDelete.mockResolvedValueOnce({ userId: 'user-1', type: tokenTypes.REFRESH });
|
||||
|
||||
const res = await service.logout({ refreshToken: 'r1', ip: '1.2.3.4' });
|
||||
|
||||
expect(tokens.findOneAndDelete).toHaveBeenCalledWith('r1', tokenTypes.REFRESH);
|
||||
expect(res.subjectId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('отзывает и access-токен, если передан', async () => {
|
||||
const { service, tokens } = setup();
|
||||
tokens.findOneAndDelete.mockResolvedValue({ userId: 'user-1' });
|
||||
|
||||
await service.logout({ refreshToken: 'r1', accessToken: 'a1' });
|
||||
|
||||
expect(tokens.findOneAndDelete).toHaveBeenCalledWith('r1', tokenTypes.REFRESH);
|
||||
expect(tokens.findOneAndDelete).toHaveBeenCalledWith('a1', tokenTypes.ACCESS);
|
||||
});
|
||||
|
||||
it('идемпотентен: токена нет в БД → subjectId null, без ошибки', async () => {
|
||||
const { service, tokens } = setup();
|
||||
tokens.findOneAndDelete.mockResolvedValue(null);
|
||||
|
||||
const res = await service.logout({ refreshToken: 'gone' });
|
||||
|
||||
expect(res.subjectId).toBeNull();
|
||||
});
|
||||
|
||||
it('пустой вызов (нет токенов) не дёргает отзыв, всё равно пишет аудит', async () => {
|
||||
const { service, tokens, audit } = setup();
|
||||
|
||||
const res = await service.logout({});
|
||||
|
||||
expect(tokens.findOneAndDelete).not.toHaveBeenCalled();
|
||||
expect(res.subjectId).toBeNull();
|
||||
expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ event: 'coopid.logout', result: 'success' }));
|
||||
});
|
||||
|
||||
it('пишет audit coopid.logout с субъектом из отозванного токена', async () => {
|
||||
const { service, audit, tokens } = setup();
|
||||
tokens.findOneAndDelete.mockResolvedValueOnce({ userId: 'user-7' });
|
||||
|
||||
await service.logout({ refreshToken: 'r1', ip: '9.9.9.9' });
|
||||
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'coopid.logout', subjectId: 'user-7', actor: 'user-7', result: 'success', ip: '9.9.9.9' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('сбой аудита не валит logout (safeAudit проглатывает)', async () => {
|
||||
const { service, audit, tokens } = setup();
|
||||
tokens.findOneAndDelete.mockResolvedValueOnce({ userId: 'user-1' });
|
||||
audit.record.mockRejectedValueOnce(new Error('coop-postgres down'));
|
||||
|
||||
await expect(service.logout({ refreshToken: 'r1' })).resolves.toEqual({ subjectId: 'user-1' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TokenApplicationService } from '~/application/token/services/token-application.service';
|
||||
import { tokenTypes } from '~/types/token.types';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
|
||||
export interface LogoutInput {
|
||||
refreshToken?: string | null;
|
||||
accessToken?: string | null;
|
||||
ip?: string | null;
|
||||
}
|
||||
|
||||
export interface LogoutResult {
|
||||
/** userId отозванного токена (для лога/контекста); null, если токен уже отсутствовал. */
|
||||
subjectId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RP-initiated logout (Story 1.10): отзыв refresh/access токенов текущей сессии.
|
||||
* Толерантен — не требует валидного access-токена (выйти можно и с истёкшим);
|
||||
* идемпотентен — повторный/пустой вызов не ошибка. Субъект для аудита берётся из
|
||||
* `userId` отозванного refresh-токена. Стандартный OIDC end-session с `id_token_hint`
|
||||
* придёт в Story 5.1; здесь — отзыв токенов платформенным механизмом, как legacy-logout.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LogoutService {
|
||||
constructor(
|
||||
private readonly tokens: TokenApplicationService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async logout(input: LogoutInput): Promise<LogoutResult> {
|
||||
let subjectId: string | null = null;
|
||||
|
||||
if (input.refreshToken) {
|
||||
const revoked = await this.tokens.findOneAndDelete(input.refreshToken, tokenTypes.REFRESH);
|
||||
subjectId = revoked?.userId ?? null;
|
||||
}
|
||||
if (input.accessToken) {
|
||||
await this.tokens.findOneAndDelete(input.accessToken, tokenTypes.ACCESS);
|
||||
}
|
||||
|
||||
await this.safeAudit({ event: 'coopid.logout', subjectId, actor: subjectId, result: 'success', ip: input.ip });
|
||||
|
||||
return { subjectId };
|
||||
}
|
||||
|
||||
/** Аудит не должен валить ответ logout (coop_domain_db недоступен → degraded-лог, не 500). */
|
||||
private async safeAudit(record: Parameters<AuditService['record']>[0]): Promise<void> {
|
||||
try {
|
||||
await this.audit.record(record);
|
||||
} catch {
|
||||
// намеренно проглатываем: audit-инфраструктура отдельна от auth-критпути
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { AUTH_RATE_LIMIT_METADATA, type AuthRateLimitConfig } from './auth-rate-limit.types';
|
||||
|
||||
/**
|
||||
* Навесить двухключевой rate-limit (Story 9.1) на endpoint контура auth-v2.
|
||||
* Считывается `AuthRateLimitGuard`. Пример:
|
||||
* `@AuthRateLimit({ ip: LOGIN_IP_RULE, account: { ...LOGIN_ACCOUNT_RULE, key: r => r.params.subject_id } })`
|
||||
*/
|
||||
export const AuthRateLimit = (config: AuthRateLimitConfig) => SetMetadata(AUTH_RATE_LIMIT_METADATA, config);
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import type { Reflector } from '@nestjs/core';
|
||||
import type { ThrottlerStorage } from '@nestjs/throttler';
|
||||
import type { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface';
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
import { AuthRateLimitGuard } from './auth-rate-limit.guard';
|
||||
import { LOGIN_ACCOUNT_RULE, LOGIN_IP_RULE, type AuthRateLimitConfig } from './auth-rate-limit.types';
|
||||
|
||||
function record(totalHits: number, isBlocked = false): ThrottlerStorageRecord {
|
||||
return { totalHits, timeToExpire: 1000, isBlocked, timeToBlockExpire: isBlocked ? 1000 : 0 };
|
||||
}
|
||||
|
||||
function makeContext(config: AuthRateLimitConfig | undefined, req: { ip?: string; params?: Record<string, string> }): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => () => undefined,
|
||||
getClass: () => class {},
|
||||
switchToHttp: () => ({ getRequest: () => req }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function makeGuard(config: AuthRateLimitConfig | undefined, increment: jest.Mock) {
|
||||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(config) } as unknown as Reflector;
|
||||
const storage = { increment } as unknown as ThrottlerStorage;
|
||||
return { guard: new AuthRateLimitGuard(reflector, storage), increment };
|
||||
}
|
||||
|
||||
const TWO_KEY: AuthRateLimitConfig = {
|
||||
ip: LOGIN_IP_RULE,
|
||||
account: { ...LOGIN_ACCOUNT_RULE, key: (req) => req.params?.subject_id },
|
||||
};
|
||||
|
||||
describe('AuthRateLimitGuard (Story 9.1)', () => {
|
||||
it('без @AuthRateLimit-конфига пропускает и не трогает хранилище', async () => {
|
||||
const { guard, increment } = makeGuard(undefined, jest.fn());
|
||||
const ctx = makeContext(undefined, { ip: '1.2.3.4' });
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
expect(increment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('оба ключа под лимитом → пропуск; инкрементированы и ip, и account', async () => {
|
||||
const increment = jest.fn().mockResolvedValue(record(1));
|
||||
const { guard } = makeGuard(TWO_KEY, increment);
|
||||
const ctx = makeContext(TWO_KEY, { ip: '1.2.3.4', params: { subject_id: 'ant' } });
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
expect(increment).toHaveBeenCalledTimes(2);
|
||||
expect(increment).toHaveBeenCalledWith('1.2.3.4', LOGIN_IP_RULE.ttl, LOGIN_IP_RULE.limit, LOGIN_IP_RULE.ttl, 'ip');
|
||||
expect(increment).toHaveBeenCalledWith('ant', LOGIN_ACCOUNT_RULE.ttl, LOGIN_ACCOUNT_RULE.limit, LOGIN_ACCOUNT_RULE.ttl, 'account');
|
||||
});
|
||||
|
||||
it('превышение IP-лимита → 429 TooManyAttempts', async () => {
|
||||
const increment = jest.fn().mockResolvedValue(record(LOGIN_IP_RULE.limit + 1));
|
||||
const { guard } = makeGuard({ ip: LOGIN_IP_RULE }, increment);
|
||||
const ctx = makeContext({ ip: LOGIN_IP_RULE } as AuthRateLimitConfig, { ip: '1.2.3.4' });
|
||||
await expect(guard.canActivate(ctx)).rejects.toMatchObject({
|
||||
code: AuthV2ErrorCode.TooManyAttempts,
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(AuthV2Error);
|
||||
});
|
||||
|
||||
it('превышение account-лимита → 429 (ip под лимитом)', async () => {
|
||||
const increment = jest.fn().mockImplementation((_tracker, _ttl, _limit, _block, name) =>
|
||||
Promise.resolve(name === 'account' ? record(LOGIN_ACCOUNT_RULE.limit + 1) : record(1)),
|
||||
);
|
||||
const { guard } = makeGuard(TWO_KEY, increment);
|
||||
const ctx = makeContext(TWO_KEY, { ip: '1.2.3.4', params: { subject_id: 'ant' } });
|
||||
await expect(guard.canActivate(ctx)).rejects.toMatchObject({ code: AuthV2ErrorCode.TooManyAttempts });
|
||||
});
|
||||
|
||||
it('isBlocked (в окне блока) → 429 даже если totalHits в пределах', async () => {
|
||||
const increment = jest.fn().mockResolvedValue(record(1, true));
|
||||
const { guard } = makeGuard({ ip: LOGIN_IP_RULE }, increment);
|
||||
const ctx = makeContext({ ip: LOGIN_IP_RULE } as AuthRateLimitConfig, { ip: '1.2.3.4' });
|
||||
await expect(guard.canActivate(ctx)).rejects.toMatchObject({ code: AuthV2ErrorCode.TooManyAttempts });
|
||||
});
|
||||
|
||||
it('account-id не извлекаем → работает только per-IP ключ', async () => {
|
||||
const increment = jest.fn().mockResolvedValue(record(1));
|
||||
const { guard } = makeGuard(TWO_KEY, increment);
|
||||
// params без subject_id → account.key вернёт undefined
|
||||
const ctx = makeContext(TWO_KEY, { ip: '1.2.3.4', params: {} });
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
expect(increment).toHaveBeenCalledTimes(1);
|
||||
expect(increment).toHaveBeenCalledWith('1.2.3.4', LOGIN_IP_RULE.ttl, LOGIN_IP_RULE.limit, LOGIN_IP_RULE.ttl, 'ip');
|
||||
});
|
||||
|
||||
it('нет req.ip → трекер "unknown" (не падает)', async () => {
|
||||
const increment = jest.fn().mockResolvedValue(record(1));
|
||||
const { guard } = makeGuard({ ip: LOGIN_IP_RULE }, increment);
|
||||
const ctx = makeContext({ ip: LOGIN_IP_RULE } as AuthRateLimitConfig, {});
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
expect(increment).toHaveBeenCalledWith('unknown', LOGIN_IP_RULE.ttl, LOGIN_IP_RULE.limit, LOGIN_IP_RULE.ttl, 'ip');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { ThrottlerStorage } from '@nestjs/throttler';
|
||||
import type { Request } from 'express';
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
import { RATE_LIMIT_STORAGE } from '~/domain/auth-v2/ports/rate-limit-storage.port';
|
||||
import { AUTH_RATE_LIMIT_METADATA, type AuthRateLimitConfig, type RateLimitRule } from './auth-rate-limit.types';
|
||||
|
||||
/**
|
||||
* Двухключевой rate-limit контура auth-v2 (CoopID, Story 9.1). На endpoint'ах с
|
||||
* `@AuthRateLimit(...)` проверяет ДВА независимых счётчика: per-IP и (если
|
||||
* идентификатор доступен) per-account. Превышение ЛЮБОГО → `AuthV2Error(TooManyAttempts)`,
|
||||
* который контурный `AuthV2ExceptionFilter` (Story 1.11) отдаёт как `429` в формате OAuth2.
|
||||
*
|
||||
* Намеренно НЕ использует глобальный список throttlers `ThrottlerModule.forRoot`
|
||||
* (он применился бы ко всем guarded-роутам приложения) — guard сам зовёт
|
||||
* `storage.increment` для своих ключей. Хранилище — Redis (переживает рестарт,
|
||||
* общее для нескольких инстансов). Блок выставляется на длину окна (`blockDuration = ttl`);
|
||||
* нарастающий (escalating) lockout — Story 3.12.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthRateLimitGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
@Inject(RATE_LIMIT_STORAGE) private readonly storage: ThrottlerStorage,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const config = this.reflector.getAllAndOverride<AuthRateLimitConfig | undefined>(AUTH_RATE_LIMIT_METADATA, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!config) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const ip = req.ip ?? 'unknown';
|
||||
|
||||
// Собираем активные ключи: per-IP всегда; per-account — только если извлекаем.
|
||||
const checks: Array<{ name: string; tracker: string; rule: RateLimitRule }> = [
|
||||
{ name: 'ip', tracker: ip, rule: config.ip },
|
||||
];
|
||||
if (config.account) {
|
||||
const accountId = config.account.key(req);
|
||||
if (accountId) checks.push({ name: 'account', tracker: accountId, rule: config.account });
|
||||
}
|
||||
|
||||
let exceeded = false;
|
||||
// Инкрементируем оба счётчика (обе попытки должны учитываться), затем решаем.
|
||||
for (const { name, tracker, rule } of checks) {
|
||||
const record = await this.storage.increment(tracker, rule.ttl, rule.limit, rule.ttl, name);
|
||||
if (record.isBlocked || record.totalHits > rule.limit) exceeded = true;
|
||||
}
|
||||
|
||||
if (exceeded) {
|
||||
throw new AuthV2Error(
|
||||
AuthV2ErrorCode.TooManyAttempts,
|
||||
'Слишком много попыток. Подождите и повторите позже.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
/** Окна (мс): см. Story 9.1 AC / NFR10. */
|
||||
export const RATE_LIMIT_WINDOW_15M = 15 * 60 * 1000;
|
||||
export const RATE_LIMIT_WINDOW_1H = 60 * 60 * 1000;
|
||||
|
||||
/** Одно правило лимита: не более `limit` обращений за окно `ttl` (мс). */
|
||||
export interface RateLimitRule {
|
||||
limit: number;
|
||||
/** окно в миллисекундах */
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь идентификатор аккаунта для per-account ключа из запроса. Возвращает
|
||||
* `null|undefined`, если идентификатор недоступен ДО хендлера (тогда account-ключ
|
||||
* пропускается, работает только per-IP) — например, на session/bind, где username
|
||||
* резолвится из cookie authentik уже внутри обработчика.
|
||||
*/
|
||||
export type AccountKeyExtractor = (req: Request) => string | null | undefined;
|
||||
|
||||
/** Конфиг двухключевого rate-limit для endpoint'а: всегда per-IP, опционально per-account. */
|
||||
export interface AuthRateLimitConfig {
|
||||
ip: RateLimitRule;
|
||||
account?: RateLimitRule & { key: AccountKeyExtractor };
|
||||
}
|
||||
|
||||
/** Ключ метаданных, под которым `@AuthRateLimit` кладёт конфиг для guard'а. */
|
||||
export const AUTH_RATE_LIMIT_METADATA = 'auth-v2:rate-limit';
|
||||
|
||||
// --- Пресеты лимитов (Story 9.1 AC) ---
|
||||
|
||||
/** Per-IP для login-flow: 50 обращений / 15 мин. */
|
||||
export const LOGIN_IP_RULE: RateLimitRule = { limit: 50, ttl: RATE_LIMIT_WINDOW_15M };
|
||||
|
||||
/** Per-account для login-flow: 5 обращений / 15 мин. */
|
||||
export const LOGIN_ACCOUNT_RULE: RateLimitRule = { limit: 5, ttl: RATE_LIMIT_WINDOW_15M };
|
||||
|
||||
/**
|
||||
* Magic-link (NFR10): 3 / час по обоим ключам. Endpoint magic-link — Story 3.1
|
||||
* (ещё нет); пресет готов и навешивается на него при появлении.
|
||||
*/
|
||||
export const MAGIC_LINK_RULE: RateLimitRule = { limit: 3, ttl: RATE_LIMIT_WINDOW_1H };
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Controller,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { AUTHN_SESSION_PORT } from '~/domain/auth-v2/ports/authn-session.port';
|
||||
import type { IAuthnSessionPort } from '~/domain/auth-v2/ports/authn-session.port';
|
||||
import { AuthV2ExceptionFilter } from '../exceptions/auth-v2-exception.filter';
|
||||
import { AuthRateLimit } from '../rate-limit/auth-rate-limit.decorator';
|
||||
import { AuthRateLimitGuard } from '../rate-limit/auth-rate-limit.guard';
|
||||
import { LOGIN_IP_RULE } from '../rate-limit/auth-rate-limit.types';
|
||||
import { SessionBindingService } from './session-binding.service';
|
||||
|
||||
/**
|
||||
* Этап-мост двухэтапной аутентификации (CoopID, Story 1.6, вариант B).
|
||||
* Вызывается фронтом сразу после прохождения authentik-password на общем домене:
|
||||
* сессионная cookie authentik доезжает сюда, controller выпускает
|
||||
* session_binding_token в HTTP-only secure cookie.
|
||||
*/
|
||||
@Controller('coop/session')
|
||||
export class SessionBindingController {
|
||||
constructor(
|
||||
@Inject(AUTHN_SESSION_PORT) private readonly authnSession: IAuthnSessionPort,
|
||||
private readonly binding: SessionBindingService,
|
||||
) {}
|
||||
|
||||
@Post('bind')
|
||||
@HttpCode(204)
|
||||
@UseFilters(AuthV2ExceptionFilter)
|
||||
@UseGuards(AuthRateLimitGuard)
|
||||
// per-IP only: username резолвится из cookie authentik уже внутри хендлера,
|
||||
// в guard'е (до обработчика) аккаунт неизвестен — per-account ключ не навешиваем.
|
||||
@AuthRateLimit({ ip: LOGIN_IP_RULE })
|
||||
async bind(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
|
||||
const cookieHeader = req.headers.cookie ?? '';
|
||||
|
||||
let username: string | null;
|
||||
try {
|
||||
username = await this.authnSession.resolveUsername(cookieHeader);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('authentik unavailable');
|
||||
}
|
||||
if (!username) throw new UnauthorizedException();
|
||||
|
||||
const issued = await this.binding.issue(username);
|
||||
res.cookie(issued.cookieName, issued.token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: issued.maxAgeSec * 1000,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SignJWT } from 'jose';
|
||||
import config from '~/config/config';
|
||||
import { REDIS_PORT } from '~/domain/common/ports/redis.port';
|
||||
import type { RedisPort } from '~/domain/common/ports/redis.port';
|
||||
|
||||
export const SESSION_BINDING_TTL_SEC = 120;
|
||||
/** Redis-ключ держим чуть дольше exp токена, чтобы 1.7 мог отвергнуть свежеистёкший по jti, а не «не найти». */
|
||||
const REDIS_TTL_SEC = SESSION_BINDING_TTL_SEC + 5;
|
||||
const COOKIE_NAME = 'coop_session_binding';
|
||||
|
||||
export interface IssuedBinding {
|
||||
token: string;
|
||||
jti: string;
|
||||
cookieName: string;
|
||||
maxAgeSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выпуск session_binding_token (CoopID, вариант B): мост между этапом password
|
||||
* (authentik) и этапом timestamp-signature (controller, Story 1.7).
|
||||
* HS256 + shared secret (Docker Secret); jti кладётся в Redis single-use —
|
||||
* потребляется в 1.7 (защита от replay).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionBindingService {
|
||||
constructor(@Inject(REDIS_PORT) private readonly redis: RedisPort) {}
|
||||
|
||||
async issue(sub: string): Promise<IssuedBinding> {
|
||||
const secret = config.authV2.sessionBindingSecret;
|
||||
if (!secret) throw new Error('AUTH_V2_SESSION_BINDING_SECRET не задан');
|
||||
|
||||
const jti = randomUUID();
|
||||
const token = await new SignJWT({ stage_completed: 'password' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(sub)
|
||||
.setJti(jti)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SESSION_BINDING_TTL_SEC}s`)
|
||||
.sign(new TextEncoder().encode(secret));
|
||||
|
||||
await this.redis.setSingleUse(`coopid:binding:${jti}`, sub, REDIS_TTL_SEC);
|
||||
|
||||
return { token, jti, cookieName: COOKIE_NAME, maxAgeSec: SESSION_BINDING_TTL_SEC };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, HttpCode, NotFoundException, Param, Post, UseFilters, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { EncryptedVaultBlob, VaultSubjectType } from '~/domain/auth-v2/vault/vault.types';
|
||||
import { AuthV2ExceptionFilter } from '../exceptions/auth-v2-exception.filter';
|
||||
import { AuthRateLimit } from '../rate-limit/auth-rate-limit.decorator';
|
||||
import { AuthRateLimitGuard } from '../rate-limit/auth-rate-limit.guard';
|
||||
import { LOGIN_ACCOUNT_RULE, LOGIN_IP_RULE } from '../rate-limit/auth-rate-limit.types';
|
||||
import { VaultService } from './vault.service';
|
||||
|
||||
interface StoreVaultDto extends EncryptedVaultBlob {
|
||||
subject_type: VaultSubjectType;
|
||||
subject_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST-приём зашифрованного блоба от клиента (Story 2.1). Тело уже зашифровано
|
||||
* на клиенте — сервер только сохраняет. Авторизация bearer/grant — Эпик 6 (CASL);
|
||||
* на этапе 2.1 эндпоинт принимает blob как есть (write-only, без чтения секретов).
|
||||
*/
|
||||
@Controller('coop/vault')
|
||||
export class VaultController {
|
||||
constructor(private readonly vault: VaultService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
async store(@Body() dto: StoreVaultDto): Promise<void> {
|
||||
const { subject_type, subject_id, ...blob } = dto;
|
||||
await this.vault.store({ subject_type, subject_id }, blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отдаёт ТОЛЬКО зашифрованный blob пайщика (Story 2.2): SDK расшифровывает его
|
||||
* клиентским паролём (type-ban серверной расшифровки из 2.1 в силе — retrieve
|
||||
* не несёт секрета). Blob криптографически бесполезен без пароля, поэтому на
|
||||
* этом шаге эндпоинт не гейтится сессией; session-гейтинг придёт с verify-flow
|
||||
* (Stories 1.7+). 404 — vault'а у субъекта нет.
|
||||
*/
|
||||
@Get('participant/:subject_id')
|
||||
@UseFilters(AuthV2ExceptionFilter)
|
||||
@UseGuards(AuthRateLimitGuard)
|
||||
@AuthRateLimit({
|
||||
ip: LOGIN_IP_RULE,
|
||||
// per-account ключ — subject_id из URL (цель harvest-энумерации зашифрованных блобов).
|
||||
account: { ...LOGIN_ACCOUNT_RULE, key: (req: Request) => req.params.subject_id },
|
||||
})
|
||||
async retrieveParticipant(@Param('subject_id') subjectId: string): Promise<EncryptedVaultBlob> {
|
||||
const blob = await this.vault.retrieve({ subject_type: 'participant', subject_id: subjectId });
|
||||
if (!blob)
|
||||
throw new NotFoundException('vault не найден');
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { VaultServerDecryptionForbiddenError } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
import { VAULT_REPOSITORY } from '~/domain/auth-v2/vault/vault-repository.port';
|
||||
import type { IVaultRepository } from '~/domain/auth-v2/vault/vault-repository.port';
|
||||
import type { EncryptedVaultBlob, RetrieveVaultInput, VaultSubject } from '~/domain/auth-v2/vault/vault.types';
|
||||
|
||||
/**
|
||||
* Серверная сторона vault (CoopID, Story 2.1). Хранит и отдаёт ТОЛЬКО
|
||||
* зашифрованный блоб. Расшифровка ключа пайщика на сервере невозможна:
|
||||
* `RetrieveVaultInput` для participant не несёт секрета (type-ban), а
|
||||
* `assertNoServerDecrypt` страхует в рантайме.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VaultService {
|
||||
constructor(@Inject(VAULT_REPOSITORY) private readonly repo: IVaultRepository) {}
|
||||
|
||||
async store(subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void> {
|
||||
await this.repo.upsert(subject, blob);
|
||||
}
|
||||
|
||||
/** Возвращает зашифрованный блоб субъекта (расшифровка — только на клиенте). */
|
||||
async retrieve(input: RetrieveVaultInput): Promise<EncryptedVaultBlob | null> {
|
||||
return this.repo.find({ subject_type: input.subject_type, subject_id: input.subject_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Намеренно нереализуемый метод-маркер: любая попытка серверной расшифровки
|
||||
* ключа участника обязана пройти сюда и упасть. Существует, чтобы инвариант
|
||||
* был виден в коде и тестах, а не только в отсутствии метода.
|
||||
*/
|
||||
assertNoServerDecrypt(): never {
|
||||
throw new VaultServerDecryptionForbiddenError();
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AuthV2ExceptionFilter } from '../exceptions/auth-v2-exception.filter';
|
||||
import { AuthRateLimit } from '../rate-limit/auth-rate-limit.decorator';
|
||||
import { AuthRateLimitGuard } from '../rate-limit/auth-rate-limit.guard';
|
||||
import { LOGIN_IP_RULE } from '../rate-limit/auth-rate-limit.types';
|
||||
import { VerifyTimestampService } from './verify-timestamp.service';
|
||||
import type { VerifyTimestampResult } from './verify-timestamp.service';
|
||||
|
||||
const BINDING_COOKIE_NAME = 'coop_session_binding';
|
||||
|
||||
interface VerifyTimestampBody {
|
||||
signature?: string;
|
||||
timestamp?: string;
|
||||
binding_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Второй этап двухэтапной аутентификации CoopID (Story 1.7). Принимает подпись
|
||||
* канонической метки времени (SDK `signTimestamp`, Story 2.4), доказывает владение
|
||||
* приватным ключом пайщика против COOPOS и завершает вход.
|
||||
*
|
||||
* `binding_token` берётся из body (контракт AC) либо, как fallback, из httpOnly
|
||||
* cookie `coop_session_binding`, которую ставит этап-мост (Story 1.6) — seam между
|
||||
* cookie-хранением и client-side декодированием claims разрешается на стыке 1.6/2.x.
|
||||
*/
|
||||
@Controller('coop/verify')
|
||||
@UseFilters(AuthV2ExceptionFilter)
|
||||
export class VerifyTimestampController {
|
||||
constructor(private readonly verifyService: VerifyTimestampService) {}
|
||||
|
||||
@Post('timestamp')
|
||||
@HttpCode(200)
|
||||
@UseGuards(AuthRateLimitGuard)
|
||||
// per-IP: аккаунт зашит в подписанном binding_token, до хендлера не извлекаем.
|
||||
@AuthRateLimit({ ip: LOGIN_IP_RULE })
|
||||
async verifyTimestamp(@Body() body: VerifyTimestampBody, @Req() req: Request): Promise<VerifyTimestampResult> {
|
||||
const signature = body?.signature;
|
||||
const timestamp = body?.timestamp;
|
||||
const bindingToken = body?.binding_token ?? this.readBindingCookie(req);
|
||||
|
||||
if (!signature || !timestamp || !bindingToken)
|
||||
throw new BadRequestException('Требуются signature, timestamp и binding_token');
|
||||
|
||||
// AuthV2Error из сервиса пробрасывается контурному AuthV2ExceptionFilter
|
||||
// (Story 1.11) — единый маппинг код→HTTP/OAuth2.
|
||||
return this.verifyService.verify({
|
||||
signature,
|
||||
timestamp,
|
||||
bindingToken,
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private readBindingCookie(req: Request): string | undefined {
|
||||
const fromParser = (req as Request & { cookies?: Record<string, string> }).cookies?.[BINDING_COOKIE_NAME];
|
||||
if (fromParser) return fromParser;
|
||||
const header = req.headers.cookie ?? '';
|
||||
const match = header.split(';').map((c) => c.trim()).find((c) => c.startsWith(`${BINDING_COOKIE_NAME}=`));
|
||||
return match ? decodeURIComponent(match.slice(BINDING_COOKIE_NAME.length + 1)) : undefined;
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { Bytes, PrivateKey, Signature } from '@wharfkit/antelope';
|
||||
import { SignJWT } from 'jose';
|
||||
import config from '~/config/config';
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
import { canonicalTimestampMessage, VerifyTimestampService } from './verify-timestamp.service';
|
||||
|
||||
const KEY = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3';
|
||||
const ACCOUNT = 'ant';
|
||||
const SECRET = 'test-binding-secret-padding-0000000000';
|
||||
const PUB = PrivateKey.from(KEY).toPublic().toString();
|
||||
const TS = '2026-06-10T12:00:00.000Z';
|
||||
|
||||
async function makeToken(sub = ACCOUNT, jti = 'jti-1'): Promise<string> {
|
||||
return new SignJWT({ stage_completed: 'password' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(sub)
|
||||
.setJti(jti)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('2m')
|
||||
.sign(new TextEncoder().encode(SECRET));
|
||||
}
|
||||
|
||||
/** Подпись метки клиентским ключом — зеркало SDK signTimestamp (Story 2.4). */
|
||||
function signCanonical(ts: string, jti: string, sub: string): string {
|
||||
const message = canonicalTimestampMessage({ ts, binding_token_jti: jti, sub });
|
||||
return PrivateKey.from(KEY).signMessage(new TextEncoder().encode(message)).toString();
|
||||
}
|
||||
|
||||
function makeService(overrides: {
|
||||
consume?: jest.Mock;
|
||||
headBlockTime?: string;
|
||||
getInfoThrows?: boolean;
|
||||
hasActiveKey?: boolean;
|
||||
getAccountThrows?: boolean;
|
||||
}) {
|
||||
const redis = {
|
||||
consumeSingleUse: overrides.consume ?? jest.fn().mockResolvedValue(ACCOUNT),
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
setSingleUse: jest.fn(),
|
||||
};
|
||||
const blockchain = {
|
||||
getInfo: overrides.getInfoThrows
|
||||
? jest.fn().mockRejectedValue(new Error('down'))
|
||||
: jest.fn().mockResolvedValue({ head_block_time: overrides.headBlockTime ?? TS }),
|
||||
getAccount: overrides.getAccountThrows
|
||||
? jest.fn().mockRejectedValue(new Error('down'))
|
||||
: jest.fn().mockResolvedValue({ account_name: ACCOUNT }),
|
||||
hasActiveKey: jest.fn().mockReturnValue(overrides.hasActiveKey ?? true),
|
||||
// настоящий recover — зеркало BlockchainService.recoverPublicKey
|
||||
recoverPublicKey: jest.fn((message: string, signature: string) =>
|
||||
Signature.from(signature).recoverMessage(Bytes.fromString(message, 'utf8')).toString(),
|
||||
),
|
||||
};
|
||||
const user = { getUserByUsername: jest.fn().mockResolvedValue({ id: 'user-uuid-1' }) };
|
||||
const tokens = {
|
||||
generateAuthTokens: jest.fn().mockResolvedValue({
|
||||
access: { token: 'access-jwt', expires: new Date() },
|
||||
refresh: { token: 'refresh-jwt', expires: new Date() },
|
||||
}),
|
||||
};
|
||||
const audit = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
const certificate = { issueForUsername: jest.fn().mockResolvedValue('cert-jws') };
|
||||
const service = new VerifyTimestampService(
|
||||
redis as any,
|
||||
blockchain as any,
|
||||
user as any,
|
||||
tokens as any,
|
||||
audit as any,
|
||||
certificate as any,
|
||||
);
|
||||
return { service, redis, blockchain, user, tokens, audit, certificate };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(config.authV2, 'sessionBindingSecret', 'get').mockReturnValue(SECRET);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('canonicalTimestampMessage: зеркало SDK (golden-вектор)', () => {
|
||||
it('фиксированный алфавитный порядок ключей, байт-идентично SDK-тесту', () => {
|
||||
expect(canonicalTimestampMessage({ ts: 't', binding_token_jti: 'j', sub: 's' })).toBe(
|
||||
'{"binding_token_jti":"j","sub":"s","ts":"t"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VerifyTimestampService.verify', () => {
|
||||
it('happy path: верная подпись → токены + audit success', async () => {
|
||||
const { service, redis, tokens, audit } = makeService({ hasActiveKey: true });
|
||||
const token = await makeToken(ACCOUNT, 'jti-1');
|
||||
const signature = signCanonical(TS, 'jti-1', ACCOUNT);
|
||||
|
||||
const res = await service.verify({ signature, timestamp: TS, bindingToken: token });
|
||||
|
||||
expect(res).toEqual({ access_token: 'access-jwt', refresh_token: 'refresh-jwt', participant_certificate: 'cert-jws' });
|
||||
expect(redis.consumeSingleUse).toHaveBeenCalledWith('coopid:binding:jti-1');
|
||||
expect(tokens.generateAuthTokens).toHaveBeenCalledWith('user-uuid-1');
|
||||
expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ result: 'success' }));
|
||||
});
|
||||
|
||||
it('сбой выпуска сертификата best-effort: токены выдаются без participant_certificate', async () => {
|
||||
const { service, certificate } = makeService({ hasActiveKey: true });
|
||||
certificate.issueForUsername.mockRejectedValue(new Error('cert key missing'));
|
||||
const token = await makeToken(ACCOUNT, 'jti-nocert');
|
||||
const signature = signCanonical(TS, 'jti-nocert', ACCOUNT);
|
||||
|
||||
const res = await service.verify({ signature, timestamp: TS, bindingToken: token });
|
||||
|
||||
expect(res.access_token).toBe('access-jwt');
|
||||
expect(res.participant_certificate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recover round-trip: восстановленный pubkey передан в hasActiveKey', async () => {
|
||||
const { service, blockchain } = makeService({ hasActiveKey: true });
|
||||
const token = await makeToken(ACCOUNT, 'jti-rt');
|
||||
const signature = signCanonical(TS, 'jti-rt', ACCOUNT);
|
||||
|
||||
await service.verify({ signature, timestamp: TS, bindingToken: token });
|
||||
|
||||
expect(blockchain.hasActiveKey).toHaveBeenCalledWith(expect.anything(), PUB);
|
||||
});
|
||||
|
||||
it('повторный jti (нет в Redis) → SessionBindingReused + audit failure', async () => {
|
||||
const { service, audit } = makeService({ consume: jest.fn().mockResolvedValue(null) });
|
||||
const token = await makeToken(ACCOUNT, 'jti-used');
|
||||
const signature = signCanonical(TS, 'jti-used', ACCOUNT);
|
||||
|
||||
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toMatchObject({
|
||||
code: AuthV2ErrorCode.SessionBindingReused,
|
||||
});
|
||||
expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ result: 'failure' }));
|
||||
});
|
||||
|
||||
it('метка вне окна 60s → TimestampTooOld', async () => {
|
||||
const { service } = makeService({ headBlockTime: '2026-06-10T12:05:00.000Z' });
|
||||
const token = await makeToken(ACCOUNT, 'jti-stale');
|
||||
const signature = signCanonical(TS, 'jti-stale', ACCOUNT);
|
||||
|
||||
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toMatchObject({
|
||||
code: AuthV2ErrorCode.TimestampTooOld,
|
||||
});
|
||||
});
|
||||
|
||||
it('ключ не на аккаунте → ChainVerificationFailed', async () => {
|
||||
const { service } = makeService({ hasActiveKey: false });
|
||||
const token = await makeToken(ACCOUNT, 'jti-badkey');
|
||||
const signature = signCanonical(TS, 'jti-badkey', ACCOUNT);
|
||||
|
||||
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toMatchObject({
|
||||
code: AuthV2ErrorCode.ChainVerificationFailed,
|
||||
});
|
||||
});
|
||||
|
||||
it('битый binding_token → SessionBindingExpired', async () => {
|
||||
const { service } = makeService({});
|
||||
const signature = signCanonical(TS, 'jti-x', ACCOUNT);
|
||||
|
||||
await expect(service.verify({ signature, timestamp: TS, bindingToken: 'not-a-jwt' })).rejects.toMatchObject({
|
||||
code: AuthV2ErrorCode.SessionBindingExpired,
|
||||
});
|
||||
});
|
||||
|
||||
it('подпись чужого сообщения не восстанавливает ключ аккаунта (integrity)', async () => {
|
||||
const { service, blockchain } = makeService({ hasActiveKey: true });
|
||||
const token = await makeToken(ACCOUNT, 'jti-int');
|
||||
// подпись над ДРУГИМ ts, чем тот, что отправлен в verify
|
||||
const signature = signCanonical('2026-01-01T00:00:00.000Z', 'jti-int', ACCOUNT);
|
||||
|
||||
blockchain.hasActiveKey.mockImplementation((_acc: unknown, key: string) => key === PUB);
|
||||
|
||||
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toBeInstanceOf(AuthV2Error);
|
||||
});
|
||||
|
||||
it('COOPOS недоступен (getInfo бросает) → CooposDegraded', async () => {
|
||||
const { service } = makeService({ getInfoThrows: true });
|
||||
const token = await makeToken(ACCOUNT, 'jti-down');
|
||||
const signature = signCanonical(TS, 'jti-down', ACCOUNT);
|
||||
|
||||
await expect(service.verify({ signature, timestamp: TS, bindingToken: token })).rejects.toMatchObject({
|
||||
code: AuthV2ErrorCode.CooposDegraded,
|
||||
});
|
||||
});
|
||||
});
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { jwtVerify } from 'jose';
|
||||
import config from '~/config/config';
|
||||
import { REDIS_PORT } from '~/domain/common/ports/redis.port';
|
||||
import type { RedisPort } from '~/domain/common/ports/redis.port';
|
||||
import { BLOCKCHAIN_PORT } from '~/domain/common/ports/blockchain.port';
|
||||
import type { BlockchainPort } from '~/domain/common/ports/blockchain.port';
|
||||
import { USER_DOMAIN_SERVICE } from '~/domain/user/services/user-domain.service';
|
||||
import type { UserDomainService } from '~/domain/user/services/user-domain.service';
|
||||
import { TokenApplicationService } from '~/application/token/services/token-application.service';
|
||||
import { AuthV2Error, AuthV2ErrorCode } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { CertificateService } from '../certificate/certificate.service';
|
||||
|
||||
/** Окно свежести метки времени против head_block_time, сек (epic AC Story 1.7). */
|
||||
const TIMESTAMP_WINDOW_SEC = 60;
|
||||
/** Префикс Redis-ключа single-use jti (пишется в Story 1.6 при выпуске binding-токена). */
|
||||
const BINDING_JTI_PREFIX = 'coopid:binding:';
|
||||
|
||||
export interface VerifyTimestampInput {
|
||||
signature: string;
|
||||
timestamp: string;
|
||||
bindingToken: string;
|
||||
ip?: string | null;
|
||||
}
|
||||
|
||||
export interface VerifyTimestampResult {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
/** participant_certificate (Story 1.8). Best-effort: при сбое выпуска вход не
|
||||
* ломается — клиент дозапросит через GET /coop/certificate (там ошибка явная). */
|
||||
participant_certificate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Каноническое сообщение второго этапа auth — **зеркало**
|
||||
* `@coopenomics/auth.canonicalTimestampMessage` (Story 2.4). Ключи в фиксированном
|
||||
* алфавитном порядке: клиент (SDK) и сервер обязаны собрать байт-в-байт идентично,
|
||||
* иначе `recoverMessage` восстановит чужой pubkey. Зеркалим (а не импортируем ESM
|
||||
* SDK в CJS-контроллер) по той же конвенции, что `auth-v2.error.ts` зеркалит enum;
|
||||
* байт-идентичность держит общий golden-вектор в тестах обеих сторон.
|
||||
*/
|
||||
export function canonicalTimestampMessage(payload: { ts: string; binding_token_jti: string; sub: string }): string {
|
||||
return JSON.stringify({ binding_token_jti: payload.binding_token_jti, sub: payload.sub, ts: payload.ts });
|
||||
}
|
||||
|
||||
/**
|
||||
* Второй этап двухэтапной аутентификации CoopID (Story 1.7): доказательство
|
||||
* владения приватным ключом. Проверяет подпись канонической метки времени против
|
||||
* публичного ключа аккаунта в COOPOS и завершает вход выпуском токенов.
|
||||
* `id_token`/`participant_certificate` — Story 1.8.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VerifyTimestampService {
|
||||
private readonly logger = new Logger(VerifyTimestampService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS_PORT) private readonly redis: RedisPort,
|
||||
@Inject(BLOCKCHAIN_PORT) private readonly blockchainPort: BlockchainPort,
|
||||
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService,
|
||||
private readonly tokens: TokenApplicationService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly certificate: CertificateService,
|
||||
) {}
|
||||
|
||||
async verify(input: VerifyTimestampInput): Promise<VerifyTimestampResult> {
|
||||
// 1. binding_token: подпись (HS256, shared secret 1.6) + exp + sub/jti.
|
||||
const secret = config.authV2.sessionBindingSecret;
|
||||
if (!secret) throw new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'AUTH_V2_SESSION_BINDING_SECRET не сконфигурирован');
|
||||
|
||||
let sub: string;
|
||||
let jti: string;
|
||||
try {
|
||||
const { payload } = await jwtVerify(input.bindingToken, new TextEncoder().encode(secret));
|
||||
if (!payload.sub || !payload.jti) throw new Error('нет sub/jti');
|
||||
sub = payload.sub;
|
||||
jti = payload.jti;
|
||||
} catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingExpired, 'session_binding_token недействителен или истёк');
|
||||
}
|
||||
|
||||
// 2. single-use jti: потребляем ДО криптопроверки — токен «сгорает» при первой же
|
||||
// попытке, нельзя перебирать подписи на одном binding-токене (anti-replay).
|
||||
const consumed = await this.redis.consumeSingleUse(`${BINDING_JTI_PREFIX}${jti}`);
|
||||
if (consumed === null) {
|
||||
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, result: 'failure', context: { reason: 'binding_reused' }, ip: input.ip });
|
||||
throw new AuthV2Error(AuthV2ErrorCode.SessionBindingReused, 'session_binding_token уже использован');
|
||||
}
|
||||
|
||||
// 3. окно свежести метки против времени блокчейна (±60s).
|
||||
let headBlockTime: string;
|
||||
try {
|
||||
headBlockTime = (await this.blockchainPort.getInfo()).head_block_time;
|
||||
} catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'COOPOS недоступен: не удалось получить время блокчейна');
|
||||
}
|
||||
const skewSec = Math.abs(new Date(headBlockTime).getTime() - new Date(input.timestamp).getTime()) / 1000;
|
||||
if (!Number.isFinite(skewSec) || skewSec > TIMESTAMP_WINDOW_SEC) {
|
||||
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, result: 'failure', context: { reason: 'timestamp_window' }, ip: input.ip });
|
||||
throw new AuthV2Error(AuthV2ErrorCode.TimestampTooOld, 'Метка времени вне допустимого окна свежести');
|
||||
}
|
||||
|
||||
// 4. восстановить pubkey из подписи по тому же каноническому сообщению, что подписал клиент.
|
||||
let recoveredKey: string;
|
||||
try {
|
||||
const message = canonicalTimestampMessage({ ts: input.timestamp, binding_token_jti: jti, sub });
|
||||
recoveredKey = this.blockchainPort.recoverPublicKey(message, input.signature);
|
||||
} catch {
|
||||
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, result: 'failure', context: { reason: 'signature_malformed' }, ip: input.ip });
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Не удалось восстановить ключ из подписи');
|
||||
}
|
||||
|
||||
// 5. сверить восстановленный ключ с активными ключами аккаунта в COOPOS.
|
||||
let account: Awaited<ReturnType<BlockchainPort['getAccount']>>;
|
||||
try {
|
||||
account = await this.blockchainPort.getAccount(sub);
|
||||
} catch {
|
||||
throw new AuthV2Error(AuthV2ErrorCode.CooposDegraded, 'COOPOS недоступен: не удалось получить аккаунт');
|
||||
}
|
||||
if (!account || !this.blockchainPort.hasActiveKey(account, recoveredKey)) {
|
||||
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, result: 'failure', context: { reason: 'key_mismatch' }, ip: input.ip });
|
||||
throw new AuthV2Error(AuthV2ErrorCode.ChainVerificationFailed, 'Подпись не соответствует ключу аккаунта');
|
||||
}
|
||||
|
||||
// успех → выпуск токенов платформенным механизмом (id_token/certificate — Story 1.8).
|
||||
const user = await this.userDomainService.getUserByUsername(sub);
|
||||
const pair = await this.tokens.generateAuthTokens(user.id);
|
||||
|
||||
// participant_certificate (Story 1.8) — best-effort: сбой выпуска не валит логин.
|
||||
let participant_certificate: string | undefined;
|
||||
try {
|
||||
participant_certificate = await this.certificate.issueForUsername(sub);
|
||||
} catch (e) {
|
||||
this.logger.warn(`participant_certificate не выпущен на verify для ${sub}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
|
||||
await this.safeAudit({ event: 'coopid.verify.timestamp', subjectId: sub, actor: sub, result: 'success', ip: input.ip });
|
||||
|
||||
return { access_token: pair.access.token, refresh_token: pair.refresh.token, participant_certificate };
|
||||
}
|
||||
|
||||
/** Аудит не должен валить успешный вход (coop_domain_db недоступен → degraded-лог, не 500). */
|
||||
private async safeAudit(record: Parameters<AuditService['record']>[0]): Promise<void> {
|
||||
try {
|
||||
await this.audit.record(record);
|
||||
} catch {
|
||||
// намеренно проглатываем: audit-инфраструктура отдельна от auth-критпути
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
@@ -119,7 +120,35 @@ const envVarsSchema = z.object({
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
REDIS_PASSWORD: z.string(),
|
||||
BLOCKCHAIN_RPC: z.string().min(1, { message: 'Не должно быть пустым' }),
|
||||
// CSV-список RPC-endpoint'ов COOPOS (CoopID, Story 1.3): на prod ≥2 для
|
||||
// failover (механика — Story 9.4). Не задан — используется [BLOCKCHAIN_RPC].
|
||||
BLOCKCHAIN_RPC_LIST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(v => (v ? [...new Set(v.split(',').map(s => s.trim()).filter(Boolean))] : []))
|
||||
.pipe(z.array(z.string().url({ message: 'Каждый элемент BLOCKCHAIN_RPC_LIST — валидный URL' }))),
|
||||
CHAIN_ID: z.string().min(1, { message: 'Не должно быть пустым' }),
|
||||
// coop_domain_db (CoopID, Story 1.4): отдельная БД в общем сервисе postgres.
|
||||
// Пароль — значением или файлом (*_FILE приоритетнее; путь /run/secrets/... в контейнере).
|
||||
// Дефолт-порт 5532 = host-маппинг существующего postgres (PG_HOST_PORT) для запуска вне контейнера.
|
||||
COOP_DOMAIN_DB_HOST: z.string().default('127.0.0.1'),
|
||||
COOP_DOMAIN_DB_PORT: z.coerce.number().default(5532),
|
||||
COOP_DOMAIN_DB_USERNAME: z.string().default('coop_app_user'),
|
||||
COOP_DOMAIN_DB_DATABASE: z.string().default('coop_domain_db'),
|
||||
COOP_DOMAIN_DB_PASSWORD: z.string().optional(),
|
||||
COOP_DOMAIN_DB_PASSWORD_FILE: z.string().optional(),
|
||||
// auth-v2 (CoopID): shared-токен вебхука authentik→controller (Story 1.5).
|
||||
AUTH_V2_WEBHOOK_TOKEN: z.string().optional(),
|
||||
AUTH_V2_WEBHOOK_TOKEN_FILE: z.string().optional(),
|
||||
// auth-v2 (CoopID, Story 1.6): секрет подписи session_binding_token (HS256) + адрес authentik.
|
||||
AUTH_V2_SESSION_BINDING_SECRET: z.string().optional(),
|
||||
AUTH_V2_SESSION_BINDING_SECRET_FILE: z.string().optional(),
|
||||
AUTHENTIK_INTERNAL_URL: z.string().default('http://authentik-server:9000'),
|
||||
// auth-v2 (CoopID, Story 1.8): PEM приватного ключа (ES256K/secp256k1) permission `cert`
|
||||
// аккаунта vostok — им подписывается participant_certificate. Тот же ключ дериватится
|
||||
// в vostok.cert миграцией 052 (on-chain цепь обязана совпасть с ключом подписи).
|
||||
COOP_CERT_KEY: z.string().optional(),
|
||||
COOP_CERT_KEY_FILE: z.string().optional(),
|
||||
/** Задержка (мс) перед get_table_rows после мутации; 0 — отключить */
|
||||
POST_TRANSACT_CHAIN_READ_DELAY_MS: z
|
||||
.string()
|
||||
@@ -219,8 +248,69 @@ export default {
|
||||
port: envVars.data.PORT,
|
||||
server_secret: envVars.data.SERVER_SECRET,
|
||||
timezone: envVars.data.TIMEZONE,
|
||||
coopDomainDb: {
|
||||
host: envVars.data.COOP_DOMAIN_DB_HOST,
|
||||
port: envVars.data.COOP_DOMAIN_DB_PORT,
|
||||
username: envVars.data.COOP_DOMAIN_DB_USERNAME,
|
||||
database: envVars.data.COOP_DOMAIN_DB_DATABASE,
|
||||
// Ленивый геттер: отсутствие секрет-файла бьёт только по потребителям
|
||||
// coop_domain_db (миграция V2.4.0+), а не по импорту config всем coopback'ом.
|
||||
get password(): string {
|
||||
const file = envVars.data!.COOP_DOMAIN_DB_PASSWORD_FILE;
|
||||
if (file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').trim();
|
||||
} catch (e) {
|
||||
throw new Error(`COOP_DOMAIN_DB_PASSWORD_FILE недоступен (${file}): ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
return envVars.data!.COOP_DOMAIN_DB_PASSWORD ?? '';
|
||||
},
|
||||
},
|
||||
authV2: {
|
||||
authentikInternalUrl: envVars.data.AUTHENTIK_INTERNAL_URL,
|
||||
get webhookToken(): string {
|
||||
const file = envVars.data!.AUTH_V2_WEBHOOK_TOKEN_FILE;
|
||||
if (file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return envVars.data!.AUTH_V2_WEBHOOK_TOKEN ?? '';
|
||||
},
|
||||
get sessionBindingSecret(): string {
|
||||
const file = envVars.data!.AUTH_V2_SESSION_BINDING_SECRET_FILE;
|
||||
if (file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return envVars.data!.AUTH_V2_SESSION_BINDING_SECRET ?? '';
|
||||
},
|
||||
// PEM cert-ключа: многострочный, .trim() убирает только хвостовой перевод строки
|
||||
// (createPrivateKey терпим к нему). '' при отсутствии — потребитель (CertificateService)
|
||||
// отдаёт явную ошибку конфигурации, не падая на импорте config.
|
||||
get certKey(): string {
|
||||
const file = envVars.data!.COOP_CERT_KEY_FILE;
|
||||
if (file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return envVars.data!.COOP_CERT_KEY ?? '';
|
||||
},
|
||||
},
|
||||
blockchain: {
|
||||
url: envVars.data.BLOCKCHAIN_RPC,
|
||||
rpcList: envVars.data.BLOCKCHAIN_RPC_LIST.length
|
||||
? envVars.data.BLOCKCHAIN_RPC_LIST
|
||||
: [envVars.data.BLOCKCHAIN_RPC],
|
||||
id: envVars.data.CHAIN_ID,
|
||||
root_symbol: envVars.data.ROOT_SYMBOL,
|
||||
root_govern_symbol: envVars.data.ROOT_GOVERN_SYMBOL,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Зеркало enum'а ошибок SDK @coopenomics/auth (источник контракта един). */
|
||||
export enum AuthV2ErrorCode {
|
||||
InvalidCredentials = 'invalid_credentials',
|
||||
VaultDecryptionFailed = 'vault_decryption_failed',
|
||||
VaultServerDecryptionForbidden = 'vault_server_decryption_forbidden',
|
||||
TimestampTooOld = 'timestamp_too_old',
|
||||
SessionBindingReused = 'session_binding_reused',
|
||||
SessionBindingExpired = 'session_binding_expired',
|
||||
ChainVerificationFailed = 'chain_verification_failed',
|
||||
CooposDegraded = 'coopos_degraded',
|
||||
TooManyAttempts = 'too_many_attempts',
|
||||
}
|
||||
|
||||
/** Ошибки auth-v2 в формате OAuth 2.0 ({ error, error_description }). */
|
||||
export class AuthV2Error extends Error {
|
||||
constructor(
|
||||
readonly code: AuthV2ErrorCode,
|
||||
description: string,
|
||||
) {
|
||||
super(description);
|
||||
this.name = 'AuthV2Error';
|
||||
}
|
||||
|
||||
toResponse(): { error: AuthV2ErrorCode; error_description: string } {
|
||||
return { error: this.code, error_description: this.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвариант CoopID: сервер никогда не расшифровывает ключ пайщика.
|
||||
* Бросается в рантайме, если код пытается это сделать (страховка к type-ban).
|
||||
*/
|
||||
export class VaultServerDecryptionForbiddenError extends AuthV2Error {
|
||||
constructor() {
|
||||
super(
|
||||
AuthV2ErrorCode.VaultServerDecryptionForbidden,
|
||||
'Серверная расшифровка ключа участника запрещена: расшифровать может только владелец пароля на клиенте',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const AUTHN_SESSION_PORT = Symbol('AuthnSessionPort');
|
||||
|
||||
/**
|
||||
* Порт проверки сессии внешнего IdP (authentik) — первый этап аутентификации.
|
||||
* Реализация ходит к authentik по сети; application/domain знают только интерфейс.
|
||||
*/
|
||||
export interface IAuthnSessionPort {
|
||||
/**
|
||||
* Возвращает username аутентифицированного пользователя по его сессии IdP,
|
||||
* либо null если сессия отсутствует/недействительна.
|
||||
* @throws при сетевой/инфраструктурной недоступности IdP (≠ невалидная сессия).
|
||||
*/
|
||||
resolveUsername(sessionCookie: string): Promise<string | null>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Порт хранилища счётчиков rate-limit контура auth-v2 (CoopID, Story 9.1).
|
||||
* Реализация (infrastructure) — поверх Redis: счётчик переживает рестарт и общий
|
||||
* для нескольких инстансов controller'а. application/guard знают только токен;
|
||||
* сам контракт — `ThrottlerStorage` из `@nestjs/throttler` (адаптер его implements),
|
||||
* чтобы переиспользовать проверенную семантику increment/TTL/block.
|
||||
*/
|
||||
export const RATE_LIMIT_STORAGE = Symbol('RateLimitStorage');
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { EncryptedVaultBlob, VaultSubject } from './vault.types';
|
||||
|
||||
export const VAULT_REPOSITORY = Symbol('VaultRepository');
|
||||
|
||||
export interface IVaultRepository {
|
||||
/** Сохранить/обновить блоб по субъекту (upsert по subject_type+subject_id). */
|
||||
upsert(subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void>;
|
||||
/** Зашифрованный блоб субъекта или null. */
|
||||
find(subject: VaultSubject): Promise<EncryptedVaultBlob | null>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type VaultSubjectType = 'participant' | 'coop' | 'council_action';
|
||||
|
||||
export interface VaultSubject {
|
||||
subject_type: VaultSubjectType;
|
||||
subject_id: string;
|
||||
}
|
||||
|
||||
/** Зашифрованный блоб — всё, что сервер хранит и отдаёт (base64url-строки). */
|
||||
export interface EncryptedVaultBlob {
|
||||
cipher_version: string;
|
||||
kdf_version: string;
|
||||
salt: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
auth_tag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-driven decryption ban: для `participant` входной тип НЕ содержит
|
||||
* никакого секрета разблокировки — серверная расшифровка невозможна по типам.
|
||||
* coop/council_action разблокируются service-account ключом (вне scope 2.1).
|
||||
*/
|
||||
export type RetrieveVaultInput =
|
||||
| { subject_type: 'participant'; subject_id: string }
|
||||
| { subject_type: 'coop' | 'council_action'; subject_id: string };
|
||||
@@ -33,6 +33,21 @@ export interface BlockchainPort {
|
||||
|
||||
// Authentication related methods
|
||||
hasActiveKey(account: any, publicKey: string): boolean;
|
||||
/**
|
||||
* Восстановить публичный ключ из COOPOS-native recoverable подписи (`SIG_K1_…`)
|
||||
* над utf8-сообщением. Зеркало клиентского `signMessage` (SDK signTimestamp,
|
||||
* Story 2.4): сервер собирает то же каноническое сообщение и восстанавливает
|
||||
* pubkey для сверки с аккаунтом. Крипто инкапсулировано в инфраструктуре —
|
||||
* application/auth-v2 не импортирует wharfkit напрямую (гексагональный инвариант).
|
||||
*/
|
||||
recoverPublicKey(message: string, signature: string): string;
|
||||
/**
|
||||
* Публичный ключ permission `cert` аккаунта (нормализованный `PUB_K1_…`) — звено
|
||||
* cert-цепи доверия CoopID (ano→voskhod→vostok). Требует строго single-key
|
||||
* (threshold=1, один ключ, без accounts/waits). null — если cert-permission нет
|
||||
* или он не single-key. Используется при сборке claim `coop_chain` сертификата.
|
||||
*/
|
||||
getCertPublicKey(accountName: string): Promise<string | null>;
|
||||
getCooperative(coopname: string): Promise<any>;
|
||||
changeKey(data: RegistratorContract.Actions.ChangeKey.IChangeKey): Promise<void>;
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export interface RedisPort {
|
||||
publish(channel: string, message: any): Promise<void>;
|
||||
subscribe(channel: string, handler: (message: any) => void): void;
|
||||
/** SET key value NX EX ttl — true, если ключ записан (не существовал). */
|
||||
setSingleUse(key: string, value: string, ttlSec: number): Promise<boolean>;
|
||||
/** Атомарно прочитать и удалить ключ (GETDEL) — single-use consume. */
|
||||
consumeSingleUse(key: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export const REDIS_PORT = Symbol('RedisPort');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AUTHN_SESSION_PORT } from '~/domain/auth-v2/ports/authn-session.port';
|
||||
import { RATE_LIMIT_STORAGE } from '~/domain/auth-v2/ports/rate-limit-storage.port';
|
||||
import { VAULT_REPOSITORY } from '~/domain/auth-v2/vault/vault-repository.port';
|
||||
import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
import { AuthentikSessionAdapter } from './authentik-session.adapter';
|
||||
import { PostgresVaultRepository } from './postgres-vault.repository';
|
||||
import { RedisThrottlerStorage } from './redis-throttler.storage';
|
||||
|
||||
/** Инфраструктурные адаптеры auth-v2 (CoopID): порт сессии IdP + vault-репозиторий + rate-limit storage. */
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
providers: [
|
||||
{ provide: AUTHN_SESSION_PORT, useClass: AuthentikSessionAdapter },
|
||||
{ provide: VAULT_REPOSITORY, useClass: PostgresVaultRepository },
|
||||
{ provide: RATE_LIMIT_STORAGE, useClass: RedisThrottlerStorage },
|
||||
],
|
||||
exports: [AUTHN_SESSION_PORT, VAULT_REPOSITORY, RATE_LIMIT_STORAGE],
|
||||
})
|
||||
export class AuthV2InfrastructureModule {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import config from '~/config/config';
|
||||
import { IAuthnSessionPort } from '~/domain/auth-v2/ports/authn-session.port';
|
||||
|
||||
/**
|
||||
* Адаптер проверки сессии authentik: GET /api/v3/core/users/me/ с проброшенной
|
||||
* сессионной cookie пайщика. 200 → username; 401/403 → null (невалидная сессия);
|
||||
* сеть/5xx → throw (отличаем недоступность IdP от «не залогинен»).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthentikSessionAdapter implements IAuthnSessionPort {
|
||||
async resolveUsername(sessionCookie: string): Promise<string | null> {
|
||||
if (!sessionCookie) return null;
|
||||
|
||||
const res = await fetch(`${config.authV2.authentikInternalUrl}/api/v3/core/users/me/`, {
|
||||
headers: { cookie: sessionCookie, accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (res.status === 401 || res.status === 403) return null;
|
||||
if (!res.ok) throw new Error(`authentik me-endpoint вернул ${res.status}`);
|
||||
|
||||
const data = (await res.json()) as { user?: { username?: string } };
|
||||
return data?.user?.username ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '~/config/config';
|
||||
import { IVaultRepository } from '~/domain/auth-v2/vault/vault-repository.port';
|
||||
import type { EncryptedVaultBlob, VaultSubject } from '~/domain/auth-v2/vault/vault.types';
|
||||
|
||||
/**
|
||||
* Хранилище vault-блобов в coop_domain_db (таблица `vaults`, создана миграцией
|
||||
* V2.4.0). Свой DataSource, как AuditService: недоступность coop-postgres бьёт
|
||||
* только по vault-операциям, не по запуску coopback.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PostgresVaultRepository implements IVaultRepository, OnModuleDestroy {
|
||||
private ds: DataSource | null = null;
|
||||
private initializing: Promise<DataSource> | null = null;
|
||||
|
||||
private getDataSource(): Promise<DataSource> {
|
||||
if (this.ds?.isInitialized) return Promise.resolve(this.ds);
|
||||
if (!this.initializing) {
|
||||
this.initializing = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.coopDomainDb.host,
|
||||
port: config.coopDomainDb.port,
|
||||
username: config.coopDomainDb.username,
|
||||
password: config.coopDomainDb.password,
|
||||
database: config.coopDomainDb.database,
|
||||
})
|
||||
.initialize()
|
||||
.then((ds) => {
|
||||
this.ds = ds;
|
||||
return ds;
|
||||
})
|
||||
.finally(() => {
|
||||
this.initializing = null;
|
||||
});
|
||||
}
|
||||
return this.initializing;
|
||||
}
|
||||
|
||||
async upsert(subject: VaultSubject, blob: EncryptedVaultBlob): Promise<void> {
|
||||
const ds = await this.getDataSource();
|
||||
await ds.query(
|
||||
`INSERT INTO vaults (subject_type, subject_id, cipher_version, kdf_version, salt, nonce, ciphertext, auth_tag, updated_at)
|
||||
VALUES ($1,$2,$3,$4, decode($5,'base64'), decode($6,'base64'), decode($7,'base64'), decode($8,'base64'), now())
|
||||
ON CONFLICT (subject_type, subject_id) DO UPDATE SET
|
||||
cipher_version = EXCLUDED.cipher_version,
|
||||
kdf_version = EXCLUDED.kdf_version,
|
||||
salt = EXCLUDED.salt,
|
||||
nonce = EXCLUDED.nonce,
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
auth_tag = EXCLUDED.auth_tag,
|
||||
updated_at = now()`,
|
||||
[
|
||||
subject.subject_type,
|
||||
subject.subject_id,
|
||||
blob.cipher_version,
|
||||
blob.kdf_version,
|
||||
b64UrlToB64(blob.salt),
|
||||
b64UrlToB64(blob.nonce),
|
||||
b64UrlToB64(blob.ciphertext),
|
||||
b64UrlToB64(blob.auth_tag),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async find(subject: VaultSubject): Promise<EncryptedVaultBlob | null> {
|
||||
const ds = await this.getDataSource();
|
||||
const rows: any[] = await ds.query(
|
||||
`SELECT cipher_version, kdf_version,
|
||||
encode(salt,'base64') AS salt, encode(nonce,'base64') AS nonce,
|
||||
encode(ciphertext,'base64') AS ciphertext, encode(auth_tag,'base64') AS auth_tag
|
||||
FROM vaults WHERE subject_type=$1 AND subject_id=$2`,
|
||||
[subject.subject_type, subject.subject_id],
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
const r = rows[0];
|
||||
return {
|
||||
cipher_version: r.cipher_version,
|
||||
kdf_version: r.kdf_version,
|
||||
salt: b64ToB64Url(r.salt),
|
||||
nonce: b64ToB64Url(r.nonce),
|
||||
ciphertext: b64ToB64Url(r.ciphertext),
|
||||
auth_tag: b64ToB64Url(r.auth_tag),
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.ds?.isInitialized) await this.ds.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Postgres decode(...,'base64') требует стандартный алфавит И padding;
|
||||
// SDK шлёт base64url без padding — конвертируем и добиваем '=' до кратности 4.
|
||||
function b64UrlToB64(s: string): string {
|
||||
const std = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = std.length % 4;
|
||||
return pad ? std + '='.repeat(4 - pad) : std;
|
||||
}
|
||||
function b64ToB64Url(s: string): string {
|
||||
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type Redis from 'ioredis';
|
||||
import { RedisThrottlerStorage } from './redis-throttler.storage';
|
||||
|
||||
function makeStorage(evalImpl: jest.Mock) {
|
||||
const publisher = { eval: evalImpl } as unknown as Redis;
|
||||
return new RedisThrottlerStorage({ publisher });
|
||||
}
|
||||
|
||||
describe('RedisThrottlerStorage (Story 9.1)', () => {
|
||||
it('зовёт Lua eval с префиксованными hit/block ключами и аргументами ttl/limit/block', async () => {
|
||||
const evalImpl = jest.fn().mockResolvedValue([1, 900000, 0, 0]);
|
||||
const storage = makeStorage(evalImpl);
|
||||
|
||||
await storage.increment('1.2.3.4', 900000, 50, 900000, 'ip');
|
||||
|
||||
expect(evalImpl).toHaveBeenCalledTimes(1);
|
||||
const [script, numKeys, hitKey, blockKey, ttl, limit, block] = evalImpl.mock.calls[0];
|
||||
expect(typeof script).toBe('string');
|
||||
expect(numKeys).toBe(2);
|
||||
expect(hitKey).toBe('coopid:rl:ip:1.2.3.4');
|
||||
expect(blockKey).toBe('coopid:rl:ip:1.2.3.4:block');
|
||||
expect([ttl, limit, block]).toEqual([900000, 50, 900000]);
|
||||
});
|
||||
|
||||
it('маппит ответ Lua [totalHits, timeToExpire, isBlocked, timeToBlockExpire] в record', async () => {
|
||||
const storage = makeStorage(jest.fn().mockResolvedValue([6, 800000, 1, 700000]));
|
||||
const rec = await storage.increment('ant', 900000, 5, 900000, 'account');
|
||||
expect(rec).toEqual({
|
||||
totalHits: 6,
|
||||
timeToExpire: 800000,
|
||||
isBlocked: true,
|
||||
timeToBlockExpire: 700000,
|
||||
});
|
||||
});
|
||||
|
||||
it('isBlocked=false при флаге 0', async () => {
|
||||
const storage = makeStorage(jest.fn().mockResolvedValue([3, 900000, 0, 0]));
|
||||
const rec = await storage.increment('ant', 900000, 5, 900000, 'account');
|
||||
expect(rec.isBlocked).toBe(false);
|
||||
expect(rec.totalHits).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ThrottlerStorage } from '@nestjs/throttler';
|
||||
import type { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface';
|
||||
import type Redis from 'ioredis';
|
||||
import { REDIS_PROVIDER } from '~/infrastructure/redis/redis.provider';
|
||||
|
||||
/**
|
||||
* Redis-хранилище счётчиков rate-limit (Story 9.1) — реализация `ThrottlerStorage`
|
||||
* из `@nestjs/throttler` поверх ioredis (`REDIS_PROVIDER.publisher`). Готового
|
||||
* redis-storage-пакета в проекте нет; добавлять зависимость ради одной истории
|
||||
* избыточно — реализуем интерфейс сами.
|
||||
*
|
||||
* Инкремент атомарен (Lua): INCR счётчика → если он первый, сразу ставим PEXPIRE
|
||||
* (без гонки «INCR без TTL» → вечный ключ). При превышении лимита ставится отдельный
|
||||
* block-ключ с PX(blockDuration) — повторные запросы в окне блока сразу `isBlocked`.
|
||||
*
|
||||
* Префикс ключей `coopid:rl:` изолирует счётчики контура от прочих данных Redis.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisThrottlerStorage implements ThrottlerStorage {
|
||||
// KEYS[1] — счётчик попыток, KEYS[2] — block-ключ.
|
||||
// ARGV: [1] ttl(ms), [2] limit, [3] blockDuration(ms).
|
||||
private static readonly INCREMENT_LUA = `
|
||||
local totalHits = redis.call('INCR', KEYS[1])
|
||||
local timeToExpire = redis.call('PTTL', KEYS[1])
|
||||
if timeToExpire <= 0 then
|
||||
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[1]))
|
||||
timeToExpire = tonumber(ARGV[1])
|
||||
end
|
||||
local blocked = redis.call('GET', KEYS[2])
|
||||
local timeToBlockExpire = 0
|
||||
if blocked == false then
|
||||
if totalHits > tonumber(ARGV[2]) then
|
||||
redis.call('SET', KEYS[2], 1, 'PX', tonumber(ARGV[3]))
|
||||
blocked = 1
|
||||
timeToBlockExpire = tonumber(ARGV[3])
|
||||
end
|
||||
else
|
||||
timeToBlockExpire = redis.call('PTTL', KEYS[2])
|
||||
end
|
||||
return { totalHits, timeToExpire, blocked ~= false and 1 or 0, timeToBlockExpire }
|
||||
`;
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS_PROVIDER)
|
||||
private readonly redis: { publisher: Redis },
|
||||
) {}
|
||||
|
||||
async increment(
|
||||
key: string,
|
||||
ttl: number,
|
||||
limit: number,
|
||||
blockDuration: number,
|
||||
throttlerName: string,
|
||||
): Promise<ThrottlerStorageRecord> {
|
||||
const hitKey = `coopid:rl:${throttlerName}:${key}`;
|
||||
const blockKey = `${hitKey}:block`;
|
||||
const raw = (await this.redis.publisher.eval(
|
||||
RedisThrottlerStorage.INCREMENT_LUA,
|
||||
2,
|
||||
hitKey,
|
||||
blockKey,
|
||||
ttl,
|
||||
limit,
|
||||
blockDuration,
|
||||
)) as [number, number, number, number];
|
||||
|
||||
const [totalHits, timeToExpire, isBlocked, timeToBlockExpire] = raw;
|
||||
return {
|
||||
totalHits: Number(totalHits),
|
||||
timeToExpire: Number(timeToExpire),
|
||||
isBlocked: Number(isBlocked) === 1,
|
||||
timeToBlockExpire: Number(timeToBlockExpire),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// infrastructure/blockchain/blockchain.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Action, API, APIClient, Name, PrivateKey, PublicKey } from '@wharfkit/antelope';
|
||||
import { Action, API, APIClient, Bytes, Name, PrivateKey, PublicKey, Signature } from '@wharfkit/antelope';
|
||||
import { ContractKit, Table } from '@wharfkit/contract';
|
||||
import { Session, TransactResult } from '@wharfkit/session';
|
||||
import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey';
|
||||
@@ -169,6 +169,28 @@ export class BlockchainService implements BlockchainPort {
|
||||
}
|
||||
|
||||
// Authentication related methods
|
||||
public async getCertPublicKey(accountName: string): Promise<string | null> {
|
||||
const account = await this.getAccount(accountName);
|
||||
if (!account) return null;
|
||||
const accountJson = JSON.parse(JSON.stringify(account));
|
||||
const certPerm = accountJson.permissions?.find((p: any) => p.perm_name === 'cert');
|
||||
const auth = certPerm?.required_auth;
|
||||
// строго single-key cert-permission: один ключ, без делегирования (accounts/waits)
|
||||
if (!auth || auth.threshold !== 1 || auth.keys?.length !== 1 || auth.accounts?.length || auth.waits?.length)
|
||||
return null;
|
||||
try {
|
||||
return PublicKey.from(auth.keys[0].key).toString();
|
||||
} catch {
|
||||
return auth.keys[0].key ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
public recoverPublicKey(message: string, signature: string): string {
|
||||
// recoverMessage хэширует utf8-байты (Checksum256) и восстанавливает pubkey —
|
||||
// байт-в-байт зеркало клиентского PrivateKey.signMessage (SDK signTimestamp).
|
||||
return Signature.from(signature).recoverMessage(Bytes.fromString(message, 'utf8')).toString();
|
||||
}
|
||||
|
||||
public hasActiveKey(account: BlockchainAccountInterface, publicKey: string): boolean {
|
||||
// Преобразуем объект аккаунта в обычный JSON для работы со строками (Wharfkit возвращает свои объекты)
|
||||
const accountJson = JSON.parse(JSON.stringify(account));
|
||||
|
||||
@@ -31,6 +31,16 @@ export class RedisService implements OnModuleDestroy, RedisPort {
|
||||
await this.redisClient.publisher.publish(channel, JSON.stringify(message));
|
||||
}
|
||||
|
||||
async setSingleUse(key: string, value: string, ttlSec: number): Promise<boolean> {
|
||||
const res = await this.redisClient.publisher.set(key, value, 'EX', ttlSec, 'NX');
|
||||
return res === 'OK';
|
||||
}
|
||||
|
||||
async consumeSingleUse(key: string): Promise<string | null> {
|
||||
// GETDEL (Redis ≥6.2) — атомарное чтение+удаление, гарантирует single-use.
|
||||
return this.redisClient.publisher.getdel(key);
|
||||
}
|
||||
|
||||
subscribe(channel: string, handler: (message: any) => void) {
|
||||
if (this.redisClient.subscriber.status !== 'ready') {
|
||||
this.logger.error('Клиент Subscriber Redis не готов');
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { assertContextHasNoSecrets } from '~/application/auth-v2/audit/audit.service';
|
||||
import { mapAuthentikEvent } from '~/application/auth-v2/authentik-events.controller';
|
||||
|
||||
describe('auth-v2: audit blacklist', () => {
|
||||
it('пропускает чистый context', () => {
|
||||
expect(() => assertContextHasNoSecrets({ messages: ['weak'], created: 'x' })).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(['password', 'user_password', 'private_key', 'access_token', 'server_secret', 'signature'])(
|
||||
'бросает на ключ %s',
|
||||
(key) => {
|
||||
expect(() => assertContextHasNoSecrets({ [key]: 'value' })).toThrow(/секрет/);
|
||||
},
|
||||
);
|
||||
|
||||
it('бросает на секрет во вложенном объекте', () => {
|
||||
expect(() => assertContextHasNoSecrets({ outer: { inner: { Token: 'x' } } })).toThrow(/outer\.inner\.Token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-v2: маппинг webhook authentik', () => {
|
||||
it('policy_execution + passing=false → WeakPasswordRejected', () => {
|
||||
const record = mapAuthentikEvent({
|
||||
event_action: 'policy_execution',
|
||||
passing: false,
|
||||
event_user: 'ant',
|
||||
messages: ['Пароль слишком слабый. Попробуйте длиннее или с большей вариативностью'],
|
||||
created: '2026-06-10T00:00:00Z',
|
||||
});
|
||||
expect(record).toMatchObject({
|
||||
event: 'WeakPasswordRejected',
|
||||
subjectId: 'ant',
|
||||
result: 'failure',
|
||||
});
|
||||
expect(record!.context).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('passing=true → null (успешная проверка не аудируется как отказ)', () => {
|
||||
expect(mapAuthentikEvent({ event_action: 'policy_execution', passing: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('чужое событие → null', () => {
|
||||
expect(mapAuthentikEvent({ event_action: 'login', passing: false })).toBeNull();
|
||||
});
|
||||
|
||||
it('пустое тело → null без падения', () => {
|
||||
expect(mapAuthentikEvent({} as never)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ServiceUnavailableException, UnauthorizedException } from '@nestjs/common';
|
||||
import { SessionBindingController } from '~/application/auth-v2/session-binding/session-binding.controller';
|
||||
import type { IAuthnSessionPort } from '~/domain/auth-v2/ports/authn-session.port';
|
||||
import type { SessionBindingService } from '~/application/auth-v2/session-binding/session-binding.service';
|
||||
|
||||
function makeCtl(port: Partial<IAuthnSessionPort>, binding: Partial<SessionBindingService>) {
|
||||
return new SessionBindingController(port as IAuthnSessionPort, binding as SessionBindingService);
|
||||
}
|
||||
const req = (cookie?: string) => ({ headers: { cookie } }) as any;
|
||||
const res = () => {
|
||||
const calls: any[] = [];
|
||||
return { calls, cookie: (...a: any[]) => calls.push(a) } as any;
|
||||
};
|
||||
|
||||
describe('SessionBindingController.bind', () => {
|
||||
it('валидная сессия → выпускает токен и ставит HTTP-only secure cookie', async () => {
|
||||
const issued = { token: 'jwt.value', jti: 'j1', cookieName: 'coop_session_binding', maxAgeSec: 120 };
|
||||
const ctl = makeCtl(
|
||||
{ resolveUsername: jest.fn(async () => 'ant') },
|
||||
{ issue: jest.fn(async () => issued) as any },
|
||||
);
|
||||
const r = res();
|
||||
await ctl.bind(req('authentik_session=abc'), r);
|
||||
expect(r.calls).toHaveLength(1);
|
||||
const [name, token, opts] = r.calls[0];
|
||||
expect(name).toBe('coop_session_binding');
|
||||
expect(token).toBe('jwt.value');
|
||||
expect(opts).toMatchObject({ httpOnly: true, secure: true, sameSite: 'strict', maxAge: 120000, path: '/' });
|
||||
});
|
||||
|
||||
it('нет сессии → 401, cookie не ставится', async () => {
|
||||
const issue = jest.fn();
|
||||
const ctl = makeCtl({ resolveUsername: jest.fn(async () => null) }, { issue } as any);
|
||||
const r = res();
|
||||
await expect(ctl.bind(req(), r)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(r.calls).toHaveLength(0);
|
||||
expect(issue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authentik недоступен (throw порта) → 503', async () => {
|
||||
const ctl = makeCtl(
|
||||
{ resolveUsername: jest.fn(async () => { throw new Error('ECONNREFUSED'); }) },
|
||||
{ issue: jest.fn() } as any,
|
||||
);
|
||||
await expect(ctl.bind(req('authentik_session=abc'), res())).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { jwtVerify } from 'jose';
|
||||
import { SessionBindingService, SESSION_BINDING_TTL_SEC } from '~/application/auth-v2/session-binding/session-binding.service';
|
||||
import type { RedisPort } from '~/domain/common/ports/redis.port';
|
||||
|
||||
jest.mock('~/config/config', () => ({
|
||||
__esModule: true,
|
||||
default: { authV2: { sessionBindingSecret: 'test-binding-secret-min-32-bytes-len!!' } },
|
||||
}));
|
||||
|
||||
function makeRedisMock() {
|
||||
const store: Record<string, { value: string; ttl: number }> = {};
|
||||
const redis: RedisPort = {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
setSingleUse: jest.fn(async (key, value, ttl) => {
|
||||
if (store[key]) return false;
|
||||
store[key] = { value, ttl };
|
||||
return true;
|
||||
}),
|
||||
consumeSingleUse: jest.fn(async (key) => {
|
||||
const v = store[key]?.value ?? null;
|
||||
delete store[key];
|
||||
return v;
|
||||
}),
|
||||
};
|
||||
return { redis, store };
|
||||
}
|
||||
|
||||
describe('SessionBindingService.issue', () => {
|
||||
const secret = new TextEncoder().encode('test-binding-secret-min-32-bytes-len!!');
|
||||
|
||||
it('выпускает JWT HS256 с контрактными claims', async () => {
|
||||
const { redis } = makeRedisMock();
|
||||
const svc = new SessionBindingService(redis);
|
||||
const { token, jti } = await svc.issue('ant');
|
||||
|
||||
const { payload, protectedHeader } = await jwtVerify(token, secret);
|
||||
expect(protectedHeader.alg).toBe('HS256');
|
||||
expect(payload.sub).toBe('ant');
|
||||
expect(payload.jti).toBe(jti);
|
||||
expect(payload.stage_completed).toBe('password');
|
||||
expect(payload.exp! - payload.iat!).toBe(SESSION_BINDING_TTL_SEC);
|
||||
});
|
||||
|
||||
it('пишет jti→sub в Redis с TTL чуть больше exp', async () => {
|
||||
const { redis, store } = makeRedisMock();
|
||||
const svc = new SessionBindingService(redis);
|
||||
const { jti } = await svc.issue('petrov');
|
||||
expect(store[`coopid:binding:${jti}`].value).toBe('petrov');
|
||||
expect(store[`coopid:binding:${jti}`].ttl).toBeGreaterThan(SESSION_BINDING_TTL_SEC);
|
||||
});
|
||||
|
||||
it('каждый вызов — новый jti', async () => {
|
||||
const { redis } = makeRedisMock();
|
||||
const svc = new SessionBindingService(redis);
|
||||
const a = await svc.issue('x');
|
||||
const b = await svc.issue('x');
|
||||
expect(a.jti).not.toBe(b.jti);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { VaultService } from '~/application/auth-v2/vault/vault.service';
|
||||
import { VaultServerDecryptionForbiddenError } from '~/domain/auth-v2/errors/auth-v2.error';
|
||||
import type { IVaultRepository } from '~/domain/auth-v2/vault/vault-repository.port';
|
||||
import type { EncryptedVaultBlob } from '~/domain/auth-v2/vault/vault.types';
|
||||
|
||||
const BLOB: EncryptedVaultBlob = {
|
||||
cipher_version: 'aes-256-gcm-v1',
|
||||
kdf_version: 'argon2id-v1',
|
||||
salt: 'c2FsdA',
|
||||
nonce: 'bm9uY2U',
|
||||
ciphertext: 'Y2lwaGVy',
|
||||
auth_tag: 'dGFn',
|
||||
};
|
||||
|
||||
function makeRepo() {
|
||||
const store = new Map<string, EncryptedVaultBlob>();
|
||||
const repo: IVaultRepository = {
|
||||
upsert: jest.fn(async (s, b) => void store.set(`${s.subject_type}:${s.subject_id}`, b)),
|
||||
find: jest.fn(async (s) => store.get(`${s.subject_type}:${s.subject_id}`) ?? null),
|
||||
};
|
||||
return { repo, store };
|
||||
}
|
||||
|
||||
describe('VaultService', () => {
|
||||
it('store → repo.upsert по субъекту', async () => {
|
||||
const { repo, store } = makeRepo();
|
||||
const svc = new VaultService(repo);
|
||||
await svc.store({ subject_type: 'participant', subject_id: 'ant' }, BLOB);
|
||||
expect(store.get('participant:ant')).toEqual(BLOB);
|
||||
});
|
||||
|
||||
it('retrieve возвращает зашифрованный blob, не расшифровывает', async () => {
|
||||
const { repo } = makeRepo();
|
||||
const svc = new VaultService(repo);
|
||||
await svc.store({ subject_type: 'participant', subject_id: 'ant' }, BLOB);
|
||||
const got = await svc.retrieve({ subject_type: 'participant', subject_id: 'ant' });
|
||||
expect(got).toEqual(BLOB);
|
||||
});
|
||||
|
||||
it('retrieve несуществующего → null', async () => {
|
||||
const { repo } = makeRepo();
|
||||
expect(await new VaultService(repo).retrieve({ subject_type: 'participant', subject_id: 'nobody' })).toBeNull();
|
||||
});
|
||||
|
||||
it('серверная расшифровка участника запрещена в рантайме', () => {
|
||||
const { repo } = makeRepo();
|
||||
expect(() => new VaultService(repo).assertNoServerDecrypt()).toThrow(VaultServerDecryptionForbiddenError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { sendGET } from 'src/shared/api/axios';
|
||||
|
||||
export interface CoopChainLink {
|
||||
account: string;
|
||||
public_key: string;
|
||||
}
|
||||
|
||||
/** Claims удостоверения пайщика (payload participant_certificate, Story 1.8). */
|
||||
export interface ParticipantCertificate {
|
||||
jti: string;
|
||||
sub: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
coopname: string;
|
||||
coop_chain: CoopChainLink[];
|
||||
verification_types: string[];
|
||||
identification: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальный декод payload JWS для ОТОБРАЖЕНИЯ в ЛК (подпись здесь не проверяется —
|
||||
* это делает offline-верификация, Story 4.4). Каноническое чтение claims —
|
||||
* `@coopenomics/auth.decodeParticipantCertificate`; используем его, когда desktop
|
||||
* подключит auth-SDK (Эпик 7). UTF-8-декод обязателен — в identification кириллица.
|
||||
*/
|
||||
function decodePayload(jws: string): ParticipantCertificate {
|
||||
const segment = jws.split('.')[1];
|
||||
if (!segment) throw new Error('Некорректный формат удостоверения');
|
||||
const b64 = segment.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const bin = atob(b64);
|
||||
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
const raw = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;
|
||||
return {
|
||||
jti: String(raw.jti ?? ''),
|
||||
sub: String(raw.sub ?? ''),
|
||||
iat: Number(raw.iat ?? 0),
|
||||
exp: Number(raw.exp ?? 0),
|
||||
coopname: String(raw.coopname ?? ''),
|
||||
coop_chain: Array.isArray(raw.coop_chain) ? (raw.coop_chain as CoopChainLink[]) : [],
|
||||
verification_types: Array.isArray(raw.verification_types) ? (raw.verification_types as string[]) : [],
|
||||
identification: (raw.identification as Record<string, unknown> | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Запросить и декодировать актуальное удостоверение пайщика (GET /coop/certificate). */
|
||||
export async function fetchParticipantCertificate(): Promise<ParticipantCertificate | null> {
|
||||
const res = (await sendGET('/coop/certificate')) as { participant_certificate?: string };
|
||||
if (!res?.participant_certificate) return null;
|
||||
return decodePayload(res.participant_certificate);
|
||||
}
|
||||
@@ -3,6 +3,46 @@
|
||||
//- Шапка-удостоверение: ФИО/наименование пайщика + роль в кооперативе.
|
||||
IdentityPanel(:identity='identity')
|
||||
|
||||
//- Криптографическое удостоверение пайщика (CoopID): серийник, срок,
|
||||
//- статус, цепочка подписей и собранные подтверждения.
|
||||
BaseCard(title='Удостоверение пайщика')
|
||||
template(v-if='certificate')
|
||||
.cert__head
|
||||
BaseChip(:variant='certStatus.variant') {{ certStatus.label }}
|
||||
DataRow(label='Серийный номер', :value='certificate.jti', copyable, mono)
|
||||
DataRow(label='Действует до', :value='formatExp(certificate.exp)')
|
||||
|
||||
.cert__block
|
||||
.cert__block-label Цепочка подписей
|
||||
.cert__chain
|
||||
template(v-for='(step, i) in chainSteps', :key='i')
|
||||
BaseChip(:variant='step.variant') {{ step.label }}
|
||||
q-icon.cert__chain-arrow(
|
||||
v-if='i < chainSteps.length - 1',
|
||||
name='arrow_forward',
|
||||
size='16px'
|
||||
)
|
||||
|
||||
.cert__block(v-if='verificationLabels.length')
|
||||
.cert__block-label Подтверждения
|
||||
.cert__verifications
|
||||
BaseChip(
|
||||
v-for='(label, i) in verificationLabels',
|
||||
:key='i',
|
||||
variant='info'
|
||||
) {{ label }}
|
||||
|
||||
.cert__actions
|
||||
BaseButton(variant='secondary', @click='onDownloadQr')
|
||||
template(#icon-left)
|
||||
q-icon(name='qr_code', size='18px')
|
||||
| Скачать как QR
|
||||
EmptyState(
|
||||
v-else,
|
||||
title='Удостоверение недоступно',
|
||||
body='Войдите в кооператив, чтобы получить криптографическое удостоверение пайщика.'
|
||||
)
|
||||
|
||||
//- Учётная запись: имя аккаунта и публичный ключ — копируемые,
|
||||
//- моноширинные (это технические идентификаторы блокчейн-аккаунта).
|
||||
BaseCard(title='Учётная запись')
|
||||
@@ -117,16 +157,86 @@ import type {
|
||||
IIndividualData,
|
||||
IOrganizationData,
|
||||
} from 'src/shared/lib/types/user/IUserData';
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Notify } from 'quasar';
|
||||
import { useDisplayName } from 'src/shared/lib/composables/useDisplayName';
|
||||
import { IdentityPanel } from 'src/shared/ui/domain/IdentityPanel';
|
||||
import type { Identity } from 'src/shared/ui/domain/IdentityPanel';
|
||||
import { DataRow } from 'src/shared/ui/domain/DataRow';
|
||||
import { BaseCard } from 'src/shared/ui/base/BaseCard';
|
||||
import { BaseChip } from 'src/shared/ui/base/BaseChip';
|
||||
import type { BaseChipVariant } from 'src/shared/ui/base/BaseChip';
|
||||
import { BaseButton } from 'src/shared/ui/base/BaseButton';
|
||||
import { EmptyState } from 'src/shared/ui/base/EmptyState';
|
||||
import { fetchParticipantCertificate } from '../api';
|
||||
import type { ParticipantCertificate } from '../api';
|
||||
|
||||
const session = useSessionStore();
|
||||
|
||||
// ── Криптографическое удостоверение пайщика (CoopID, Story 1.9) ──
|
||||
const certificate = ref<ParticipantCertificate | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
// Best-effort: отсутствие удостоверения (старый контур входа / сбой) не должно
|
||||
// ломать страницу профиля — карточка просто покажет EmptyState.
|
||||
try {
|
||||
certificate.value = await fetchParticipantCertificate();
|
||||
} catch {
|
||||
certificate.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Статус по сроку действия (зеркало @coopenomics/auth.certificateStatus).
|
||||
const EXPIRING_WINDOW_MS = 60 * 60 * 1000;
|
||||
const certStatus = computed<{ label: string; variant: BaseChipVariant }>(() => {
|
||||
const exp = (certificate.value?.exp ?? 0) * 1000;
|
||||
const now = Date.now();
|
||||
if (!exp || now >= exp) return { label: 'Истекло', variant: 'neg' };
|
||||
if (exp - now <= EXPIRING_WINDOW_MS) return { label: 'Истекает', variant: 'warn' };
|
||||
return { label: 'Активно', variant: 'pos' };
|
||||
});
|
||||
|
||||
// Человекочитаемые имена звеньев цепи подписей + сам пайщик в конце.
|
||||
const CHAIN_LABELS: Record<string, string> = {
|
||||
ano: 'АНО',
|
||||
voskhod: 'Восход',
|
||||
vostok: 'Восток',
|
||||
};
|
||||
const chainSteps = computed<{ label: string; variant: BaseChipVariant }[]>(() => {
|
||||
const links = (certificate.value?.coop_chain ?? []).map((l) => ({
|
||||
label: CHAIN_LABELS[l.account] ?? l.account,
|
||||
variant: 'neutral' as BaseChipVariant,
|
||||
}));
|
||||
return [...links, { label: 'Вы', variant: 'accent' as BaseChipVariant }];
|
||||
});
|
||||
|
||||
// Описания типов верификации (зеркало @coopenomics/auth.verificationTypeLabel).
|
||||
const VERIFICATION_LABELS: Record<string, string> = {
|
||||
coop_baseline: 'Базовое подтверждение кооперативом',
|
||||
};
|
||||
const verificationLabels = computed(() =>
|
||||
(certificate.value?.verification_types ?? []).map((t) => VERIFICATION_LABELS[t] ?? t),
|
||||
);
|
||||
|
||||
const formatExp = (exp: number): string => {
|
||||
if (!exp) return '';
|
||||
return new Date(exp * 1000).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Экспорт в QR — функция Vision (SDK exportToQR пока stub); сообщаем мягко.
|
||||
const onDownloadQr = () => {
|
||||
Notify.create({
|
||||
type: 'info',
|
||||
message: 'Экспорт удостоверения в QR станет доступен в составе приложения Vision.',
|
||||
});
|
||||
};
|
||||
|
||||
const userType = computed(() => {
|
||||
return session.privateAccount?.type;
|
||||
});
|
||||
@@ -265,6 +375,36 @@ const getRepresentativeName = (representative: any) => {
|
||||
padding: var(--p-6, 24px);
|
||||
}
|
||||
|
||||
.cert__head {
|
||||
margin-bottom: var(--p-3, 12px);
|
||||
}
|
||||
|
||||
.cert__block {
|
||||
margin-top: var(--p-4, 16px);
|
||||
}
|
||||
|
||||
.cert__block-label {
|
||||
margin-bottom: var(--p-2, 8px);
|
||||
color: var(--p-ink-3);
|
||||
font-size: var(--p-fs-sm, 13px);
|
||||
}
|
||||
|
||||
.cert__chain,
|
||||
.cert__verifications {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--p-2, 8px);
|
||||
}
|
||||
|
||||
.cert__chain-arrow {
|
||||
color: var(--p-ink-3);
|
||||
}
|
||||
|
||||
.cert__actions {
|
||||
margin-top: var(--p-4, 16px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
padding: var(--p-4, 16px);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"local": "049_migrate_phase2_progwallets.ts",
|
||||
"local": "052_coopid_cert_permissions.ts",
|
||||
"test": "051_edit_selected_draft.ts",
|
||||
"prod": "051_edit_selected_draft.ts"
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* CoopID Story 1.3: permission `cert` (ES256K/K1, parent `active`) на аккаунтах
|
||||
* цепочки доверия `ano → voskhod → vostok`. Идемпотентна: существующий аккаунт —
|
||||
* skip; существующий `cert` с тем же ключом — skip, с другим — updateauth.
|
||||
*
|
||||
* Первая миграция на WharfKit (архитектура CoopID переводит блокчейн-IO на
|
||||
* WharfKit; eosjs-хелпер ../src/eos остаётся для старых миграций).
|
||||
*
|
||||
* Ключи `cert` резолвятся в порядке:
|
||||
* 1. env `<ACCOUNT>_CERT_PUBLIC_KEY` (обязательно для testnet/mainnet);
|
||||
* 2. для `vostok` — деривация из PEM `infra/coopid/secrets/coop_cert_key`
|
||||
* (цепочка на chain обязана совпасть с ключом подписи controller'а);
|
||||
* 3. дев-fallback — стандартный ключ дев-сети (только NODE_ENV=local).
|
||||
*/
|
||||
import { createPrivateKey } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { APIClient, Bytes, KeyType, PrivateKey, PublicKey } from '@wharfkit/antelope'
|
||||
import { Session } from '@wharfkit/session'
|
||||
import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey'
|
||||
import type { Migration } from '../src/migration_interface'
|
||||
import { getDirname } from '../src/utils/dirname'
|
||||
|
||||
const CERT_ACCOUNTS = ['ano', 'voskhod', 'vostok'] as const
|
||||
const DEV_DEFAULT_PUBLIC_KEY = 'EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV'
|
||||
|
||||
function pemToPublicKey(pemPath: string): string {
|
||||
const pem = readFileSync(pemPath, 'utf-8')
|
||||
const jwk = createPrivateKey(pem).export({ format: 'jwk' }) as { crv?: string, d?: string }
|
||||
if (jwk.crv !== 'secp256k1' || !jwk.d)
|
||||
throw new Error(`${pemPath}: ожидался приватный ключ secp256k1 (ES256K), получено crv=${jwk.crv}`)
|
||||
// JWK может опускать ведущие нули скаляра — выравниваем до 32 байт,
|
||||
// иначе получится валидный по форме, но ЧУЖОЙ публичный ключ.
|
||||
const d = Buffer.from(jwk.d, 'base64url')
|
||||
if (d.length > 32)
|
||||
throw new Error(`${pemPath}: скаляр d длиннее 32 байт (${d.length})`)
|
||||
const raw = Buffer.concat([Buffer.alloc(32 - d.length), d])
|
||||
return new PrivateKey(KeyType.K1, Bytes.from(raw)).toPublic().toString()
|
||||
}
|
||||
|
||||
function resolveCertKey(account: string, repoRoot: string): string {
|
||||
const fromEnv = process.env[`${account.toUpperCase()}_CERT_PUBLIC_KEY`]
|
||||
if (fromEnv)
|
||||
return fromEnv
|
||||
|
||||
if (account === 'vostok') {
|
||||
const pemPath = path.join(repoRoot, 'infra/coopid/secrets/coop_cert_key')
|
||||
if (existsSync(pemPath))
|
||||
return pemToPublicKey(pemPath)
|
||||
}
|
||||
|
||||
const env = process.env.NODE_ENV || 'local'
|
||||
if (env !== 'local') {
|
||||
throw new Error(
|
||||
`Ключ cert для ${account} не задан: для окружения ${env} обязателен env ${account.toUpperCase()}_CERT_PUBLIC_KEY `
|
||||
+ `(для vostok — либо PEM infra/coopid/secrets/coop_cert_key). Дев-fallback разрешён только в local.`,
|
||||
)
|
||||
}
|
||||
return DEV_DEFAULT_PUBLIC_KEY
|
||||
}
|
||||
|
||||
export class InitialMigration implements Migration {
|
||||
async run(): Promise<void> {
|
||||
const endpoint = process.env.EOSIO_ENDPOINT
|
||||
const privateKey = process.env.EOSIO_PRIVATE_KEY
|
||||
if (!endpoint || !privateKey)
|
||||
throw new Error('EOSIO_ENDPOINT и EOSIO_PRIVATE_KEY обязательны (см. local.env)')
|
||||
|
||||
const repoRoot = path.join(getDirname(import.meta.url), '../../..')
|
||||
// Ключи резолвим ДО любых транзакций — fail-fast при отсутствии PEM/env.
|
||||
const certKeys = Object.fromEntries(
|
||||
CERT_ACCOUNTS.map(account => [account, resolveCertKey(account, repoRoot)]),
|
||||
)
|
||||
|
||||
const client = new APIClient({ url: endpoint })
|
||||
const info = await client.v1.chain.get_info()
|
||||
const chain = { id: String(info.chain_id), url: endpoint }
|
||||
const walletPlugin = new WalletPluginPrivateKey(privateKey)
|
||||
const sessionFor = (actor: string, permission = 'active') =>
|
||||
new Session({ chain, actor, permission, walletPlugin })
|
||||
|
||||
for (const account of CERT_ACCOUNTS) {
|
||||
const exists = await client.v1.chain
|
||||
.get_account(account)
|
||||
.then(() => true, () => false)
|
||||
|
||||
if (!exists) {
|
||||
// Создание аккаунтов — ТОЛЬКО dev: на testnet/mainnet аккаунты заводит
|
||||
// registrator (реестр, referer, eosio.prods), а owner/active на
|
||||
// общеизвестном дев-ключе означали бы немедленный захват аккаунта.
|
||||
if ((process.env.NODE_ENV || 'local') !== 'local')
|
||||
throw new Error(`Аккаунт ${account} не существует: вне local-сети миграция аккаунты не создаёт — заведите через registrator и повторите.`)
|
||||
await sessionFor('eosio').transact({
|
||||
actions: [{
|
||||
account: 'eosio',
|
||||
name: 'newaccount',
|
||||
authorization: [{ actor: 'eosio', permission: 'active' }],
|
||||
data: {
|
||||
creator: 'eosio',
|
||||
name: account,
|
||||
owner: { threshold: 1, keys: [{ key: DEV_DEFAULT_PUBLIC_KEY, weight: 1 }], accounts: [], waits: [] },
|
||||
active: { threshold: 1, keys: [{ key: DEV_DEFAULT_PUBLIC_KEY, weight: 1 }], accounts: [], waits: [] },
|
||||
},
|
||||
}],
|
||||
})
|
||||
console.log(`[052] создан аккаунт ${account} (дев-ключи)`)
|
||||
}
|
||||
|
||||
const acc = await client.v1.chain.get_account(account)
|
||||
const certPerm = acc.permissions.find(p => String(p.perm_name) === 'cert')
|
||||
const desiredKey = certKeys[account]
|
||||
const currentKey = certPerm?.required_auth.keys[0]?.key?.toString()
|
||||
// Skip только при ТОЧНОМ совпадении всей структуры single-key auth:
|
||||
// дрейф (threshold>1, второй ключ, accounts/waits) чиним updateauth'ом.
|
||||
const isCleanSingleKey = certPerm
|
||||
&& Number(certPerm.required_auth.threshold) === 1
|
||||
&& certPerm.required_auth.keys.length === 1
|
||||
&& certPerm.required_auth.accounts.length === 0
|
||||
&& certPerm.required_auth.waits.length === 0
|
||||
|
||||
if (isCleanSingleKey && currentKey && samePublicKey(currentKey, desiredKey)) {
|
||||
console.log(`[052] ${account}@cert уже на месте (${desiredKey}) — skip`)
|
||||
continue
|
||||
}
|
||||
|
||||
await sessionFor(account).transact({
|
||||
actions: [{
|
||||
account: 'eosio',
|
||||
name: 'updateauth',
|
||||
authorization: [{ actor: account, permission: 'active' }],
|
||||
data: {
|
||||
account,
|
||||
permission: 'cert',
|
||||
parent: 'active',
|
||||
auth: { threshold: 1, keys: [{ key: desiredKey, weight: 1 }], accounts: [], waits: [] },
|
||||
},
|
||||
}],
|
||||
})
|
||||
console.log(
|
||||
certPerm
|
||||
? `[052] ${account}@cert обновлён: ${currentKey} → ${desiredKey}`
|
||||
: `[052] ${account}@cert создан (parent active): ${desiredKey}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сравнение ключей с учётом legacy (EOS...) и нового (PUB_K1_...) форматов. */
|
||||
function samePublicKey(a: string, b: string): boolean {
|
||||
try {
|
||||
return PublicKey.from(a).equals(PublicKey.from(b))
|
||||
}
|
||||
catch {
|
||||
return a === b
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@wharfkit/antelope": "^1.1.1",
|
||||
"@wharfkit/session": "^1.4.0",
|
||||
"@wharfkit/wallet-plugin-privatekey": "^1.1.0",
|
||||
"cooptypes": "workspace:*",
|
||||
"dotenv": "^16.4.5",
|
||||
"eosjs": "^22.1.0",
|
||||
|
||||
@@ -46,8 +46,23 @@ services:
|
||||
POSTGRES_DB: voskhod
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
# init-скрипт CoopID выполняется ТОЛЬКО при пустом data dir: на свежей
|
||||
# машине создаёт authentik_db + coop_domain_db с ролевой изоляцией. На уже
|
||||
# инициализированном томе БД заводятся вручную (см. infra/coopid/README.md).
|
||||
- ./infra/coopid/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
secrets:
|
||||
# пароли прикладных ролей CoopID — init-скрипт читает их из /run/secrets.
|
||||
- coop_pg_authentik_password
|
||||
- coop_pg_app_password
|
||||
ports:
|
||||
- "127.0.0.1:${PG_HOST_PORT:-5532}:5432"
|
||||
healthcheck:
|
||||
# authentik стартует только после готовности обеих БД CoopID; pg_isready
|
||||
# их бы не проверил и замаскировал недоинициализированный том.
|
||||
test: ["CMD-SHELL", "psql -U postgres -d authentik_db -c 'select 1' >/dev/null 2>&1 && psql -U postgres -d coop_domain_db -c 'select 1' >/dev/null 2>&1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
coopback:
|
||||
build:
|
||||
@@ -69,6 +84,14 @@ services:
|
||||
- "0.0.0.0:${COOPBACK_HOST_PORT:-2998}:2998"
|
||||
working_dir: /app
|
||||
command: pnpm --filter @coopenomics/controller run dev
|
||||
# CoopID: ключ подписи participant_certificate (Stories 1.7–1.8), vault-ключ
|
||||
# кооператива, пароль роли coop_domain_db, токены вебхука и session_binding.
|
||||
secrets:
|
||||
- coop_cert_key
|
||||
- coop_vault_key
|
||||
- coop_pg_app_password
|
||||
- authentik_webhook_token
|
||||
- auth_v2_session_binding_secret
|
||||
|
||||
cooparser:
|
||||
build:
|
||||
@@ -124,7 +147,123 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# ── CoopID: per-coop OIDC (authentik) ──────────────────────────────────────
|
||||
# Данные authentik живут отдельной БД authentik_db в общем сервисе postgres
|
||||
# (роль authentik_user, ролевая изоляция от coop_domain_db). Redis — общий
|
||||
# monoredis, DB-индекс 1 (controller сидит в индексе 0).
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:2026.2
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment: &authentik-env
|
||||
AUTHENTIK_SECRET_KEY: file:///run/secrets/authentik_secret_key
|
||||
AUTHENTIK_POSTGRESQL__HOST: postgres
|
||||
AUTHENTIK_POSTGRESQL__PORT: 5432
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik_db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik_user
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: file:///run/secrets/coop_pg_authentik_password
|
||||
AUTHENTIK_REDIS__HOST: monoredis
|
||||
AUTHENTIK_REDIS__DB: ${AUTHENTIK_REDIS_DB:-1}
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: file:///run/secrets/authentik_bootstrap_password
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: file:///run/secrets/authentik_bootstrap_token
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
|
||||
# Подставляется blueprint'ом coopid-password-policy в headers-mapping вебхука.
|
||||
COOPID_WEBHOOK_TOKEN: ${COOPID_WEBHOOK_TOKEN:-}
|
||||
volumes: &authentik-volumes
|
||||
- ./infra/coopid/authentik/blueprints:/blueprints/custom:ro
|
||||
secrets: &authentik-secrets
|
||||
- authentik_secret_key
|
||||
- authentik_bootstrap_password
|
||||
- authentik_bootstrap_token
|
||||
- coop_pg_authentik_password
|
||||
ports:
|
||||
- "127.0.0.1:${AUTHENTIK_HOST_PORT:-9008}:9000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
monoredis:
|
||||
condition: service_started
|
||||
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:2026.2
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment: *authentik-env
|
||||
volumes: *authentik-volumes
|
||||
secrets: *authentik-secrets
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
monoredis:
|
||||
condition: service_started
|
||||
|
||||
# ── CoopID edge: внешний TLS 1.3 (caddy) + разбор access-логов (crowdsec) ───
|
||||
caddy:
|
||||
image: caddy:2.11
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# localhost → self-signed (внутренний CA caddy); реальный домен → Let's Encrypt.
|
||||
COOPID_DOMAIN: ${COOPID_DOMAIN:-localhost}
|
||||
ports:
|
||||
# Прод с реальным доменом: CADDY_BIND=0.0.0.0, порты 80/443 — иначе ACME
|
||||
# HTTP-01 challenge недостижим снаружи и Let's Encrypt не выдаст сертификат.
|
||||
- "${CADDY_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8088}:80"
|
||||
- "${CADDY_BIND:-127.0.0.1}:${CADDY_HTTPS_PORT:-8443}:443"
|
||||
volumes:
|
||||
- ./infra/coopid/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
- caddy_logs:/var/log/caddy
|
||||
depends_on:
|
||||
- authentik-server
|
||||
- coopback
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
COLLECTIONS: "crowdsecurity/caddy crowdsecurity/http-cve"
|
||||
volumes:
|
||||
- ./infra/coopid/crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml:ro
|
||||
- crowdsec_db:/var/lib/crowdsec/data
|
||||
- crowdsec_config:/etc/crowdsec
|
||||
- caddy_logs:/var/log/caddy:ro
|
||||
depends_on:
|
||||
- caddy
|
||||
|
||||
# Все секреты CoopID — file-based из infra/coopid/secrets/ (каталог в .gitignore).
|
||||
# ⚠️ Файлов нет → `docker compose up -d` ПАДАЕТ на валидации ("secret not found")
|
||||
# и не поднимает НИЧЕГО, включая базовый стек. Перед первым запуском на любом
|
||||
# чекауте/ноде ОБЯЗАТЕЛЬНО: bash scripts/coopid-gen-secrets.sh.
|
||||
# На prod секреты раскладывает плейбук ~/playbooks. Подробности: infra/coopid/README.md
|
||||
secrets:
|
||||
coop_pg_authentik_password:
|
||||
file: ./infra/coopid/secrets/coop_pg_authentik_password
|
||||
coop_pg_app_password:
|
||||
file: ./infra/coopid/secrets/coop_pg_app_password
|
||||
authentik_secret_key:
|
||||
file: ./infra/coopid/secrets/authentik_secret_key
|
||||
authentik_bootstrap_password:
|
||||
file: ./infra/coopid/secrets/authentik_bootstrap_password
|
||||
authentik_bootstrap_token:
|
||||
file: ./infra/coopid/secrets/authentik_bootstrap_token
|
||||
authentik_webhook_token:
|
||||
file: ./infra/coopid/secrets/authentik_webhook_token
|
||||
auth_v2_session_binding_secret:
|
||||
file: ./infra/coopid/secrets/auth_v2_session_binding_secret
|
||||
coop_cert_key:
|
||||
file: ./infra/coopid/secrets/coop_cert_key
|
||||
coop_vault_key:
|
||||
file: ./infra/coopid/secrets/coop_vault_key
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
caddy_logs:
|
||||
crowdsec_db:
|
||||
crowdsec_config:
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# CoopID — Disaster Recovery Runbook
|
||||
|
||||
Восстановление кооператива из ежедневного S3-бэкапа (Story 9.8) после потери
|
||||
сервера. Цель — **RTO 1–2 часа** до прохождения smoke-теста.
|
||||
|
||||
> **Формат бэкапа.** `coopid-backup.sh` снимает **физический** `pg_basebackup`
|
||||
> всего PG-кластера (обе БД сразу) в `s3://{bucket}/{coopname}/{YYYY-MM-DD}.tar.gz`.
|
||||
> Это НЕ логический `pg_dump` → восстановление выполняется **распаковкой архива в
|
||||
> каталог данных PostgreSQL (PGDATA)**, а не командой `pg_restore`.
|
||||
|
||||
> Этот runbook — базовый сценарий восстановления PG. Сценарные runbook'и
|
||||
> DR-01..DR-04 (Story 9.10) ссылаются на него.
|
||||
|
||||
## 0. Предпосылки
|
||||
|
||||
- Доступ к S3-бакету `coopid-backups` (ключи `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`).
|
||||
- Известны: `COOPNAME` и дата бэкапа (`YYYY-MM-DD`), который восстанавливаем.
|
||||
- **Секреты кооператива** на момент снятия бэкапа: каталог `infra/coopid/secrets/`
|
||||
(особенно `coop_pg_app_password`, `coop_pg_authentik_password`) — их пароли ДОЛЖНЫ
|
||||
совпадать с тем, что в бэкапе (см. §6, грабля №1). Храните их вместе с бэкапами
|
||||
или будьте готовы к `ALTER ROLE`.
|
||||
|
||||
## 1. Новый сервер с Docker
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian; на ноде нужны docker + docker compose v2 и aws CLI.
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
git clone <repo> mono && cd mono # тот же репозиторий
|
||||
# разложить секреты (ТЕ ЖЕ, что при снятии бэкапа — см. §6 грабля №1)
|
||||
bash scripts/coopid-gen-secrets.sh # ТОЛЬКО если секреты потеряны (см. §6)
|
||||
```
|
||||
|
||||
## 2. Скачать backup из S3
|
||||
|
||||
```bash
|
||||
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
|
||||
COOPNAME=voskhod ; DATE=2026-06-10
|
||||
aws s3 cp "s3://coopid-backups/${COOPNAME}/${DATE}.tar.gz" ./backup.tar.gz
|
||||
# проверить, что архив не пустой и валиден:
|
||||
gzip -t backup.tar.gz && tar -tzf backup.tar.gz | head # должен показать PG_VERSION, base/, global/, pg_wal/
|
||||
```
|
||||
|
||||
## 3. Восстановить в новый PG-том (распаковка в PGDATA)
|
||||
|
||||
postgres:18: `PGDATA=/var/lib/postgresql/18/docker`, том примонтирован на родитель
|
||||
`/var/lib/postgresql`. Имя тома — `{project}_postgres_data` (project = имя каталога
|
||||
репозитория, напр. `mono_postgres_data`). Стек должен быть остановлен.
|
||||
|
||||
```bash
|
||||
PROJECT=mono # docker compose project name
|
||||
docker compose down # если что-то поднято
|
||||
|
||||
# чистый том
|
||||
docker volume rm ${PROJECT}_postgres_data 2>/dev/null || true
|
||||
docker volume create ${PROJECT}_postgres_data
|
||||
|
||||
# распаковать содержимое архива в PGDATA внутри тома
|
||||
docker run --rm -v ${PROJECT}_postgres_data:/v -v "$PWD":/b alpine sh -c '
|
||||
mkdir -p /v/18/docker &&
|
||||
tar -xzf /b/backup.tar.gz -C /v/18/docker &&
|
||||
chown -R 999:999 /v/18/docker' # postgres в образе работает под uid 999
|
||||
```
|
||||
|
||||
WAL включён в базовый бэкап (`pg_basebackup -X fetch`), поэтому при старте PG сам
|
||||
выполнит crash-recovery и поднимется на момент бэкапа. PITR (точка во времени между
|
||||
бэкапами) в MVP не предусмотрен — восстановление до состояния выбранного дня.
|
||||
|
||||
## 4. Поднять стек
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose ps # postgres healthy → authentik healthy → coopback/caddy
|
||||
docker compose logs -f postgres | grep -m1 "database system is ready to accept connections"
|
||||
```
|
||||
|
||||
## 5. Smoke-test (вход → сертификат → цепочка)
|
||||
|
||||
Проверяем сквозной контур CoopID одним пайщиком.
|
||||
|
||||
```bash
|
||||
BASE=https://localhost:8443 # caddy (или прод-домен); SNI обязателен для localhost
|
||||
COOP=voskhod
|
||||
|
||||
# (a) Вход: пароль на authentik → session_binding_token.
|
||||
# Проще всего — через SDK @coopenomics/auth (login(email,password)),
|
||||
# ниже — эквивалент curl поверх endpoint'ов Эпика 1:
|
||||
# 1) получить сессию authentik (UI-флоу или /api/v3/flows/...), сохранить cookie;
|
||||
# 2) обменять на binding-cookie:
|
||||
curl -sk "$BASE/coop/session/bind" -X POST -b authentik_cookies.txt -c binding.txt -w '%{http_code}\n'
|
||||
# ожидаемо 204 + cookie coop_session_binding
|
||||
|
||||
# (b) Второй этап: подпись метки времени ключом пайщика (SDK signTimestamp) →
|
||||
curl -sk "$BASE/coop/verify/timestamp" -X POST -b binding.txt \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"signature":"SIG_K1_...","timestamp":"<ISO>","binding_token":"<jwt>"}'
|
||||
# ожидаемо 200 + {access_token, refresh_token, participant_certificate?}
|
||||
|
||||
# (c) Выпуск/получение сертификата:
|
||||
curl -sk "$BASE/coop/certificate" -H "Authorization: Bearer <access_token>"
|
||||
# ожидаемо 200 + {participant_certificate: "<compact JWS>"}
|
||||
|
||||
# (d) Проверка цепочки coop_chain в сертификате:
|
||||
echo "<participant_certificate>" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.coop_chain'
|
||||
# ожидаемо массив [ano, voskhod, vostok]; coopname совпадает; iat/exp валидны
|
||||
```
|
||||
|
||||
**Критерий успеха smoke-теста:** пайщик вошёл, сертификат выпущен, `coop_chain`
|
||||
содержит полную цепочку доверия. Если все три шага зелёные — кооператив восстановлен.
|
||||
|
||||
## 6. Troubleshooting (частые грабли)
|
||||
|
||||
**№1. authentik/coopback не подключаются к БД (`password authentication failed`).**
|
||||
Главная грабля. Пароли ролей `authentik_user`/`coop_app_user` хранятся ВНУТРИ бэкапа
|
||||
(`pg_authid`). Init-скрипт `infra/coopid/postgres/init/01-init.sh` выполняется ТОЛЬКО
|
||||
на пустом томе — на восстановленном он НЕ запускается, роли уже есть из бэкапа.
|
||||
→ Либо разложить в `infra/coopid/secrets/` ТЕ ЖЕ пароли, что были при снятии бэкапа;
|
||||
либо привести пароли ролей под текущие секреты:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres psql -U postgres -v ON_ERROR_STOP=1 \
|
||||
-v apw="$(cat infra/coopid/secrets/coop_pg_authentik_password)" \
|
||||
-v cpw="$(cat infra/coopid/secrets/coop_pg_app_password)" <<'SQL'
|
||||
ALTER ROLE authentik_user PASSWORD :'apw';
|
||||
ALTER ROLE coop_app_user PASSWORD :'cpw';
|
||||
SQL
|
||||
```
|
||||
|
||||
**№2. PG не стартует, `data directory ... has wrong ownership`.** Файлы PGDATA должны
|
||||
принадлежать uid 999 (postgres в образе). Повторите `chown -R 999:999 /v/18/docker`
|
||||
(см. §3).
|
||||
|
||||
**№3. PG не стартует, несовпадение мажорной версии (`incompatible ... PG_VERSION`).**
|
||||
Физический бэкап восстановим ТОЛЬКО на ту же мажорную версию PostgreSQL (18).
|
||||
Используйте образ `postgres:18`. Кросс-версионно — только логический dump (вне MVP).
|
||||
|
||||
**№4. `gzip -t` падает / `tar` обрывается.** Архив битый или скачан не полностью —
|
||||
возьмите соседний день (retention 30 дней) и повторите §2.
|
||||
|
||||
**№5. S3: AccessDenied / NoSuchKey.** Проверьте ключи, бакет, регион и что объект
|
||||
ещё не истёк по lifecycle (хранятся 30 дней). При SSE-S3 на чтение доп-действий не нужно.
|
||||
|
||||
**№6. caddy «TLS internal error» на localhost.** Запрос без SNI — добавьте
|
||||
`--resolve localhost:8443:127.0.0.1` (см. `infra/coopid/README.md`).
|
||||
|
||||
## RTO
|
||||
|
||||
Ожидаемое время восстановления — **1–2 часа**: ~15 мин подготовка сервера, ~10–40 мин
|
||||
скачивание+распаковка (зависит от размера БД и канала), ~10 мин старт стека и миграций,
|
||||
~15 мин smoke-test и устранение граблей §6. Цель RTO для MVP — **≤ 2 часов**.
|
||||
@@ -0,0 +1,154 @@
|
||||
# CoopID — инфраструктура per-coop (Story 1.1)
|
||||
|
||||
Стек OIDC-аутентификации кооператива встроен в **основной** `docker-compose.yaml`
|
||||
монорепы: authentik 2026.2 (server + worker), caddy (внешний TLS 1.3) и crowdsec.
|
||||
Отдельный postgres не поднимается — данные authentik и доменные данные CoopID
|
||||
живут **двумя отдельными БД в существующем сервисе `postgres`**:
|
||||
|
||||
- `authentik_db` — роль `authentik_user` (данные authentik);
|
||||
- `coop_domain_db` — роль `coop_app_user` (vaults, audit, кэши — миграции V2.4.x).
|
||||
|
||||
Ролевая изоляция: подключиться к чужой БД может только её владелец (и суперюзер).
|
||||
Redis — общий `monoredis` (authentik в DB-индексе 1, controller в 0).
|
||||
|
||||
Весь стек поднимается **одной командой** — никаких overlay `-f`-цепочек.
|
||||
|
||||
> ⚠️ **ОБЯЗАТЕЛЬНО перед первым `docker compose up -d` (и на каждом чекауте/ноде).**
|
||||
> CoopID-сервисы в `docker-compose.yaml` ссылаются на file-секреты из
|
||||
> `infra/coopid/secrets/*` (каталог в `.gitignore` — в репозиторий не попадает).
|
||||
> Если этих файлов нет, `docker compose up -d` **падает на этапе валидации
|
||||
> конфига** (`secret ... not found`) и не поднимает вообще ничего, включая
|
||||
> базовый стек. Лекарство — один раз прогнать:
|
||||
>
|
||||
> ```bash
|
||||
> bash scripts/coopid-gen-secrets.sh
|
||||
> ```
|
||||
>
|
||||
> Это касается любого чекаута монорепы (mono-ai-1..5) после `git pull` ветки с
|
||||
> этими изменениями, не только mono-ai-3. На prod секреты раскладывает плейбук
|
||||
> `~/playbooks` — там скрипт-генератор не используется.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
# 1. Секреты (один раз; идемпотентно — существующие не перезаписывает)
|
||||
bash scripts/coopid-gen-secrets.sh
|
||||
|
||||
# 2. Весь стек
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Состав (в docker-compose.yaml)
|
||||
|
||||
| Сервис | Образ | Назначение |
|
||||
|---|---|---|
|
||||
| `postgres` | postgres:18 | Общий postgres. Кроме `voskhod` держит `authentik_db` и `coop_domain_db` (создаются init-скриптом на свежем томе либо вручную — см. ниже) |
|
||||
| `authentik-server` | ghcr.io/goauthentik/server:2026.2 | OIDC Provider; UI/API на :9000 внутри сети |
|
||||
| `authentik-worker` | ghcr.io/goauthentik/server:2026.2 | Фоновые задачи authentik |
|
||||
| `caddy` | caddy:2.11 | Внешний HTTPS (TLS 1.3 min): `/api/coopback/*` → controller, остальное → authentik; JSON access-log |
|
||||
| `crowdsec` | crowdsecurity/crowdsec:v1.7.8 | Разбор access-логов caddy (активный bouncer — Story 9.2) |
|
||||
|
||||
## Две БД в существующем postgres
|
||||
|
||||
`infra/coopid/postgres/init/01-init.sh` смонтирован в `/docker-entrypoint-initdb.d`
|
||||
и заводит роли+БД **только при пустом data dir** (правило официального образа
|
||||
postgres). Поэтому:
|
||||
|
||||
- **Свежая машина / пустой том** — `docker compose up -d` создаёт обе БД сам.
|
||||
- **Уже инициализированный том** (как на текущих dev-стендах) — init-скрипт
|
||||
не запускается, БД заводятся вручную тем же SQL:
|
||||
|
||||
```bash
|
||||
AUTHPW=$(cat infra/coopid/secrets/coop_pg_authentik_password)
|
||||
APPPW=$(cat infra/coopid/secrets/coop_pg_app_password)
|
||||
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 \
|
||||
-v apw="$AUTHPW" -v cpw="$APPPW" -U postgres -d postgres <<'SQL'
|
||||
CREATE ROLE authentik_user LOGIN PASSWORD :'apw';
|
||||
CREATE ROLE coop_app_user LOGIN PASSWORD :'cpw';
|
||||
CREATE DATABASE authentik_db OWNER authentik_user;
|
||||
CREATE DATABASE coop_domain_db OWNER coop_app_user;
|
||||
REVOKE CONNECT ON DATABASE authentik_db FROM PUBLIC;
|
||||
REVOKE CONNECT ON DATABASE coop_domain_db FROM PUBLIC;
|
||||
SQL
|
||||
```
|
||||
|
||||
Затем coopback на старте применит миграции `coop_domain_db` (V2.4.0/V2.4.1).
|
||||
|
||||
## Секреты
|
||||
|
||||
File-based Docker Secrets из `infra/coopid/secrets/` (каталог в `.gitignore`):
|
||||
`coop_cert_key` (secp256k1 PEM — подпись `participant_certificate`),
|
||||
`coop_vault_key`, `authentik_secret_key`, bootstrap-пароль/токен `akadmin`,
|
||||
`authentik_webhook_token`, `auth_v2_session_binding_secret`, два пароля ролей
|
||||
postgres. Суперюзер postgres — inline-пароль в compose, отдельного секрета нет.
|
||||
В `.env`/compose значений секретов нет; контейнеры читают их из `/run/secrets/*`
|
||||
(postgres-роли — через init-скрипт, authentik — `file://`, coopback — `*_FILE`).
|
||||
На prod секреты раскладывает плейбук с правами `0400`.
|
||||
|
||||
## Порты (dev, mono-ai-3)
|
||||
|
||||
- `127.0.0.1:8443` — caddy HTTPS (self-signed при `COOPID_DOMAIN=localhost`)
|
||||
- `127.0.0.1:8088` — caddy HTTP
|
||||
- `127.0.0.1:9008` — authentik напрямую (мимо caddy, для отладки; `AUTHENTIK_HOST_PORT`)
|
||||
- `127.0.0.1:5532` — postgres (обе БД, `PG_HOST_PORT`)
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
# SNI обязателен: caddy с авто-TLS для localhost без SNI отдаёт TLS internal error.
|
||||
curl -sk --tlsv1.3 --resolve localhost:8443:127.0.0.1 https://localhost:8443/ \
|
||||
-o /dev/null -w '%{http_code}\n' # 302/200 от authentik (порт = CADDY_HTTPS_PORT)
|
||||
# Ролевая изоляция (ожидаемо permission denied):
|
||||
docker compose exec postgres psql -U coop_app_user -d authentik_db -c 'select 1'
|
||||
```
|
||||
|
||||
Учётка администратора authentik: `akadmin`, пароль — в
|
||||
`infra/coopid/secrets/authentik_bootstrap_password`.
|
||||
|
||||
## Бэкапы (Story 9.8)
|
||||
|
||||
Ежедневный `pg_basebackup` всего PG-кластера (обе БД сразу) → один gzip-tar → S3
|
||||
`s3://{bucket}/{coopname}/{YYYY-MM-DD}.tar.gz` с SSE-S3. Retention 30 дней — через
|
||||
S3 lifecycle rule (не ручным удалением). На каждый прогон — строка `audit_events`
|
||||
(`coopid.backup.created`: `s3_key`, `size_bytes`, `duration_seconds`).
|
||||
|
||||
Скрипт: `infra/coopid/scripts/coopid-backup.sh`. Env:
|
||||
|
||||
```bash
|
||||
export COOPNAME=voskhod # имя кооператива (обязательно)
|
||||
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
|
||||
export S3_BUCKET=coopid-backups # дефолт
|
||||
export S3_ENDPOINT= # пусто = реальный AWS S3; для MinIO: http://minio:9000
|
||||
export RETENTION_DAYS=30 # дефолт
|
||||
export PG_CONTAINER=coop-postgres # имя контейнера postgres
|
||||
```
|
||||
|
||||
Один раз при деплое — создать bucket и поставить lifecycle:
|
||||
|
||||
```bash
|
||||
COOPNAME=voskhod ./infra/coopid/scripts/coopid-backup.sh --setup
|
||||
```
|
||||
|
||||
Cron (ставит прод-плейбук `~/playbooks`; локально — в crontab хоста), 03:00 UTC daily:
|
||||
|
||||
```cron
|
||||
0 3 * * * cd /path/to/mono && COOPNAME=voskhod AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \
|
||||
./infra/coopid/scripts/coopid-backup.sh >> /var/log/coopid-backup.log 2>&1
|
||||
```
|
||||
|
||||
`--dry-run` печатает команды без выполнения. Восстановление — runbook
|
||||
`docs/operations/disaster-recovery.md` (Story 9.9). Требования к PG: `wal_level>=replica`
|
||||
(дефолт) и replication-доступ суперпользователя по локальному сокету контейнера.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Init-скрипт postgres выполняется только при пустом data dir.** На живом томе
|
||||
заводите БД вручную (см. блок выше). Ротация паролей ролей — `ALTER ROLE`, не
|
||||
перегенерацией секрета.
|
||||
- **Прод с реальным доменом:** задайте `COOPID_DOMAIN`, `CADDY_BIND=0.0.0.0`,
|
||||
`CADDY_HTTP_PORT=80`, `CADDY_HTTPS_PORT=443` — ACME HTTP-01 должен быть
|
||||
достижим снаружи на 80/443.
|
||||
- **authentik на postgres:18:** официальная поддержка на pg16, но pg18 как
|
||||
сервер обратносовместим (Django+psycopg). На dev/testnet общий postgres
|
||||
используется сознательно — единый стек одной командой важнее версии-в-версию.
|
||||
@@ -0,0 +1,91 @@
|
||||
# CoopID Story 1.5: парольная политика (zxcvbn ≥3, ≥12 символов, русское
|
||||
# сообщение) + аудит отказов в coop_domain_db через webhook к controller'у.
|
||||
#
|
||||
# Полиси переопределяется по identifiers дефолтного blueprint'а (upsert) —
|
||||
# весь flow password-change не форкаем, при апгрейдах authentik дрейфа меньше.
|
||||
# execution_logging: true — единственный источник событий policy_execution
|
||||
# в этой инсталляции (дефолтные полиси его не включают), поэтому controller
|
||||
# фильтрует только result.passing=false.
|
||||
version: 1
|
||||
metadata:
|
||||
name: CoopID - password policy & weak-password audit
|
||||
labels:
|
||||
blueprints.goauthentik.io/instantiate: "true"
|
||||
entries:
|
||||
- identifiers:
|
||||
name: default-password-change-password-policy
|
||||
model: authentik_policies_password.passwordpolicy
|
||||
attrs:
|
||||
check_static_rules: true
|
||||
check_have_i_been_pwned: false # HIBP — Growth (FR21)
|
||||
check_zxcvbn: true
|
||||
zxcvbn_score_threshold: 3
|
||||
length_min: 12
|
||||
password_field: password
|
||||
error_message: Пароль слишком слабый. Попробуйте длиннее или с большей вариативностью
|
||||
execution_logging: true
|
||||
|
||||
- identifiers:
|
||||
name: coopid-event-matcher-policy-execution
|
||||
id: coopid-event-matcher
|
||||
model: authentik_policies_event_matcher.eventmatcherpolicy
|
||||
attrs:
|
||||
action: policy_execution
|
||||
|
||||
- identifiers:
|
||||
name: coopid-webhook-body
|
||||
id: coopid-webhook-body
|
||||
model: authentik_events.notificationwebhookmapping
|
||||
attrs:
|
||||
expression: |
|
||||
event = notification.event
|
||||
ctx = (event.context or {}) if event else {}
|
||||
return {
|
||||
"severity": notification.severity,
|
||||
"event_action": event.action if event else None,
|
||||
"event_user": ((event.user or {}).get("username")) if event else None,
|
||||
"passing": ((ctx.get("result") or {}).get("passing")),
|
||||
"messages": ((ctx.get("result") or {}).get("messages")),
|
||||
"created": event.created.isoformat() if event else None,
|
||||
}
|
||||
|
||||
- identifiers:
|
||||
name: coopid-webhook-headers
|
||||
id: coopid-webhook-headers
|
||||
model: authentik_events.notificationwebhookmapping
|
||||
attrs:
|
||||
# Токен подставляется при применении blueprint'а из env контейнера
|
||||
# (COOPID_WEBHOOK_TOKEN); сверяется controller'ом constant-time.
|
||||
expression: !Format [
|
||||
"return {'X-Authentik-Webhook-Token': '%s'}",
|
||||
!Env COOPID_WEBHOOK_TOKEN,
|
||||
]
|
||||
|
||||
- identifiers:
|
||||
name: coopid-webhook-transport
|
||||
id: coopid-webhook-transport
|
||||
model: authentik_events.notificationtransport
|
||||
attrs:
|
||||
mode: webhook
|
||||
webhook_url: http://coopback:2998/coop/internal/authentik-events
|
||||
webhook_mapping_body: !KeyOf coopid-webhook-body
|
||||
webhook_mapping_headers: !KeyOf coopid-webhook-headers
|
||||
send_once: true
|
||||
|
||||
- identifiers:
|
||||
name: coopid-weak-password-rule
|
||||
id: coopid-weak-password-rule
|
||||
model: authentik_events.notificationrule
|
||||
attrs:
|
||||
severity: notice
|
||||
transports:
|
||||
- !KeyOf coopid-webhook-transport
|
||||
destination_group: !Find [authentik_core.group, [name, authentik Admins]]
|
||||
|
||||
- identifiers:
|
||||
policy: !KeyOf coopid-event-matcher
|
||||
target: !KeyOf coopid-weak-password-rule
|
||||
order: 0
|
||||
model: authentik_policies.policybinding
|
||||
attrs:
|
||||
enabled: true
|
||||
@@ -0,0 +1,32 @@
|
||||
# Edge-вход кооператива (CoopID). TLS 1.3 минимум; внутрь сети — plain HTTP.
|
||||
# COOPID_DOMAIN=localhost (dev) даёт self-signed сертификат внутреннего CA;
|
||||
# реальный домен автоматически получает Let's Encrypt.
|
||||
|
||||
{
|
||||
admin off
|
||||
}
|
||||
|
||||
{$COOPID_DOMAIN:localhost} {
|
||||
tls {
|
||||
protocols tls1.3
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/access.log {
|
||||
roll_size 50MiB
|
||||
roll_keep 5
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
# REST/GraphQL controller'а — до общего catch-all.
|
||||
handle /api/coopback/* {
|
||||
uri strip_prefix /api/coopback
|
||||
reverse_proxy coopback:2998
|
||||
}
|
||||
|
||||
# Всё остальное — OIDC Provider (authentik UI, /application/o/*, flows).
|
||||
handle {
|
||||
reverse_proxy authentik-server:9000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Источники логов для crowdsec: JSON access-лог caddy (shared volume caddy_logs).
|
||||
# Активные блокировки (bouncer в caddy) подключаются в Story 9.2.
|
||||
filenames:
|
||||
- /var/log/caddy/access.log
|
||||
labels:
|
||||
type: caddy
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Инициализация coop-postgres (выполняется entrypoint'ом postgres только при пустом data dir):
|
||||
# две БД с ролевой изоляцией — authentik_db (authentik_user) и coop_domain_db (coop_app_user).
|
||||
# Пароли ролей берутся из Docker Secrets, в env/compose они не попадают.
|
||||
set -euo pipefail
|
||||
|
||||
AUTHENTIK_PW="$(cat /run/secrets/coop_pg_authentik_password)"
|
||||
APP_PW="$(cat /run/secrets/coop_pg_app_password)"
|
||||
|
||||
# Пароли — через psql-переменные с квотированием :'var': не попадают в текст
|
||||
# SQL-команды (логи statement'ов, pg_stat_activity) и безопасны к спецсимволам.
|
||||
psql -v ON_ERROR_STOP=1 -v apw="$AUTHENTIK_PW" -v cpw="$APP_PW" \
|
||||
--username "$POSTGRES_USER" --dbname postgres <<-'EOSQL'
|
||||
CREATE ROLE authentik_user LOGIN PASSWORD :'apw';
|
||||
CREATE ROLE coop_app_user LOGIN PASSWORD :'cpw';
|
||||
|
||||
CREATE DATABASE authentik_db OWNER authentik_user;
|
||||
CREATE DATABASE coop_domain_db OWNER coop_app_user;
|
||||
|
||||
-- Ролевая изоляция: подключаться к БД может только её владелец (и суперпользователь).
|
||||
REVOKE CONNECT ON DATABASE authentik_db FROM PUBLIC;
|
||||
REVOKE CONNECT ON DATABASE coop_domain_db FROM PUBLIC;
|
||||
EOSQL
|
||||
|
||||
# Запрет создания объектов в public-схеме чужой БД на случай выдачи CONNECT в будущем.
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname authentik_db <<-EOSQL
|
||||
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
||||
GRANT ALL ON SCHEMA public TO authentik_user;
|
||||
EOSQL
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname coop_domain_db <<-EOSQL
|
||||
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
||||
GRANT ALL ON SCHEMA public TO coop_app_user;
|
||||
EOSQL
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# CoopID daily backup (Story 9.8): pg_basebackup всего PG-кластера (authentik_db +
|
||||
# coop_domain_db + любые прочие БД внутри контейнера) → один gzip-tar → S3
|
||||
# `s3://{bucket}/{coopname}/{YYYY-MM-DD}.tar.gz` с SSE-S3. На каждый прогон —
|
||||
# строка audit_events (coopid.backup.created). Retention 30 дней — S3 lifecycle
|
||||
# rule (ставится один раз режимом `--setup`), НЕ ручным удалением.
|
||||
#
|
||||
# Один скрипт для dev (MinIO) и prod (реальный S3): endpoint и креды — из env.
|
||||
# Cron 03:00 UTC ставит прод-плейбук (~/playbooks); crontab-строка — в README.
|
||||
#
|
||||
# Использование:
|
||||
# COOPNAME=voskhod ./coopid-backup.sh # сделать бэкап
|
||||
# COOPNAME=voskhod ./coopid-backup.sh --setup # создать bucket + lifecycle 30d
|
||||
# COOPNAME=voskhod ./coopid-backup.sh --dry-run # показать команды, не выполнять
|
||||
#
|
||||
# Обязательные env: COOPNAME, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY.
|
||||
# Опциональные env (со значениями по умолчанию ниже):
|
||||
# S3_BUCKET (coopid-backups), S3_ENDPOINT (пусто=реальный AWS S3),
|
||||
# RETENTION_DAYS (30), PG_CONTAINER (coop-postgres), PG_SUPERUSER (postgres),
|
||||
# COOP_DOMAIN_DB (coop_domain_db), S3_SSE (AES256), AWS_DEFAULT_REGION (us-east-1).
|
||||
set -euo pipefail
|
||||
|
||||
COOPNAME="${COOPNAME:?COOPNAME обязателен (имя кооператива)}"
|
||||
S3_BUCKET="${S3_BUCKET:-coopid-backups}"
|
||||
S3_ENDPOINT="${S3_ENDPOINT:-}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-30}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-coop-postgres}"
|
||||
PG_SUPERUSER="${PG_SUPERUSER:-postgres}"
|
||||
COOP_DOMAIN_DB="${COOP_DOMAIN_DB:-coop_domain_db}"
|
||||
S3_SSE="${S3_SSE:-AES256}"
|
||||
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
|
||||
|
||||
MODE="backup"
|
||||
case "${1:-}" in
|
||||
--setup) MODE="setup" ;;
|
||||
--dry-run) MODE="dry-run" ;;
|
||||
"") MODE="backup" ;;
|
||||
*) echo "Неизвестный аргумент: $1 (ожидается --setup | --dry-run)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# aws CLI с опциональным endpoint (MinIO в dev / реальный S3 в prod).
|
||||
aws_s3() {
|
||||
if [[ -n "$S3_ENDPOINT" ]]; then aws --endpoint-url "$S3_ENDPOINT" "$@"; else aws "$@"; fi
|
||||
}
|
||||
|
||||
log() { echo "[coopid-backup] $*"; }
|
||||
|
||||
# --- режим --setup: bucket + lifecycle (retention 30 дней) ---
|
||||
setup_bucket() {
|
||||
log "ensure bucket s3://$S3_BUCKET"
|
||||
if ! aws_s3 s3api head-bucket --bucket "$S3_BUCKET" >/dev/null 2>&1; then
|
||||
aws_s3 s3 mb "s3://$S3_BUCKET"
|
||||
fi
|
||||
log "apply lifecycle: expire objects after ${RETENTION_DAYS}d"
|
||||
aws_s3 s3api put-bucket-lifecycle-configuration \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--lifecycle-configuration "{
|
||||
\"Rules\": [{
|
||||
\"ID\": \"coopid-backups-retention-${RETENTION_DAYS}d\",
|
||||
\"Status\": \"Enabled\",
|
||||
\"Filter\": { \"Prefix\": \"\" },
|
||||
\"Expiration\": { \"Days\": ${RETENTION_DAYS} }
|
||||
}]
|
||||
}"
|
||||
log "setup завершён"
|
||||
}
|
||||
|
||||
# --- запись результата в append-only audit_events (coop_domain_db) ---
|
||||
# Значения подставляются через psql-переменные с квотированием :'var' — не
|
||||
# попадают в текст команды и безопасны к спецсимволам (как в init-скрипте).
|
||||
audit() {
|
||||
local result="$1" s3_key="$2" size_bytes="$3" duration="$4" stage="$5"
|
||||
docker exec -i "$PG_CONTAINER" \
|
||||
psql -v ON_ERROR_STOP=1 -q \
|
||||
-v cn="$COOPNAME" -v key="$s3_key" -v sz="$size_bytes" -v dur="$duration" \
|
||||
-v res="$result" -v stg="$stage" \
|
||||
--username "$PG_SUPERUSER" --dbname "$COOP_DOMAIN_DB" >/dev/null <<-'EOSQL'
|
||||
INSERT INTO audit_events (event, subject_id, actor, result, context)
|
||||
VALUES (
|
||||
'coopid.backup.created', NULL, 'backup-cron', :'res',
|
||||
jsonb_build_object(
|
||||
'coopname', :'cn', 's3_key', :'key',
|
||||
'size_bytes', (:'sz')::bigint, 'duration_seconds', (:'dur')::int,
|
||||
'stage', :'stg'
|
||||
)
|
||||
);
|
||||
EOSQL
|
||||
}
|
||||
|
||||
run_backup() {
|
||||
local date_utc start end duration tmp archive size s3_key
|
||||
date_utc="$(date -u +%F)"
|
||||
start="$(date +%s)"
|
||||
s3_key="${COOPNAME}/${date_utc}.tar.gz"
|
||||
tmp="$(mktemp -d)"
|
||||
archive="${tmp}/${date_utc}.tar.gz"
|
||||
# shellcheck disable=SC2064 # tmp фиксируем сейчас, очистка при любом выходе
|
||||
trap "rm -rf '$tmp'" EXIT
|
||||
|
||||
log "pg_basebackup кластера из контейнера $PG_CONTAINER"
|
||||
if ! docker exec "$PG_CONTAINER" \
|
||||
pg_basebackup --username "$PG_SUPERUSER" --format=tar --gzip \
|
||||
--wal-method=fetch --pgdata=- > "$archive"; then
|
||||
audit failure "$s3_key" 0 "$(( $(date +%s) - start ))" basebackup || true
|
||||
log "ОШИБКА: pg_basebackup не удался" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
size="$(stat -c%s "$archive")"
|
||||
if [[ "$size" -le 0 ]]; then
|
||||
audit failure "$s3_key" 0 "$(( $(date +%s) - start ))" basebackup || true
|
||||
log "ОШИБКА: пустой архив бэкапа" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "upload → s3://${S3_BUCKET}/${s3_key} (${size} байт, SSE=${S3_SSE})"
|
||||
if ! aws_s3 s3 cp "$archive" "s3://${S3_BUCKET}/${s3_key}" --sse "$S3_SSE"; then
|
||||
audit failure "$s3_key" "$size" "$(( $(date +%s) - start ))" upload || true
|
||||
log "ОШИБКА: загрузка в S3 не удалась" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
end="$(date +%s)"
|
||||
duration="$(( end - start ))"
|
||||
audit success "$s3_key" "$size" "$duration" "done"
|
||||
log "готово: ${s3_key} за ${duration}s"
|
||||
}
|
||||
|
||||
dry_run() {
|
||||
cat <<EOF
|
||||
[dry-run] backup-команды для coopname=${COOPNAME}:
|
||||
docker exec ${PG_CONTAINER} pg_basebackup --username ${PG_SUPERUSER} \\
|
||||
--format=tar --gzip --wal-method=fetch --pgdata=- > <tmp>/$(date -u +%F).tar.gz
|
||||
aws${S3_ENDPOINT:+ --endpoint-url ${S3_ENDPOINT}} s3 cp <tmp>/$(date -u +%F).tar.gz \\
|
||||
s3://${S3_BUCKET}/${COOPNAME}/$(date -u +%F).tar.gz --sse ${S3_SSE}
|
||||
audit_events ← coopid.backup.created (s3_key, size_bytes, duration_seconds)
|
||||
[dry-run] retention: S3 lifecycle expire ${RETENTION_DAYS}d (см. --setup)
|
||||
EOF
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
setup) setup_bucket ;;
|
||||
dry-run) dry_run ;;
|
||||
backup) run_backup ;;
|
||||
esac
|
||||
+3
-2
@@ -24,18 +24,19 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@coopenomics/auth": "workspace:*",
|
||||
"@coopenomics/blago-cli": "workspace:*",
|
||||
"@coopenomics/boot": "workspace:*",
|
||||
"@coopenomics/cleos": "workspace:*",
|
||||
"@coopenomics/contracts": "workspace:*",
|
||||
"@coopenomics/controller": "workspace:*",
|
||||
"@coopenomics/desktop": "workspace:*",
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
"@coopenomics/parser": "workspace:*",
|
||||
"@coopenomics/sdk": "workspace:*",
|
||||
"@coopenomics/setup": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/blago-cli": "workspace:*",
|
||||
"commitgpt": "^2.0.0",
|
||||
"cooptypes": "workspace:*",
|
||||
"eslint": "^9.9.1",
|
||||
|
||||
Generated
+77
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@coopenomics/auth':
|
||||
specifier: workspace:*
|
||||
version: link:components/auth
|
||||
'@coopenomics/blago-cli':
|
||||
specifier: workspace:*
|
||||
version: link:components/blago-cli
|
||||
@@ -70,6 +73,49 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
components/auth:
|
||||
dependencies:
|
||||
'@noble/hashes':
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
'@wharfkit/antelope':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
'@wharfkit/session':
|
||||
specifier: ^1.4.0
|
||||
version: 1.6.1
|
||||
cooptypes:
|
||||
specifier: workspace:*
|
||||
version: link:../cooptypes
|
||||
jose:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.3
|
||||
oidc-client-ts:
|
||||
specifier: ^3.1.0
|
||||
version: 3.5.0
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^3.3.2
|
||||
version: 3.16.0(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.31)(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1))
|
||||
'@types/node':
|
||||
specifier: ^22.5.4
|
||||
version: 22.19.15
|
||||
eslint:
|
||||
specifier: ^9.9.1
|
||||
version: 9.39.4(jiti@2.6.1)
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.9.3
|
||||
unbuild:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(sass@1.98.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))
|
||||
vite:
|
||||
specifier: ^5.4.3
|
||||
version: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)
|
||||
vitest:
|
||||
specifier: ^2.0.5
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)
|
||||
|
||||
components/blago-cli:
|
||||
dependencies:
|
||||
'@coopenomics/sdk':
|
||||
@@ -475,6 +521,9 @@ importers:
|
||||
joi-to-typescript:
|
||||
specifier: ^4.13.0
|
||||
version: 4.15.0(joi@17.13.3)
|
||||
jose:
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
@@ -1175,6 +1224,15 @@ importers:
|
||||
'@coopenomics/factory':
|
||||
specifier: workspace:*
|
||||
version: link:../factory
|
||||
'@wharfkit/antelope':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
'@wharfkit/session':
|
||||
specifier: ^1.4.0
|
||||
version: 1.6.1
|
||||
'@wharfkit/wallet-plugin-privatekey':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(@wharfkit/session@1.6.1)
|
||||
cooptypes:
|
||||
specifier: workspace:*
|
||||
version: link:../cooptypes
|
||||
@@ -12991,6 +13049,9 @@ packages:
|
||||
jose@5.10.0:
|
||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -13177,6 +13238,10 @@ packages:
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
jwt-decode@4.0.0:
|
||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
kareem@3.2.0:
|
||||
resolution: {integrity: sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -14723,6 +14788,10 @@ packages:
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
oidc-client-ts@3.5.0:
|
||||
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ol-contextmenu@5.5.0:
|
||||
resolution: {integrity: sha512-IglB0OfzSmcP++dykqrQU62lJAn/4O61hxyD0s5V03HE70Efm6EgSaNq8YOlgEbnqfUL+7F3fS7VI3UV+0+NZg==}
|
||||
engines: {node: '>=16', npm: '>=8'}
|
||||
@@ -34276,6 +34345,8 @@ snapshots:
|
||||
|
||||
jose@5.10.0: {}
|
||||
|
||||
jose@6.2.3: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-beautify@1.14.11:
|
||||
@@ -34484,6 +34555,8 @@ snapshots:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jwt-decode@4.0.0: {}
|
||||
|
||||
kareem@3.2.0: {}
|
||||
|
||||
katex@0.16.43:
|
||||
@@ -36409,6 +36482,10 @@ snapshots:
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
oidc-client-ts@3.5.0:
|
||||
dependencies:
|
||||
jwt-decode: 4.0.0
|
||||
|
||||
ol-contextmenu@5.5.0(ol@9.2.4):
|
||||
dependencies:
|
||||
ol: 9.2.4
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Генерация file-based Docker Secrets для CoopID-стека (dev).
|
||||
# Идемпотентен: существующие непустые файлы не перезаписывает.
|
||||
# На prod секреты раскладывает плейбук — этот скрипт только для локальной разработки.
|
||||
set -euo pipefail
|
||||
|
||||
command -v openssl >/dev/null || { echo "Нужен openssl (генерация ключей/паролей)"; exit 1; }
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SECRETS_DIR="$ROOT/infra/coopid/secrets"
|
||||
mkdir -p "$SECRETS_DIR"
|
||||
|
||||
gen() {
|
||||
local mode="$1" name="$2"
|
||||
shift 2
|
||||
local file="$SECRETS_DIR/$name"
|
||||
if [[ -s "$file" ]]; then
|
||||
echo " = $name — уже существует, пропущен"
|
||||
return
|
||||
fi
|
||||
"$@" > "$file"
|
||||
chmod "$mode" "$file"
|
||||
echo " + $name — создан"
|
||||
}
|
||||
|
||||
rand_hex() { openssl rand -hex 32; }
|
||||
rand_pass() { openssl rand -base64 36 | tr -d '\n=/+'; }
|
||||
secp256k1_pem() { openssl ecparam -genkey -name secp256k1 -noout; }
|
||||
|
||||
echo "Секреты CoopID → $SECRETS_DIR"
|
||||
# Ключ подписи participant_certificate (ES256K, secp256k1).
|
||||
gen 0400 coop_cert_key secp256k1_pem
|
||||
# AES-ключ vault'а кооператива (subject_type='coop').
|
||||
gen 0400 coop_vault_key rand_hex
|
||||
# authentik: secret key + bootstrap-учётка akadmin (образ работает под uid 1000 = владелец файлов).
|
||||
gen 0400 authentik_secret_key rand_pass
|
||||
gen 0400 authentik_bootstrap_password rand_pass
|
||||
gen 0400 authentik_bootstrap_token rand_pass
|
||||
# Shared-токен вебхука authentik → controller (audit weak-password, Story 1.5).
|
||||
# Дублируется в .env (COOPID_WEBHOOK_TOKEN) для blueprint'а authentik.
|
||||
gen 0400 authentik_webhook_token rand_pass
|
||||
# Секрет подписи session_binding_token (HS256, Story 1.6).
|
||||
gen 0400 auth_v2_session_binding_secret rand_pass
|
||||
if ! grep -q '^COOPID_WEBHOOK_TOKEN=' "$ROOT/.env" 2>/dev/null; then
|
||||
printf '\n# Webhook-токен authentik→coopback (= infra/coopid/secrets/authentik_webhook_token)\nCOOPID_WEBHOOK_TOKEN=%s\n' "$(cat "$SECRETS_DIR/authentik_webhook_token")" >> "$ROOT/.env"
|
||||
echo " + COOPID_WEBHOOK_TOKEN добавлен в .env"
|
||||
fi
|
||||
# postgres: пароли прикладных ролей CoopID (authentik_user, coop_app_user).
|
||||
# Суперюзер postgres использует inline-пароль из docker-compose.yaml — отдельный
|
||||
# секрет ему не нужен. file-секреты в plain compose монтируются bind-mount'ом с
|
||||
# хостовыми правами, а init-скрипты официального образа выполняются под uid 999
|
||||
# (postgres) — поэтому пароли world-readable. Только dev; prod-права (0400) — плейбук.
|
||||
gen 0444 coop_pg_authentik_password rand_pass
|
||||
gen 0444 coop_pg_app_password rand_pass
|
||||
|
||||
echo "Готово. Дальше: docker compose up -d (весь стек одной командой)."
|
||||
+30
-9
@@ -1,14 +1,35 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./components/controller" },
|
||||
{ "path": "./components/desktop" },
|
||||
{ "path": "./components/sdk" },
|
||||
{ "path": "./components/cooptypes" },
|
||||
{ "path": "./components/notifications" },
|
||||
{ "path": "./components/factory" },
|
||||
{ "path": "./components/parser" },
|
||||
{ "path": "./components/boot" },
|
||||
{ "path": "./components/migrator" }
|
||||
{
|
||||
"path": "./components/auth"
|
||||
},
|
||||
{
|
||||
"path": "./components/controller"
|
||||
},
|
||||
{
|
||||
"path": "./components/desktop"
|
||||
},
|
||||
{
|
||||
"path": "./components/sdk"
|
||||
},
|
||||
{
|
||||
"path": "./components/cooptypes"
|
||||
},
|
||||
{
|
||||
"path": "./components/notifications"
|
||||
},
|
||||
{
|
||||
"path": "./components/factory"
|
||||
},
|
||||
{
|
||||
"path": "./components/parser"
|
||||
},
|
||||
{
|
||||
"path": "./components/boot"
|
||||
},
|
||||
{
|
||||
"path": "./components/migrator"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user