[105-10][@ant] feat: мост подписи CoopID на десктопе — WalletPluginCoopId + CoopID-сессия в session.init (Эпик 7) — чтобы подписывать он-чейн без извлечения ключа из keystore

WalletPluginCoopId (AbstractWalletPlugin) считает signing-дайджест и делегирует
подпись в @coopenomics/auth.signChainDigest — приватный ключ из keystore не
выходит, как у Ledger/Anchor. session-store: establishCoopIdSession строит
wharfkit Session поверх keystore (без globalStore.wif); ensureUnlocked — единая
точка авто-unlock по PIN-кэшу перед каждой подписью; авто-лок RAM 30 мин
(скользящий); username/isAuth fallback на CoopID-аккаунт; close затирает ключ и
PIN-кэш. session.init: ветка CoopID после легаси — строго аддитивно, при
отсутствии CoopID-кэша no-op, легаси-путь байт-в-байт не изменён. StorageAdapter
поверх IndexedDB (createCoopIdStorage) + deleteFromIndexedDB. ESLint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-15 08:07:07 +00:00
parent 0ba0d4c382
commit d1d7410731
4 changed files with 245 additions and 1 deletions
@@ -0,0 +1,34 @@
import type { StorageAdapter } from '@coopenomics/auth';
import {
deleteFromIndexedDB,
getFromIndexedDB,
setToIndexedDB,
} from 'src/shared/api/indexDB';
/** Тот же IndexedDB-store, что и легаси globalStore; ключи не пересекаются. */
const STORE = 'store';
/**
* `StorageAdapter` контура CoopID для `@coopenomics/auth`: локальный PIN-кэш ключа
* (`coopid.wallet.pin-vault`) и копия серверного vault'а (`coopid.wallet.vault`).
* Бэкенд — тот же IndexedDB (db = coopname, store `store`), что и легаси
* globalStore; ключи `coopid.wallet.*` с легаси (`encrypted*`) не пересекаются.
* Скоупится по кооперативу, чтобы кэши разных коопов на устройстве не смешивались.
*
* `get` возвращает `null` при отсутствии (idb отдаёт `undefined`) — этого требует
* контракт адаптера: `loadPinProtected`/`hasPinProtected` различают `null` и значение.
*/
export function createCoopIdStorage(coopname: string): StorageAdapter {
return {
async get(key) {
const raw = await getFromIndexedDB(coopname, STORE, key);
return raw == null ? null : (raw as string);
},
async set(key, value) {
await setToIndexedDB(coopname, STORE, key, value);
},
async remove(key) {
await deleteFromIndexedDB(coopname, STORE, key);
},
};
}
@@ -0,0 +1,91 @@
import {
AbstractWalletPlugin,
Checksum256,
Signature,
Transaction,
WalletPluginMetadata,
} from '@wharfkit/session';
import type {
LoginContext,
ResolvedSigningRequest,
TransactContext,
WalletPluginLoginResponse,
WalletPluginSignResponse,
} from '@wharfkit/session';
import { signChainDigest } from '@coopenomics/auth';
export interface WalletPluginCoopIdOptions {
/** Публичный ключ пайщика для metadata (из keystore @coopenomics/auth). */
publicKey?: string;
/**
* Хук, гарантирующий разблокировку keystore перед подписью: авто-unlock по
* локальному ПИН-кэшу при дефолтном ПИН (прозрачно), либо запрос ПИН при
* кастомном. Вызывается перед КАЖДОЙ подписью — это единственная точка
* перехвата «ключ должен быть доступен» для всех session.transact. Внедряется
* из session.init (Эпик 7 шаг 4). Если не задан — подпись идёт напрямую и
* бросит WalletLocked, когда keystore заперт.
*/
ensureUnlocked?: () => Promise<void>;
}
/**
* WalletPlugin контура CoopID (мост подписи, Эпик 7). Подписывает транзакции
* COOPOS, НЕ извлекая приватный ключ: как Ledger/Anchor-плагины, он получает от
* keystore готовую подпись, а не ключ. Считает signing-дайджест из резолвленной
* транзакции и делегирует подпись в `@coopenomics/auth.signChainDigest`, который
* подписывает ключом из RAM-keystore (Story 2.2) и возвращает только `SIG_K1_…`.
* Так wharfkit `Session.transact()` подписывает «чужими руками», не зная WIF —
* ключ остаётся в keystore и из него не выходит (инвариант RAM-only).
*
* Заменяет на десктопе путь подписи через `WalletPluginPrivateKey(globalStore.wif)`
* (легаси-контур, где расшифрованный WIF лежал в pinia/IndexedDB).
*/
export class WalletPluginCoopId extends AbstractWalletPlugin {
private readonly ensureUnlocked?: () => Promise<void>;
constructor(options: WalletPluginCoopIdOptions = {}) {
super();
this.ensureUnlocked = options.ensureUnlocked;
// Конструктивно не требует выбора цепи/прав: Session строится напрямую с уже
// известными actor/permission/chain после CoopID-входа.
this.config = {
requiresChainSelect: false,
requiresPermissionSelect: false,
};
this.metadata = WalletPluginMetadata.from({
name: 'CoopID',
description:
'Подпись ключом пайщика из защищённого keystore CoopID; приватный ключ не покидает устройство.',
});
if (options.publicKey) this.metadata.publicKey = options.publicKey;
}
get id(): string {
return 'coopid';
}
/**
* Вход не используется при прямом построении Session (actor/permission уже
* известны после CoopID-входа). Реализован для совместимости с интерфейсом —
* возвращает цепь и permission из контекста (как `WalletPluginPrivateKey`).
*/
async login(context: LoginContext): Promise<WalletPluginLoginResponse> {
if (!context.permissionLevel)
throw new Error('WalletPluginCoopId.login требует permissionLevel');
const chain = context.chain ? context.chain.id : context.chains[0].id;
return { chain, permissionLevel: context.permissionLevel };
}
async sign(
resolved: ResolvedSigningRequest,
context: TransactContext,
): Promise<WalletPluginSignResponse> {
if (this.ensureUnlocked) await this.ensureUnlocked();
const transaction = Transaction.from(resolved.transaction);
const digest = transaction.signingDigest(Checksum256.from(context.chain.id));
// signChainDigest принимает hex-строку дайджеста (а не Checksum256) намеренно —
// чтобы не пересекать границу пакета двумя копиями antelope (см. signing/index.ts).
const signature = Signature.from(await signChainDigest(String(digest)));
return { signatures: [signature] };
}
}
@@ -3,11 +3,22 @@ import { useGlobalStore } from 'src/shared/store';
import { computed, ComputedRef, Ref, ref } from 'vue';
import { Session } from '@wharfkit/session';
import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey';
import {
clearPinCache,
getWallet,
isWalletUnlocked,
lockWallet,
persistPinCache,
unlockWithPin,
} from '@coopenomics/auth';
import { FailAlert } from 'src/shared/api';
import { PrivateKey } from '@wharfkit/antelope';
import { env } from 'src/shared/config';
import type { IAccount } from 'src/entities/Account/types';
import { useDisplayName } from 'src/shared/lib/composables/useDisplayName';
import { useSystemStore } from 'src/entities/System/model';
import { WalletPluginCoopId } from '../lib/walletPluginCoopId';
import { createCoopIdStorage } from '../lib/coopidStorage';
interface ISessionStore {
isAuth: Ref<boolean>;
@@ -16,6 +27,14 @@ interface ISessionStore {
init: () => Promise<void>;
//TODO add Blockchain Session here
session: Ref<Session | undefined>;
/**
* Установить сессию контура CoopID (мост подписи, Эпик 7): построить wharfkit
* Session с `WalletPluginCoopId` поверх keystore `@coopenomics/auth` (без WIF в
* globalStore). Вызывается флоу входа CoopID после разблокировки keystore.
* `persistPin` — записать локальный PIN-кэш (только при свежем входе, не на
* reload). Возвращает `true`, если сессия установлена.
*/
establishCoopIdSession: (opts?: { persistPin?: boolean }) => Promise<boolean>;
close: () => Promise<void>;
loadComplete: Ref<boolean>;
// Добавляю данные текущего пользователя
@@ -42,12 +61,81 @@ interface ISessionStore {
export const useSessionStore = defineStore('session', (): ISessionStore => {
const globalStore = useGlobalStore();
const systemStore = useSystemStore();
const isAuth = ref(false);
const loadComplete = ref(false);
const currentUserAccount = ref<IAccount | undefined>();
const session = ref();
// Аккаунт контура CoopID (когда сессия построена поверх keystore @coopenomics/auth,
// а не легаси globalStore.wif). Источник fallback'а для username/isAuth.
const coopIdAccount = ref('');
// Авто-лок keystore по простою (уточнённая at-rest модель): RAM-ключ стирается
// через AUTO_LOCK_MS, следующая подпись поднимет его из PIN-кэша (прозрачно при
// дефолтном ПИН). Таймер скользящий — продлевается на каждой подписи (см.
// ensureWalletUnlocked). Только для CoopID-контура; легаси globalStore не трогаем.
const AUTO_LOCK_MS = 30 * 60 * 1000;
let autoLockTimer: ReturnType<typeof setTimeout> | null = null;
const coopStorage = () => createCoopIdStorage(systemStore.info.coopname);
const armAutoLock = () => {
if (!coopIdAccount.value) return;
if (autoLockTimer) clearTimeout(autoLockTimer);
autoLockTimer = setTimeout(() => lockWallet(), AUTO_LOCK_MS);
};
/**
* Гарантирует разблокированный keystore перед подписью CoopID (единственная
* точка перехвата для всех session.transact — вызывается из WalletPluginCoopId).
* Если заперт (после авто-лока/reload) — поднимает ключ из локального PIN-кэша.
* Дефолтный ПИН делает это прозрачно; кастомный ПИН бросит VaultDecryptionFailed
* (запрос ПИН — отдельная история на столе пайщика). Продлевает авто-лок.
*/
const ensureWalletUnlocked = async (): Promise<void> => {
if (!isWalletUnlocked()) {
const wallet = await unlockWithPin({ storage: coopStorage() });
if (!wallet)
throw new Error('CoopID keystore заперт: войдите заново (нет локального ключа)');
}
armAutoLock();
};
const establishCoopIdSession = async (opts?: {
persistPin?: boolean;
}): Promise<boolean> => {
const storage = coopStorage();
// На reload keystore заперт — поднимаем из PIN-кэша (дефолт прозрачно). Если
// кэша нет (на этом устройстве не входили) — CoopID-сессии нет, выходим.
if (!isWalletUnlocked()) {
const restored = await unlockWithPin({ storage }).catch(() => null);
if (!restored) return false;
}
// PIN-кэш пишем ТОЛЬКО при свежем входе: на reload перешифровка дефолтным ПИН
// затёрла бы кастомный ПИН пользователя.
if (opts?.persistPin) await persistPinCache({ storage }).catch(() => undefined);
const wallet = await getWallet();
session.value = new Session({
actor: wallet.account,
permission: 'active',
chain: {
id: env.CHAIN_ID as string,
url: env.CHAIN_URL as string,
},
walletPlugin: new WalletPluginCoopId({
publicKey: wallet.publicKey,
ensureUnlocked: ensureWalletUnlocked,
}),
});
coopIdAccount.value = wallet.account;
isAuth.value = true;
armAutoLock();
return true;
};
const setCurrentUserAccount = (account: IAccount | undefined) => {
currentUserAccount.value = account;
};
@@ -60,6 +148,13 @@ export const useSessionStore = defineStore('session', (): ISessionStore => {
isAuth.value = false;
session.value = undefined;
currentUserAccount.value = undefined;
if (autoLockTimer) clearTimeout(autoLockTimer);
// CoopID-контур: затереть RAM-ключ и локальный PIN-кэш («забыть устройство»).
if (coopIdAccount.value) {
lockWallet();
await clearPinCache(coopStorage()).catch(() => undefined);
coopIdAccount.value = '';
}
globalStore.logout();
};
@@ -81,12 +176,23 @@ export const useSessionStore = defineStore('session', (): ISessionStore => {
globalStore.wif as PrivateKey,
),
});
return;
}
} catch (e: any) {
console.error(e);
FailAlert(e);
close();
globalStore.logout();
return;
}
// Легаси-ключа нет — пробуем контур CoopID: keystore уже разблокирован после
// входа, либо поднимаем его из локального PIN-кэша (мост подписи, Эпик 7).
// Полностью аддитивно: если CoopID-кэша нет, ветка — no-op, легаси не задет.
try {
await establishCoopIdSession();
} catch (e: any) {
console.error(e);
}
}
};
@@ -132,7 +238,8 @@ export const useSessionStore = defineStore('session', (): ISessionStore => {
() => currentUserAccount.value?.registration_payment,
);
const username = computed(() => globalStore.username);
// username легаси-контура; для CoopID-сессии globalStore пуст — fallback на аккаунт keystore.
const username = computed(() => globalStore.username || coopIdAccount.value);
// Display name пользователя (ФИО или название организации)
const displayName = computed(() => {
@@ -149,6 +256,7 @@ export const useSessionStore = defineStore('session', (): ISessionStore => {
isAuth,
init,
session,
establishCoopIdSession,
username,
displayName,
close,
@@ -39,3 +39,14 @@ export async function setToIndexedDB(dbName: string, storeName: string, key: str
return db.put(storeName, value, key);
}
}
export async function deleteFromIndexedDB(dbName: string, storeName: string, key: string) {
if (!process.env.CLIENT) {
return;
}
const db = await openIndexedDB(dbName, storeName);
if (db) {
return db.delete(storeName, key);
}
}