Как это работает
Шифрование происходит в браузере до отправки на сервер. Сервер получает только зашифрованный 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.
Формат данных
Ключ шифрования получается через 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