npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@qotodev/volt-pgp

v1.4.0

Published

PGP-based smart contract authentication and proof library for Web2

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');

Основная идея

  1. Сервер создаёт контракт с Terms и подписывает его серверным ключом.
  2. Пользователь подписывает тот же контракт своим ключом в браузере / расширении.
  3. Сервер проверяет:
    • структуру контракта
    • даты 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 регистрации:

  1. Backend создаёт одноразовый случайный challenge.
  2. Кошелёк показывает пользователю origin сайта и challenge.
  3. Пользователь подтверждает действие и вводит пароль.
  4. Кошелёк подписывает challenge private key.
  5. 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 — подписанта нет в Signatures
  • KEY_MISMATCH — публичный ключ не подходит к fingerprint
  • BAD_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, присланному клиентом как доказательству.

Минимальный набор негативных тестов

  1. Подмена public key в регистрации

Зарегистрируйте ключ A, затем отправьте в /api/contracts fingerprint A, но подпишите контракт ключом B. Ожидается BAD_SIGNATURE или SUBJECT_MISMATCH.

  1. Подмена SubjectFingerprint

Получите контракт для ключа A и замените Terms.SubjectFingerprint на fingerprint B. Ожидается отказ подписи в расширении и отказ backend с ошибкой подписи.

  1. Подмена payload

После получения контракта замените Terms.Payload, например сумму 100 на 100000. Старая подпись должна стать недействительной, ожидается BAD_SIGNATURE.

  1. Подмена действия

Замените Terms.Title или Payload.action. Backend должен отклонить контракт, а не выполнять действие из изменённого payload.

  1. Подмена audience

Измените Terms.Audience на другой домен. Ожидается AUDIENCE_MISMATCH; расширение также должно отказать до открытия подписи.

  1. Replay

Отправьте один и тот же подписанный контракт два раза. Первый запрос должен завершиться успешно, второй — ALREADY_USED.

  1. Подмена ContractId

Измените только Terms.ContractId или отправьте контракт, который сервер никогда не выдавал. Ожидается UNKNOWN_CONTRACT.

  1. Контракт с длинным сроком

Создайте контракт с большим ExpiresAt. Ожидается LIFETIME_TOO_LONG на сервере и отказ расширения.

  1. Повторная регистрация ключа

Проверьте, что регистрация нового ключа не заменяет ключ уже привязанного аккаунта без дополнительной авторизации аккаунта. Для 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