Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SSMPL — 静态站·多密码锁 / Static Site · Multi-Password Lock

中文   ·   English


中文

给纯静态网站(GitHub Pages 这类没有后端的托管)加“密码门禁”的小工具。核心就一句:整站可以被任何人下载,但没有密码就读不出正文;密码本身是单向的、不落盘、不上传;正文用的是可逆的对称加密,有授权就能看。

三套语言实现,同一套算法、互相能加解密:JavaScript(浏览器用 WebCrypto,Node 也能跑)· Python · C++(OpenSSL)。

变体一览

同一份代码库、四个 payload 版本。版本号即算法标识,四个版本互不兼容:

变体 payload 基础 一句话
SSMPL-EA ssmpl:1 — 基础方案:密本 = P ⊕ K。最简、开销最小、三语言互通最成熟
SSMPL-BD ssmpl:2 — 后防改型:用户名参与派生,payload 里不存任何秘密
SSMPL-TEA ssmpl:3 EA 时基检验:抓下来的副本过一阵子自己失效
SSMPL-TBD ssmpl:4 BD 时基检验 + 用户名绑定

T 系列(ssmpl:3 / ssmpl:4)自带有效期与代码混淆,属于混淆强度而非密码学强度,单独成篇 → docs/SSMPL-T.md

文档导航

文档 内容
docs/SPEC.md EA 协议规格:在线载荷格式、API、边界
docs/SPEC-BD.md BD 协议规格:派生链、决策记录、不变量、诚实边界
docs/BD-测试报告.md BD 构建 / 攻击 / 可用性测试报告(268 条断言)
docs/SSMPL-T.md T 系列设计文档(时基检验 / 自校验算子 / 混淆管线,285 条断言)
docs/原理解析与优势.md 原方案逐行走读
cpp/BUILD-NOTES.md C++ 构建说明(含"只有 libcrypto DLL、没有开发头文件"的 Windows 路线)

它好在哪

  • 纯静态就能跑。 不需要服务器、不需要数据库,也就没有服务器要运维、没有后端可被黑。
  • 是真加密,不是糊弄。 正文用 AES-256-GCM(或 AES-256-CBC + HMAC),不是 base64,也不是写死一个密钥的异或。
  • 多密码 / 换密码都不用重加密。 文章用随机密钥 K 加密,密码只通过「密本」去包 K。所以加一个新密码或换密码,只要重算一遍密本 P ⊕ K,文章密文一个字节都不用动。
  • 密码不会离开用户的浏览器。 密码只在本地做单向派生(PBKDF2),不落盘、不上传,也就没有可被拖库的后端。
  • 载荷很省。 每篇只存一份密文,是 O(篇数),不是 O(用户数 × 篇数)。换一次钥的成本就是一次异或。
  • 关键参数都能调。 KDF 哈希(SHA-256/512)、迭代次数、加密方式(GCM / CBC+HMAC)都能改。

算法简说

存:  正文 --AES-256-GCM(随机密钥 K)--> 密文            (双向,能还原)
密码:密码 --PBKDF2-HMAC(盐, 迭代n)--> P (32字节)       (单向,拿不回密码)
密本:密本 = P ⊕ K   (“加密后的密码”与“密钥”的差,公开存)
读:  输入密码 -> P -> K = P ⊕ 密本 -> AES 解密
      如果这篇有多个密本(多密码),解密失败就试下一个
  • 一篇正文只用一个随机 K 加密;密码只负责“包住”K。
  • 加/换密码:给同一篇换个密码,再算一个新密本塞进 books 数组即可,正文不动。
  • 所以密本依赖的只有密码的强弱和迭代次数,跟正文密文长度无关。

版本区别:EA ↔ BD

两者共用同一套「随机 K + 可逆密本」骨架,区别只在凭证怎么来:

EA(ssmpl:1) BD(ssmpl:2)
凭证来源 单级 KDF:P = PBKDF2(pw, salt, iters) 多级派生链(见下)
用户名 不参与 参与,且不进 payload
同一密码的两个用户 撞车:凭证相同、密本字节相同 —— 分不开,也无法单独撤销 互不影响
payload 泄露的用户信息 每篇有几条密本(= 每篇几个被授权人) 连"有几个用户"都看不出来
校验值 / 用户清单 无 无(明确不引入)
包装 book = P ⊕ K book = C ⊕ K(同一个骨架)
AAD 无 绑定篇名
载荷规模 密文 O(篇数)、密本 O(授权条数) 相同

BD 的派生链(‖ 为拼接;全链只有第一环不可逆):

EP = PBKDF2-HMAC(grow(pw), salt, iterations, 32)     ← 唯一不可逆环
LP = HMAC-SHA256(EP, "SSMPL-BD|LP|"    ‖ salt)
Ku = HKDF-SHA256(EP, salt, "SSMPL-BD|uname", 32)
Eu = HMAC-SHA256(Ku, "SSMPL-BD|uname|" ‖ norm(u))
C  = HMAC-SHA256(LP, "SSMPL-BD|C|"     ‖ Eu)         ← 32 字节凭证,永不落盘

三条要点:

  1. BD 修掉的是 EA 唯一的"功能性"局限 —— 旧版 README 把"每个用户必须用不同密码"列为限制,BD 之后不再需要。
  2. BD 的 payload 里不存任何秘密:没有用户清单、没有校验值、没有加密用户名。于是"入侵数据库"本身无利可图 —— 没有用户自己的密码,拿到的东西全是乱码。
  3. 两者都不存在通用凭证:K 每篇独立随机且从不出现,想全开就得拥有每一篇对应的凭证。

命名:本项目的基础方案(原 v1)现在叫 SSMPL-EA,它的代码一个字节都没改;SSMPL-BD 是后来新增的并列实现。两者必须按 ssmpl 字段分流,不能靠空值/默认值兼容(会派生出不同的凭证,老密码直接打不开)。

选型建议

你的情况 推荐
一个人或少数几个人用,只要"没密码读不出正文" EA —— 最简、开销最小、三语言实现最成熟
多用户共享一个静态站,要"按篇、按人"给权限与撤销 BD —— 同密码的不同用户不再撞车,能单独撤销
不想在公开载荷里暴露"有几个用户" BD
需要"抓下来的副本过一阵子失效" TEA / TBD → docs/SSMPL-T.md
需要真正的账号鉴权、服务器端吊销 以上都不是 —— 纯静态做不到,需要后端

四者的正文加密完全相同(AES-256-GCM,每篇一个随机 K),差别只在派生链长度:BD 比 EA 多一次 HKDF + 三次 HMAC(微秒级),可以忽略。

按文章、按用户给权限(这个才是重点)

每篇文章的 books(密本列表)是彼此独立的,同一个人在不同篇里可以有不同的密本或没有密本:

  • 授权某人看某篇:add_password(那篇item, 那人的密码, 管理员密码, cfg) —— 往这一篇塞一个该用户的密本。
  • 不让某人看某篇:那一篇的 books 里不放他的密本 —— 他算不出 K,读不了这篇。
  • 收回某人的某篇:remove_password(那篇item, 那人的密码, cfg) —— 只删这一篇里属于他的那条密本,正文不动。

所以一个公开的 JSON 就能做到「张三能看 A、B,李四只能看 B,管理员全看」:

blob.items.A = await removePassword(await addPassword(itemA, 'zhang', 'admin', cfg), 'li', cfg);
blob.items.B = await removePassword(await addPassword(itemB, 'li',    'admin', cfg), 'zhang', cfg);

硬要挑刺的边界(照样写清楚):

  • 每个“用户”得用不同的密码(不然两个用户共用同一个密本,分不开)。
  • 收回来的是密本,不是把密文改掉——所以重新托管 JSON 后,之前已经拿到并保存的密本的人仍能看(静态站没法“远程踢人”)。
  • 用户之间可以转借密码/密本,静态站拦不住。
  • 依然是离线可穷举弱密码,强口令 + 高迭代才是安全感来源。

在线载荷格式(托管到站点的那份 JSON)

{
  "ssmpl": 1,
  "cipher": "AES-256-GCM",            // 或 "AES-256-CBC-HMAC-SHA256"
  "kdf": "PBKDF2-HMAC",
  "hash": "SHA-256",                  // 或 "SHA-512"
  "iterations": 250000,
  "salt": "<base64>",
  "items": {
    "<名称>": { "iv": "...", "ct": "...", "tag": "..." 或 "mac": "...",
                "books": ["<base64 密本>", ...] }        // 一个密码对应一个密本
  }
}

配置(三个语言都能设)

字段 值 说明
hash SHA-256 / SHA-512 密码 KDF 的哈希
iterations 整数 KDF 代价,越大越抗离线爆破
cipher AES-256-GCM / AES-256-CBC-HMAC-SHA256 正文加密方式(推荐 GCM,带认证)
salt bytes 公开盐,一个 lock 固定一份

上手

JavaScript(js/src/ssmpl.js,浏览器 / Node ≥18 通用)

import { Lock, encryptItem, addPassword, b64e } from './src/ssmpl.js';

// 后端:加密 + 再加一个密码
const salt = crypto.getRandomValues(new Uint8Array(16));
const cfg  = { hash:'SHA-256', iterations:250000, cipher:'AES-256-GCM', salt };
let item   = await encryptItem('正文…', '密码A', cfg);
item       = await addPassword(item, '密码B', '密码A', cfg);   // 不重加密

const blob = { ssmpl:1, cipher:cfg.cipher, kdf:'PBKDF2-HMAC', hash:cfg.hash,
               iterations:cfg.iterations, salt:b64e(salt), items:{ story:item } };

// 前端:使用者解锁并解密
const lock = new Lock(JSON.parse(JSON.stringify(blob)));
await lock.unlock('密码B');                 // 密码错则返回 false
const text = await lock.decrypt('story');

Python(python/ssmpl/)

from ssmpl import Config, encrypt_item, add_password, Lock
cfg = Config(hash="SHA-256", iterations=250000, cipher="AES-256-GCM", salt=bytes(range(16)))
item = encrypt_item("正文…", "密码A", cfg)
item = add_password(item, "密码B", "密码A", cfg)
lock = Lock({"ssmpl":1,"cipher":cfg.cipher,"kdf":"PBKDF2-HMAC","hash":cfg.hash,
             "iterations":cfg.iterations,"salt":b64e(cfg.salt),"items":{"story":item}})
lock.unlock("密码B"); print(lock.decrypt("story"))

C++(cpp/,用 CMake 构建,需 OpenSSL)

cd cpp && cmake -B build && cmake --build build
./build/test_ssmpl        # 自测:往返 + 多密码 + GCM/CBC
./build/ssmpl_cli encrypt 密码A article.txt
./build/ssmpl_cli addpass ssmpl-lock.json article.txt 密码B 密码A
./build/ssmpl_cli decrypt ssmpl-lock.json article.txt 密码B

测试

node js/test/test.js                # JS 16 用例
py -3.9 python/tests/test_ssmpl.py  # Python 16 用例
node js/test/interop.mjs            # JS <-> Python 互通(GCM/CBC 双向)

命名

  • SSMPL-EA —— 本项目的基础方案(payload ssmpl: 1),就是上面这套「密本 = P ⊕ K」。
  • SSMPL-BD —— 后防改型(payload ssmpl: 2),见下。

SSMPL-BD(backdefense 改型,ssmpl: 2)

在原方案背后加一条"后防"链。要点是:payload 里不存放任何秘密——没有用户清单、没有校验值、 没有加密用户名,只有密文和密本。于是"入侵数据库"本身无利可图:拿到的东西没有用户自己的密码就全是乱码。

norm(s) = UTF-8(NFC(s))            grow(b) = uint32be(len(b)) || b
EP = PBKDF2(grow(pw), salt, iterations, 32)      ← 全链唯一不可逆环
LP = HMAC(EP, "SSMPL-BD|LP|"   || salt)
Ku = HKDF(EP, salt, "SSMPL-BD|uname")
Eu = HMAC(Ku, "SSMPL-BD|uname|"|| norm(u))       ← 加密用户名:秘密,不落盘
C  = HMAC(LP, "SSMPL-BD|C|"    || Eu)            ← 凭证 32B:秘密,不落盘
K_i = 32B 随机(每篇一个)        book = C ⊕ K_i   ← 公开、可逆
读:算出 C → K_i = C ⊕ book → AES-256-GCM(用 ABDD tag 判定对错)
  • 修掉了 v1 的一条旧局限:同一密码的不同用户不再撞车(用户名参与派生);

  • payload 里连"有哪些用户"都看不出来(比 v1 更严:v1 至少泄露每篇有几个被授权人);

  • K_i 每篇独立随机且从不出现 ⇒ 不存在"一把钥匙开全部"的通用凭证。

  • 规格:docs/SPEC-BD.md

  • 测试报告:docs/BD-测试报告.md(三语言 268 条断言全部通过)

node js/test/test-bd.js             # JS 功能 46
node js/test/attack-bd.js           # JS 攻击 42
node js/test/gen-vectors-bd.js      # 生成跨语言测试向量(可复现)
node js/test/interop-bd.mjs         # Python -> JS 反向验证

python python/tests/test_bd.py      # Python 功能 45
python python/tests/attack_bd.py    # Python 攻击 41
python python/tests/interop_bd.py   # JS -> Python 验证(并产出反向验证用的 payload)

cpp/ 是按标准 OpenSSL 写的。本仓库已在 MSVC + OpenSSL 3.5.7 运行时 DLL 上编译并跑通 test_ssmpl_bd(35 条断言,与 JS/Python 的测试向量逐字节一致)。 只有运行时 DLL、没有开发头文件时的构建办法见 cpp/BUILD-NOTES.md。

SSMPL-T(时基检验改型,ssmpl:3 = TEA / ssmpl:4 = TBD)

给纯静态站点加有效期:抓下来的副本会在一个时间窗之后自己失效。

  • 时间窗并入慢 KDF 的盐 —— 每试一个窗口都要付一次完整 PBKDF2,不是一次异或;
  • payload 里没有任何时间字段 —— 与普通 EA/BD payload 逐字节同构,从数据本身看不出用了时基;
  • 自校验算子 —— 附在解密算子最后,无分支、无报错,把自身代码的 SHA-256 直接喂进派生链: 代码被改 → 密钥不对 → 静默解不开;
  • JS 代码混淆作为构建步骤(控制流平坦化 + 字符串数组 + 标识符改名,seed=0 可复现构建)。

⚠️ 这一层是混淆强度,不是密码学强度。 看穿机制的人有三条零成本绕过(改系统时钟、枚举窗口、 硬编码正确摘要)——它们被写成**断言"绕过成功"**的测试用例,而不是被藏起来。

  • 设计文档:docs/SSMPL-T.md
  • 测试:285 条断言(功能 55+45 / 攻击 35+32 / 互通 32+17 / C++ 47 / 混淆产物 22)
node js/test/test-t.js
node js/test/attack-t.js
python python/tests/test_t.py
python python/tests/attack_t.py
python python/tests/interop_t.py && node js/test/interop-t.mjs
cd js && npm install && npm run build && node test/smoke-dist-t.js   # 混淆产物 + 自校验

别过度期待的地方

这是静态站的访问门禁 + 口令级权限:能用「这篇的 books 里有没有某人的密本」做到按文章、按用户给权限(见上一节)。但它不是服务器式账号系统——没有登录态,不能“远程踢掉”已经拿到密本的人,也拦不住用户把密码/密本转借他人。载荷公开意味着弱密码可被离线穷举,安全感 = 密码强度 × 迭代次数。要真正的账户鉴权 + 服务器端吊销,那得用后端。

详见 docs/SPEC.md。


English

A small toolkit that adds password gates to a fully static site (GitHub Pages and similar — no backend). The gist: anyone can download the whole site, but without a valid password the content is unreadable. The password is one-way, never stored, never uploaded; the content uses reversible symmetric encryption, so an authorized reader gets it back.

Three implementations of the same, interoperable spec: JavaScript (WebCrypto in browsers, also Node) · Python · C++ (OpenSSL).

Variants at a glance

One codebase, four payload versions. The version number is the algorithm identifier — they are not interchangeable:

Variant payload Built on One-liner
SSMPL-EA ssmpl:1 — Base scheme: cipher-book = P ⊕ K. Simplest, cheapest, most mature across languages
SSMPL-BD ssmpl:2 — Backdefense variant: the username joins the derivation, the payload stores no secret at all
SSMPL-TEA ssmpl:3 EA Time gate: a scraped copy expires by itself
SSMPL-TBD ssmpl:4 BD Time gate + username binding

The T series ships an expiry window and code obfuscation — obfuscation grade, not cryptographic grade. Documented separately → docs/SSMPL-T.md

Documentation

Doc What it covers
docs/SPEC.md EA protocol spec: hosted payload, API, limits
docs/SPEC-BD.md BD protocol spec: derivation chain, decisions, invariants, honest limits
docs/BD-测试报告.md BD build / attack / usability report (268 assertions)
docs/SSMPL-T.md T-series design doc (time gate / self-check operator / obfuscation pipeline, 285 assertions)
cpp/BUILD-NOTES.md C++ build notes, incl. the "libcrypto DLL but no dev headers" route

What it gets you

  • Runs on static hosting. No server, no database — nothing to operate, nothing on the server side to breach.
  • Real crypto, not obfuscation. AES-256-GCM (or AES-256-CBC + HMAC) for the content — not base64, not a hard-coded XOR key.
  • Add or change a password without re-encrypting. Content is encrypted with a random key K; passwords only wrap K via the cipher-book (密本). Add a password or change one by recomputing cipher-book = P ⊕ K — the ciphertext doesn't change at all.
  • The password never leaves the browser. It's only hashed locally (PBKDF2), nothing is stored or transmitted, so there's no backend to leak from.
  • Cheap payload. One ciphertext per item → O(items), not O(users × items). A re-key costs one XOR.
  • Configurable. KDF hash (SHA-256/512), iteration count, and cipher (GCM / CBC+HMAC) are all settable.

Algorithm, briefly

store:   plaintext --AES-256-GCM(random key K)--> ciphertext     (two-way, reversible)
password: password --PBKDF2-HMAC(salt, iters)--> P (32 bytes)    (one-way, irreversible)
book:    cipher-book = P ⊕ K   (difference of the hashed password and the content key)

read:    type password -> P -> K = P ⊕ cipher-book -> AES decrypt
         if the item has several books (multi-password), a failed key tries the next
  • One random K encrypts each piece of content; a password only "wraps" K.
  • Add/change a password: recompute a new cipher-book for the same K and push it into books — the content stays untouched.
  • Security therefore depends on password strength and KDF cost, not on the ciphertext length.

EA vs BD: what actually differs

Both share the same skeleton — a random per-item K wrapped by a reversible cipher-book. Only the credential changes:

EA (ssmpl:1) BD (ssmpl:2)
Credential single KDF: P = PBKDF2(pw, salt, iters) a derivation chain (below)
Username not used used, and never stored in the payload
Two users, same password collide: identical credential, byte-identical books — indistinguishable, cannot be revoked separately independent
What the payload leaks how many books each article has (= how many readers) not even how many users exist
Verifier / user list none none (deliberately)
Wrapping book = P ⊕ K book = C ⊕ K (same skeleton)
AAD none binds the item id
Payload size ciphertext O(items), books O(grants) identical
EP = PBKDF2-HMAC(grow(pw), salt, iterations, 32)     <- the only one-way step
LP = HMAC-SHA256(EP, "SSMPL-BD|LP|"    ‖ salt)
Ku = HKDF-SHA256(EP, salt, "SSMPL-BD|uname", 32)
Eu = HMAC-SHA256(Ku, "SSMPL-BD|uname|" ‖ norm(u))
C  = HMAC-SHA256(LP, "SSMPL-BD|C|"     ‖ Eu)         <- 32-byte credential, never stored

Naming: the base scheme (formerly v1) is now called SSMPL-EA and its code is unchanged; SSMPL-BD is a parallel implementation added later. Route by the ssmpl field — never by defaults, or you will derive a different credential and old passwords will simply stop working.

Which one should I use?

Your situation Pick
One or a few people; you only need "no password, no content" EA — simplest, cheapest, most mature
Many users on one static site, with per-article grants and revocation BD — same password no longer collides, revoke individually
You do not want the public payload to reveal how many users exist BD
You need scraped copies to expire TEA / TBD → docs/SSMPL-T.md
You need real account auth or server-side revocation None of these — that needs a backend

Per-article, per-user access control (the real point)

Each item's books (cipher-books) is independent — the same person can have a 密本 in one article and no 密本 in another.

  • Grant a user an article: add_password(thatItem, thatUserPassword, adminPassword, cfg) — put one of that user's cipher-books into that article.
  • Deny a user an article: just don't put their cipher-book in that article's books — they can't recover K, so they can't read it.
  • Revoke a user's article: remove_password(thatItem, thatUserPassword, cfg) — drop only their cipher-book from that item; the content stays untouched.

So one public JSON can express "张三 reads A/B, 李四 reads only B, admin reads all":

blob.items.A = await removePassword(await addPassword(itemA, 'zhang', 'admin', cfg), 'li', cfg);
blob.items.B = await removePassword(await addPassword(itemB, 'li',    'admin', cfg), 'zhang', cfg);

Honest limits, stated plainly:

  • Each "user" needs a distinct password (otherwise two users share the same cipher-book and can't be told apart).
  • You revoke the cipher-book, not the ciphertext — so anyone who already downloaded a cipher-book keeps reading; a static site can't "kick" someone remotely.
  • Users can hand their password / cipher-book to someone else; a static site can't stop it.
  • Weak passwords are still offline-attackable; a strong password + high iteration count is what buys real safety.

Hosted payload (the JSON you publish)

{
  "ssmpl": 1,
  "cipher": "AES-256-GCM",            // or "AES-256-CBC-HMAC-SHA256"
  "kdf": "PBKDF2-HMAC",
  "hash": "SHA-256",                  // or "SHA-512"
  "iterations": 250000,
  "salt": "<base64>",
  "items": {
    "<name>": { "iv": "…", "ct": "…", "tag": "…" or "mac": "…",
                "books": ["<base64 cipher-book>", …] }   // one per authorized password
  }
}

Config (each language)

field values notes
hash SHA-256 / SHA-512 one-way password KDF hash
iterations integer KDF cost; higher = slower offline brute-force
cipher AES-256-GCM / AES-256-CBC-HMAC-SHA256 content cipher (GCM is authenticated, recommended)
salt bytes public salt, fixed per lock

Quickstart

JavaScript (js/src/ssmpl.js, browser / Node ≥18)

import { Lock, encryptItem, addPassword, b64e } from './src/ssmpl.js';

const salt = crypto.getRandomValues(new Uint8Array(16));
const cfg  = { hash:'SHA-256', iterations:250000, cipher:'AES-256-GCM', salt };
let item   = await encryptItem('body…', 'passwordA', cfg);
item       = await addPassword(item, 'passwordB', 'passwordA', cfg);   // no re-encryption

const blob = { ssmpl:1, cipher:cfg.cipher, kdf:'PBKDF2-HMAC', hash:cfg.hash,
               iterations:cfg.iterations, salt:b64e(salt), items:{ story:item } };

const lock = new Lock(JSON.parse(JSON.stringify(blob)));
await lock.unlock('passwordB');               // false if wrong
const text = await lock.decrypt('story');

Python (python/ssmpl/)

from ssmpl import Config, encrypt_item, add_password, Lock
cfg = Config(hash="SHA-256", iterations=250000, cipher="AES-256-GCM", salt=bytes(range(16)))
item = encrypt_item("body…", "passwordA", cfg)
item = add_password(item, "passwordB", "passwordA", cfg)
lock = Lock({"ssmpl":1,"cipher":cfg.cipher,"kdf":"PBKDF2-HMAC","hash":cfg.hash,
             "iterations":cfg.iterations,"salt":b64e(cfg.salt),"items":{"story":item}})
lock.unlock("passwordB"); print(lock.decrypt("story"))

C++ (cpp/, CMake + OpenSSL)

cd cpp && cmake -B build && cmake --build build
./build/test_ssmpl        # round-trip + multi-password + GCM/CBC
./build/ssmpl_cli encrypt passwordA article.txt
./build/ssmpl_cli addpass ssmpl-lock.json article.txt passwordB passwordA
./build/ssmpl_cli decrypt ssmpl-lock.json article.txt passwordB

Tests

node js/test/test.js              # JS 16 cases
py -3.9 python/tests/test_ssmpl.py  # Python 16 cases
node js/test/interop.mjs          # JS <-> Python, GCM + CBC, both directions

Naming

  • SSMPL-EA — the base scheme (payload ssmpl: 1), i.e. the 密本 = P XOR K construction above.
  • SSMPL-BD — the backdefense variant (payload ssmpl: 2); see below.

SSMPL-BD (backdefense variant, ssmpl: 2)

A second line of defence behind the original scheme. The point: the payload stores no secret at all — no user list, no verifier, no encrypted username, only ciphertexts and cipher-books. Breaking into "the database" is therefore pointless: without the user's own password, everything you steal is noise.

norm(s) = UTF-8(NFC(s))            grow(b) = uint32be(len(b)) || b
EP = PBKDF2(grow(pw), salt, iterations, 32)      <- the only one-way step in the whole chain
LP = HMAC(EP, "SSMPL-BD|LP|"   || salt)
Ku = HKDF(EP, salt, "SSMPL-BD|uname")
Eu = HMAC(Ku, "SSMPL-BD|uname|"|| norm(u))       <- encrypted username: secret, never stored
C  = HMAC(LP, "SSMPL-BD|C|"    || Eu)            <- credential, 32B: secret, never stored
K_i = random 32B per item         book = C XOR K_i    <- public, reversible
read: derive C -> K_i = C XOR book -> AES-256-GCM (the ABDD tag decides right/wrong)
  • Fixes a documented v1 limitation: two users sharing a password no longer collide (the username takes part in the derivation);

  • the payload does not even reveal how many users exist (stricter than v1, which leaks the number of authorised readers per article);

  • a per-item random K_i that never appears => there is no universal credential.

  • Spec: docs/SPEC-BD.md

  • Test report: docs/BD-测试报告.md — 268 assertions across three languages, all passing

node js/test/test-bd.js             # JS functional 46
node js/test/attack-bd.js           # JS attack 42
node js/test/gen-vectors-bd.js      # regenerate cross-language vectors (byte-identical)
node js/test/interop-bd.mjs         # Python -> JS direction

python python/tests/test_bd.py      # Python functional 45
python python/tests/attack_bd.py    # Python attack 41
python python/tests/interop_bd.py   # JS -> Python direction

cpp/ targets standard OpenSSL. It has been compiled and run on MSVC + the OpenSSL 3.5.7 runtime DLL: test_ssmpl_bd passes 35 assertions, byte-identical to the JS/Python vectors. For building with the DLL but no development headers, see cpp/BUILD-NOTES.md.

SSMPL-T (time-gated variant, ssmpl:3 = TEA / ssmpl:4 = TBD)

Adds an expiry window to a fully static site: a scraped copy stops working after one period.

  • the time window goes into the salt of the slow KDF (each candidate window costs a full PBKDF2);
  • the payload carries no time field at all — byte-for-byte isomorphic to a plain EA/BD payload;
  • a self-check operator appended after the decrypt routine: no branches, no errors — it folds the SHA-256 of its own code into the derivation, so tampering yields a wrong key and a silent failure;
  • JS code obfuscation as a build step (seed=0, reproducible).

⚠️ This layer is obfuscation grade, not cryptographic grade. Anyone who sees through it has three zero-cost bypasses (roll back the clock, enumerate windows, hard-code the digest) — each one is an assertion that the bypass works, not something hidden.

Don't over-expect

This is access gating + password-level permissions on a static site: you can grant/deny per-article, per-user by whether that article's books contains a user's cipher-book (see above). It is not a server-style account system — no session, no way to remotely revoke someone who already holds a cipher-book, and no way to stop people sharing passwords / cipher-books. The payload is public, so weak passwords are offline-attackable; safety = password strength × KDF cost. For real account auth + server-side revocation you'd need a backend.

See docs/SPEC.md.


License

MIT © 2026 LLYlab

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages