Как это работает
Шифрование происходит в браузере до отправки на сервер. Сервер получает только зашифрованный 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
Кешируй локально после первого запроса. Ответ кешируется CDN на 24 часа.
POST /api/secrets — создать секрет
Заголовки
Тело запроса
Ответ 200
Ошибки
400 — невалидный запрос, blob > 20 000 симв., файл > 7 МБ, файлов > 5
429 — rate limit (10 / IP / час)
503 — сервер заполнен (лимит 100 секретов)
POST /api/secrets/{id} — получить секрет
Тело запроса
Ответ 200
Ошибки
404 — не найден, истёк 15-минутный срок, неверный keyHash или удалён (11 промахов)
429 — rate limit (20 / IP / час)
Первый успешный запрос запускает 15-минутный таймер. Повторный запрос с тем же keyHash в этом окне вернёт тот же revealToken и expiresAt без сброса таймера. Любая неудачная попытка возвращает 404.
DELETE /api/secrets/{id} — сжечь досрочно
Заголовки
Ответ 200
Ошибки
404 — нет токена, истёк срок или секрет уже удалён
GET /api/secrets/{id}/files/{index} — скачать файл
Заголовки
Ответ 200
Ошибки
400 — невалидный index
404 — нет токена, истёк срок, файл не найден
Файлы скачиваются по одному. При обрыве сети можно повторить запрос того же index, пока не истёк expiresAt.
Markdown в тексте
Текст секрета может быть в Markdown. Это касается только отображения: на сервер по-прежнему уходит зашифрованный blob, никакого флага формата нет. Клиент сам решает, рендерить ли текст (веб-страница по умолчанию рендерит Markdown, переключатель "Текст" показывает сырой текст).
Поддерживается: заголовки, списки, цитаты, код, жирный, курсив, зачёркнутый, ссылки, картинки. HTML внутри текста не интерпретируется.
Прикреплённые файлы вставляются в текст по имени. Имя берётся из meta.name файла (см. формат ниже), сравнивается как есть. Префикс attachment: необязателен. Картинкой файл считается по meta.type, а если он пустой, по расширению.
 // картинка из вложения, покажется inline [Скачать конфиг](attachment:config.yml) // ссылка на вложение, скачивание [Документация](https://example.com) // обычная внешняя ссылка  // внешние картинки НЕ грузятся, будет ссылка
Внешние картинки нарочно не загружаются: запрос к чужому серверу выдал бы факт и момент открытия секрета. Ссылки разрешены только на 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