[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user