Как это работает

Шифрование происходит в браузере до отправки на сервер. Сервер получает только зашифрованный blob и SHA-256 хэш ключа.

Ключ — фраза из 1–5 случайных русских слов через точку. Из неё через HKDF (SHA-256) получается AES-256 ключ. Ключ передаётся во фрагменте ссылки (после #), который браузер не отправляет на сервер — сервер видит только хэш ключа для верификации.

ID секрета — два слова через точку. Нераскрытый секрет живёт 7 дней. После раскрытия — 15 минут на скачивание, затем удаляется вместе с файлами. После 11 неверных попыток секрет удаляется.

Текст и файлы отдаются раздельно: сначала blob, затем каждый файл отдельным запросом со стримингом с диска.

URL:  ss.spagin.dev/береза.молоко#кот.река
                   └──── id ────┘ └ passphrase (1–5 слов) ┘
                                  ▲ часть после # браузер на сервер не отправляет

GET /api/words — словарь

Ответ 200

[]string[]~15 000 русских слов для генерации id и passphrase на клиенте

Кешируй локально после первого запроса. Ответ кешируется CDN на 24 часа.

POST /api/secrets — создать секрет

Заголовки

Idempotency-Keystringуникальный ключ запроса (1–128 симв.); повторный запрос с тем же ключом вернёт тот же id без создания дубликата

Тело запроса

blobstringобязательноbase64(iv[12 байт] + AES-GCM ciphertext)
keyHashstringобязательноSHA-256(HKDF-ключ) в hex, 64 символа
filesstring[]до 5 зашифрованных файлов, каждый до 5 МБ (base64); на сервере сохраняются на диск

Ответ 200

idstringID секрета в виде двух русских слов через точку

Ошибки

400 — невалидный запрос, blob > 20 000 симв., файл > 7 МБ, файлов > 5

429 — rate limit (10 / IP / час)

503 — сервер заполнен (лимит 100 секретов)

POST /api/secrets/{id} — получить секрет

Тело запроса

keyHashstringобязательноSHA-256(HKDF-ключ) в hex, 64 символа

Ответ 200

blobstringзашифрованный текст (base64)
revealTokenstringтокен для GET /files/{index}
expiresAtnumberunix ms — момент удаления секрета
files{index, size}[]список файлов без содержимого; index — для запроса файла, size — размер в байтах

Ошибки

404 — не найден, истёк 15-минутный срок, неверный keyHash или удалён (11 промахов)

429 — rate limit (20 / IP / час)

Первый успешный запрос запускает 15-минутный таймер. Повторный запрос с тем же keyHash в этом окне вернёт тот же revealToken и expiresAt без сброса таймера. Любая неудачная попытка возвращает 404.

DELETE /api/secrets/{id} — сжечь досрочно

Заголовки

X-Reveal-TokenstringобязательноrevealToken из POST /api/secrets/{id}

Ответ 200

okbooleanсекрет и файлы удалены с сервера

Ошибки

404 — нет токена, истёк срок или секрет уже удалён

GET /api/secrets/{id}/files/{index} — скачать файл

Заголовки

X-Reveal-TokenstringобязательноrevealToken из POST /api/secrets/{id}

Ответ 200

bodyapplication/octet-streamзашифрованный файл бинарно: iv[12] + AES-GCM ciphertext; стримится с диска
Content-Lengthheaderразмер тела в байтах

Ошибки

400 — невалидный index

404 — нет токена, истёк срок, файл не найден

Файлы скачиваются по одному. При обрыве сети можно повторить запрос того же index, пока не истёк expiresAt.

Markdown в тексте

Текст секрета может быть в Markdown. Это касается только отображения: на сервер по-прежнему уходит зашифрованный blob, никакого флага формата нет. Клиент сам решает, рендерить ли текст (веб-страница по умолчанию рендерит Markdown, переключатель "Текст" показывает сырой текст).

Поддерживается: заголовки, списки, цитаты, код, жирный, курсив, зачёркнутый, ссылки, картинки. HTML внутри текста не интерпретируется.

Прикреплённые файлы вставляются в текст по имени. Имя берётся из meta.name файла (см. формат ниже), сравнивается как есть. Префикс attachment: необязателен. Картинкой файл считается по meta.type, а если он пустой, по расширению.

![Скриншот](screenshot.png)          // картинка из вложения, покажется inline
[Скачать конфиг](attachment:config.yml)  // ссылка на вложение, скачивание
[Документация](https://example.com)   // обычная внешняя ссылка
![Лого](https://example.com/logo.png) // внешние картинки НЕ грузятся, будет ссылка

Внешние картинки нарочно не загружаются: запрос к чужому серверу выдал бы факт и момент открытия секрета. Ссылки разрешены только на http, https, mailto, tel.

Формат данных

Ключ шифрования получается через HKDF из фразы:

HKDF(
  ikm  = UTF-8(passphrase),          // "кот.река"
  salt = UTF-8("ss.spagin.dev"),
  info = UTF-8("secret"),
  hash = SHA-256,
  len  = 32 bytes                    // → AES-256 ключ
)

keyHash = hex( SHA-256( hkdf_key_bytes ) )  // 64 символа — хранится на сервере

blob — AES-256-GCM ciphertext:

blob = base64( iv[12 байт] + AES-GCM ciphertext )

files[i] при создании — base64, при скачивании — тот же формат, но бинарно:

upload:  files[i] = base64( iv[12] + AES-GCM ciphertext(...) )
download: body    =        iv[12] + AES-GCM ciphertext(...)

ciphertext содержит:
  metaLen[4 байта, LE uint32] +
  meta[UTF-8 JSON: {name, type}] +
  fileBytes

Пример на TypeScript

const enc = new TextEncoder()
const BASE = 'https://ss.spagin.dev'

let wordsCache: string[] | null = null
let wordsLoading: Promise<string[]> | null = null

async function getWords(): Promise<string[]> {
  if (wordsCache) return wordsCache
  if (!wordsLoading) {
    wordsLoading = fetch(`${BASE}/api/words`).then(async (r) => {
      if (!r.ok) throw new Error('Failed to load words')
      wordsCache = await r.json()
      return wordsCache!
    })
  }
  return wordsLoading
}

function pickWords(words: string[], count: number): string[] {
  return Array.from({ length: count }, () =>
    words[crypto.getRandomValues(new Uint32Array(1))[0] % words.length]
  )
}

async function generatePassphrase(): Promise<string> {
  const count = 1 + crypto.getRandomValues(new Uint32Array(1))[0] % 5
  return pickWords(await getWords(), count).join('.')
}

async function deriveRawKey(passphrase: string): Promise<ArrayBuffer> {
  const ikm = await crypto.subtle.importKey(
    'raw', enc.encode(passphrase), 'HKDF', false, ['deriveKey']
  )
  const key = await crypto.subtle.deriveKey(
    { name: 'HKDF', hash: 'SHA-256',
      salt: enc.encode('ss.spagin.dev'), info: enc.encode('secret') },
    ikm, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
  )
  return crypto.subtle.exportKey('raw', key)
}

function bufToHex(b: ArrayBuffer) {
  return Array.from(new Uint8Array(b)).map(x => x.toString(16).padStart(2,'0')).join('')
}
function bufToBase64(b: Uint8Array) {
  let s = ''; for (const x of b) s += String.fromCharCode(x); return btoa(s)
}
function base64ToBuf(s: string) {
  return Uint8Array.from(atob(s), c => c.charCodeAt(0))
}

// 1. Создать секрет
async function createSecret(plaintext: string, passphrase: string) {
  const raw = await deriveRawKey(passphrase)
  const key = await crypto.subtle.importKey('raw', raw, { name:'AES-GCM' }, false, ['encrypt'])
  const iv  = crypto.getRandomValues(new Uint8Array(12))
  const ct  = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key, enc.encode(plaintext))

  const blob = new Uint8Array(12 + ct.byteLength)
  blob.set(iv); blob.set(new Uint8Array(ct), 12)

  const keyHash = bufToHex(await crypto.subtle.digest('SHA-256', raw))

  const res  = await fetch(`${BASE}/api/secrets`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ blob: bufToBase64(blob), keyHash }),
  })
  const { id } = await res.json()
  return `${BASE}/${id}#${passphrase}`
}

// 2. Раскрыть секрет по ссылке
async function revealSecret(url: string) {
  const u = new URL(url)
  const id = decodeURIComponent(u.pathname.slice(1))
  const passphrase = decodeURIComponent(u.hash.slice(1))

  const raw     = await deriveRawKey(passphrase)
  const keyHash = bufToHex(await crypto.subtle.digest('SHA-256', raw))
  const key     = await crypto.subtle.importKey('raw', raw, { name:'AES-GCM' }, false, ['decrypt'])

  const res = await fetch(`${BASE}/api/secrets/${encodeURIComponent(id)}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ keyHash }),
  })
  const { blob, revealToken, expiresAt, files } = await res.json()

  const data = base64ToBuf(blob)
  const text = new TextDecoder().decode(await crypto.subtle.decrypt(
    { name:'AES-GCM', iv: data.slice(0,12) }, key, data.slice(12)
  ))

  const decryptedFiles = []
  for (const f of files ?? []) {
    const fileRes = await fetch(`${BASE}/api/secrets/${encodeURIComponent(id)}/files/${f.index}`, {
      headers: { 'X-Reveal-Token': revealToken },
    })
    const enc = new Uint8Array(await fileRes.arrayBuffer())
    const pt  = new Uint8Array(await crypto.subtle.decrypt(
      { name:'AES-GCM', iv: enc.slice(0,12) }, key, enc.slice(12)
    ))
    const metaLen = new DataView(pt.buffer).getUint32(0, true)
    const meta = JSON.parse(new TextDecoder().decode(pt.slice(4, 4 + metaLen)))
    decryptedFiles.push({ ...meta, data: pt.slice(4 + metaLen) })
  }

  return { text, files: decryptedFiles, expiresAt }
}

// Пример:
const passphrase = await generatePassphrase()
const link = await createSecret('hunter2', passphrase)
// → https://ss.spagin.dev/береза.молоко#кот.река

const { text, expiresAt } = await revealSecret(link)
// text → hunter2, скачай всё до expiresAt

Лимиты

Макс. секретов на сервере100
TTL нераскрытого секрета7 дней
TTL после раскрытия15 минут
Удаление после ошибок11 неверных попыток
Rate limit — создание10 / IP / час
Rate limit — чтение20 / IP / час
Макс. размер blob20 000 символов
Макс. файлов5
Макс. размер файла5 МБ