@qotodev/volt-pgp
v1.4.0
Published
PGP-based smart contract authentication and proof library for Web2
Maintainers
Readme
@qotodev/volt-pgp
PGP-контракты для Web2: сервер создаёт контракт, пользователь подписывает его ключом, сервер проверяет обе подписи и срок действия.
Подходит для:
- сайт / front-end
- расширения браузера
- Node.js backend
- сценариев с подтверждением действия через приватный ключ пользователя
Установка
npm install @qotodev/volt-pgpИмпорт
ESM:
import volt from '@qotodev/volt-pgp';
// или:
import { volt } from '@qotodev/volt-pgp';CJS:
const { volt } = require('@qotodev/volt-pgp');Основная идея
- Сервер создаёт контракт с
Termsи подписывает его серверным ключом. - Пользователь подписывает тот же контракт своим ключом в браузере / расширении.
- Сервер проверяет:
- структуру контракта
- даты
CreatedAt/ExpiresAt Audience- подпись сервера
- подпись пользователя
Быстрый сценарий
1) Сервер выпускает контракт
import volt from '@qotodev/volt-pgp';
const SERVER_PRIVATE_KEY = `-----BEGIN PGP PRIVATE KEY BLOCK-----\n...\n-----END PGP PRIVATE KEY BLOCK-----`;
const contract = await volt.contract.create({
title: 'TRANSFER_BALANCE',
payload: JSON.stringify({ action: 'transfer', amount: 100 }),
privateKeyArmored: SERVER_PRIVATE_KEY,
subjectFingerprint: 'A1B2C3D4E5F6...',
audience: 'https://example.com',
expiresInSeconds: 180,
});
console.log(contract);Криптографические методы
Общие операции вынесены в volt.crypto, чтобы backend и wallet не дублировали OpenPGP.js-код.
const pair = await volt.crypto.generateKeyPair({
passphrase: 'strong-password',
userId: 'Volt User',
});
const signature = await volt.crypto.signText({
text: 'one-time challenge',
privateKeyArmored: pair.privateKey,
passphrase: 'strong-password',
});
await volt.crypto.verifyText({
text: 'one-time challenge',
signatureArmored: signature,
publicKeyArmored: pair.publicKey,
});
const keyInfo = await volt.crypto.inspectPublicKey(pair.publicKey);
console.log(keyInfo.fingerprint === pair.fingerprint);Методы:
generateKeyPair()— создать защищённую пару ключей;importPrivateKey()— проверить и импортировать зашифрованный private key;signText()— создать detached PGP-подпись текста;verifyText()— проверить detached PGP-подпись;getFingerprint()— получить fingerprint ключа;inspectPublicKey()— проверить public key и получить fingerprint;
Доказательство владения ключом
Не используйте fingerprint как доказательство владения. Правильный flow регистрации:
- Backend создаёт одноразовый случайный challenge.
- Кошелёк показывает пользователю origin сайта и challenge.
- Пользователь подтверждает действие и вводит пароль.
- Кошелёк подписывает challenge private key.
- Backend проверяет подпись присланным public key, сам вычисляет fingerprint и только потом сохраняет ключ.
Пример для сайта:
const challenge = await fetch('/api/register/challenge', {
method: 'POST',
}).then((response) => response.json());
if (!window.volt) throw new Error('Volt extension is unavailable');
const publicKey = await window.volt.getPublicKey();
const challengeSignature = await window.volt.signChallenge(challenge.challenge);
const registration = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publicKey,
challengeId: challenge.challengeId,
challengeSignature,
}),
});
if (!registration.ok) {
throw new Error('Ownership proof rejected');
}На backend проверка должна быть эквивалентна:
const key = await openpgp.readKey({ armoredKey: publicKey });
const message = await openpgp.createMessage({ text: challenge });
const signature = await openpgp.readSignature({
armoredSignature: challengeSignature,
});
const result = await openpgp.verify({
message,
signature,
verificationKeys: key,
});
await result.signatures[0].verified;Challenge должен быть случайным, короткоживущим и одноразовым. В production дополнительно включайте origin, account id и назначение в подписываемое сообщение, например Volt registration v1 | origin | accountId | nonce, и проверяйте их на сервере.
contract содержит:
{
"Terms": {
"ContractId": "...",
"Title": "TRANSFER_BALANCE",
"IssuerFingerprint": "...",
"SubjectFingerprint": "...",
"Payload": "{\"action\":\"transfer\",\"amount\":100}",
"CreatedAt": "2026-09-30T12:00:00.000Z",
"ExpiresAt": "2026-09-30T12:03:00.000Z",
"Audience": "https://example.com"
},
"Signatures": {
"<SERVER_FINGERPRINT>": "-----BEGIN PGP SIGNATURE----- ..."
}
}2) Пользователь подписывает контракт в браузере
const USER_PRIVATE_KEY = `-----BEGIN PGP PRIVATE KEY BLOCK-----\n...\n-----END PGP PRIVATE KEY BLOCK-----`;
const signedContract = await volt.contract.countersign({
contract,
privateKeyArmored: USER_PRIVATE_KEY,
passphrase: 'my-password', // если ключ зашифрован
});
console.log(signedContract);Этот результат отправляется обратно на сервер.
3) Сервер проверяет подписи и срок
const SERVER_PUBLIC_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----`;
const USER_PUBLIC_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----`;
const result = await volt.contract.proof({
contract: signedContract,
publicKeyArmored: SERVER_PUBLIC_KEY,
subjectPublicKeyArmored: USER_PUBLIC_KEY,
expectedAudience: 'https://example.com',
maxLifetimeSeconds: 300,
});
if (!result.valid) {
console.error(result.code, result.error);
throw new Error('Contract rejected');
}
console.log('valid', result.valid);
console.log('verifiedFingerprints', result.verifiedFingerprints);Поля API
volt.contract.create(options)
type CreateContractOptions = {
title: string;
payload: string;
privateKeyArmored: string;
passphrase?: string;
subjectFingerprint?: string;
audience?: string;
expiresInSeconds?: number;
expiresInDays?: number;
};Создаёт контракт и ставит подпись издателя.
volt.contract.countersign({ contract, privateKeyArmored, passphrase? })
Добавляет подпись пользователя / субъекта под тем же набором Terms.
volt.contract.proof(options)
type ProofOptions = {
contract: PgpContract | string;
publicKeyArmored: string;
subjectPublicKeyArmored?: string;
expectedAudience?: string;
maxLifetimeSeconds?: number;
clockSkewSeconds?: number;
now?: Date;
};Возвращает:
type ProofResult = {
valid: boolean;
contract?: PgpContract;
error?: string;
code?: string;
verifiedFingerprints?: string[];
};Частые коды ошибок
INVALID_STRUCTURE— контракт не распознан как объектINVALID_DATES—CreatedAt/ExpiresAtнекорректныNOT_YET_VALID— контракт создан в будущемEXPIRED— контракт уже истёкLIFETIME_TOO_LONG— контракт живёт слишком долгоAUDIENCE_MISMATCH— контракт не для этого домена / сервисаSIGNATURE_MISSING— подписанта нет вSignaturesKEY_MISMATCH— публичный ключ не подходит к fingerprintBAD_SIGNATURE— подпись не подтверждена
Важные замечания для сайта
1) Не храните приватный ключ в открытом виде на сайте
Для реального продукта приватный ключ обычно хранится:
- в браузерном расширении
- в защищённом хранилище
- в приложении с безопасным vault / keystore
Сайт должен получать только контракт и уже подписанный ответ от клиента.
2) Не забывайте про replay protection
Пакет сам проверяет подписи, но не хранит, был ли контракт уже использован. Это ваша задача:
const usedContracts = new Set();
function executeIfNotReplayed(contract) {
const id = contract.Terms.ContractId;
if (usedContracts.has(id)) {
throw new Error('Contract replay detected');
}
usedContracts.add(id);
}3) Audience надо задавать аккуратно
audience: window.location.originи на сервере проверять тем же значением:
expectedAudience: 'https://example.com'4) Максимальный срок надо ограничивать
Для авторизационных действий обычно лучше задавать короткий срок, например:
expiresInSeconds: 120
maxLifetimeSeconds: 300Рекомендуемый рабочий поток
frontend -> запрос на action
backend -> создаёт contract
frontend -> показывает approval / confirm
user wallet / browser extension -> countersign
frontend -> отправляет signed contract back
backend -> proof() + replay check + business logicЭто хорошая схема для:
- подтверждения действия пользователя
- безопасного входа / авторизации без сессий
- подписанных операций в Web2 интерфейсе
Пример для Vite + React
Создайте приложение:
npm create vite@latest volt-site -- --template react
cd volt-site
npm install
npm run devДля локальной разработки добавьте proxy в vite.config.ts, чтобы React на localhost:5173 мог обращаться к demo backend на localhost:3000:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
});Если подпись выполняет ваше Volt-расширение, пакет не нужно импортировать в React-компонент. Сайт работает через window.volt, а приватный ключ остаётся внутри расширения.
Добавьте типы для TypeScript в src/vite-env.d.ts:
interface VoltContract {
Terms: {
ContractId: string;
Title: string;
IssuerFingerprint: string;
SubjectFingerprint: string;
Payload: string;
CreatedAt: string;
ExpiresAt: string;
Audience?: string;
};
Signatures: Record<string, string>;
}
interface VoltWallet {
isVolt: boolean;
isAvailable: boolean;
getPublicKey(): Promise<string>;
signChallenge(challenge: string): Promise<string>;
signContract(contract: VoltContract): Promise<VoltContract>;
}
interface Window {
volt?: VoltWallet;
}Пример компонента src/App.tsx:
import { useState } from 'react';
type Contract = Parameters<NonNullable<Window['volt']>['signContract']>[0];
const API_URL = '';
export default function App() {
const [fingerprint, setFingerprint] = useState('');
const [contract, setContract] = useState<Contract | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
async function connectWallet() {
if (!window.volt?.isAvailable) {
setMessage('Volt extension is not installed or is disabled');
return;
}
try {
// Первый вызов открывает approval для подключения текущего origin.
const publicKey = await window.volt.getPublicKey();
const challenge = await fetch(`${API_URL}/api/register/challenge`, {
method: 'POST',
}).then((response) => response.json());
const challengeSignature = await window.volt.signChallenge(challenge.challenge);
const response = await fetch(`${API_URL}/api/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publicKey,
challengeId: challenge.challengeId,
challengeSignature,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Registration failed');
setFingerprint(data.fingerprint);
setMessage('Wallet connected');
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Connection failed');
}
}
async function requestAction() {
if (!fingerprint) {
setMessage('Connect wallet first');
return;
}
setBusy(true);
setMessage('Requesting contract...');
try {
const response = await fetch(`${API_URL}/api/contracts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'DELETE_USER_DATA',
fingerprint,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Contract request failed');
setContract(data.contract);
setMessage('Review the action, then sign it in Volt');
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Request failed');
} finally {
setBusy(false);
}
}
async function signAndExecute() {
if (!contract || !window.volt) return;
setBusy(true);
setMessage('Waiting for wallet approval...');
try {
const signedContract = await window.volt.signContract(contract);
const response = await fetch(`${API_URL}/api/execute-action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contract: signedContract }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Execution rejected');
setContract(null);
setMessage(data.logs?.join('\n') || 'Action executed');
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Signing failed');
} finally {
setBusy(false);
}
}
return (
<main>
<h1>Volt demo</h1>
<p>{fingerprint ? `Connected: ${fingerprint}` : 'Wallet is not connected'}</p>
<button onClick={connectWallet} disabled={busy}>Connect wallet</button>
<button onClick={requestAction} disabled={busy || !fingerprint}>
Request action
</button>
<button onClick={signAndExecute} disabled={busy || !contract}>
Sign and execute
</button>
<pre>{message}</pre>
</main>
);
}Для production замените API_URL на ваш HTTPS backend и не показывайте пользователю действие только по Title: перед подписью отображайте проверяемые поля Payload, Audience, сумму и получателя.
Проверка уязвимостей и подмены ключей
Что даёт украденный public key
Публичный ключ не является секретом. Его можно публиковать. Он позволяет только проверять подписи, но не создавать их.
Украденный public key не должен позволять:
- подписывать контракт от имени пользователя;
- пройти
proof()без соответствующей подписи; - заменить приватный ключ пользователя на другой.
Проверка строится на цепочке SubjectFingerprint -> зарегистрированный public key -> подпись. Сервер должен сам вычислять fingerprint из ключа при регистрации и никогда не доверять fingerprint, присланному клиентом как доказательству.
Минимальный набор негативных тестов
- Подмена public key в регистрации
Зарегистрируйте ключ A, затем отправьте в /api/contracts fingerprint A, но подпишите контракт ключом B. Ожидается BAD_SIGNATURE или SUBJECT_MISMATCH.
- Подмена
SubjectFingerprint
Получите контракт для ключа A и замените Terms.SubjectFingerprint на fingerprint B. Ожидается отказ подписи в расширении и отказ backend с ошибкой подписи.
- Подмена payload
После получения контракта замените Terms.Payload, например сумму 100 на 100000. Старая подпись должна стать недействительной, ожидается BAD_SIGNATURE.
- Подмена действия
Замените Terms.Title или Payload.action. Backend должен отклонить контракт, а не выполнять действие из изменённого payload.
- Подмена audience
Измените Terms.Audience на другой домен. Ожидается AUDIENCE_MISMATCH; расширение также должно отказать до открытия подписи.
- Replay
Отправьте один и тот же подписанный контракт два раза. Первый запрос должен завершиться успешно, второй — ALREADY_USED.
- Подмена
ContractId
Измените только Terms.ContractId или отправьте контракт, который сервер никогда не выдавал. Ожидается UNKNOWN_CONTRACT.
- Контракт с длинным сроком
Создайте контракт с большим ExpiresAt. Ожидается LIFETIME_TOO_LONG на сервере и отказ расширения.
- Повторная регистрация ключа
Проверьте, что регистрация нового ключа не заменяет ключ уже привязанного аккаунта без дополнительной авторизации аккаунта. Для production /api/register должен быть защищён существующей сессией, одноразовым challenge или другим доказательством владения аккаунтом.
Пример проверки через fetch
const original = await fetch('/api/contracts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'DELETE_USER_DATA', fingerprint }),
}).then((response) => response.json());
const tampered = structuredClone(original.contract);
tampered.Terms.Payload = JSON.stringify({ action: 'CHANGE_EMAIL' });
const result = await fetch('/api/execute-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contract: tampered }),
});
console.log(result.status, await result.json());
// Ожидается 403 и BAD_SIGNATURE либо TERMS_MISMATCH.Риски текущего demo backend
Для демонстрации текущая схема подходит, но перед production нужно заменить:
Mapдляissuedна атомарное хранилище с уникальным constraint поContractId;Mapи JSON-файл пользователей на БД с контролем доступа;- хранение server private key в файле на secret manager / KMS;
ORIGINиз переменной окружения на строгий allowlist доменов;- простейший IP rate limit на Redis / gateway;
- регистрацию public key без дополнительной привязки к аккаунту на authenticated enrollment flow.
Главный вывод: public key можно украсть, это не компрометация. Компрометация начинается при краже приватного ключа, обходе approval в расширении, подмене серверного public key или при ошибке backend, который принимает fingerprint / payload без проверки подписей.
Примечание по формату контрактов
Контракт подписывается от канонического JSON JSON.stringify(Terms). Поэтому важно:
- не менять порядок полей после создания
- передавать тот же объект, который вернул
create() - не перезаписывать JSON вручную
Лицензия
MIT
