Language: English | 中文
Security-First / Low-Friction / DO-Atomic / WebAuthn Admin / TOFU-Safe / Padded Ciphertext
v3.0 Change Summary (relative to v2.5): Unified security tiers into two user-facing entry points: Quick Share (password mode) and Secure Share (Passkey mode).
ZeroLink is a zero-knowledge secret sharing tool: no accounts, and the server never holds plaintext or private keys. Content is end-to-end encrypted, and only the receiver's local private key can decrypt it. The sender holds administrative authority and can update/destroy ciphertext but cannot decrypt the content.
v3.0 product goals:
Without sacrificing the "minimal-friction user experience," reduce real-world high-probability attack surfaces (preload lock-sniping, passkey synchronization, ciphertext length side-channel, malicious JS delivery) to an acceptable and even auditable level.
- Server Zero-Knowledge: The server/DO never stores plaintext or any private key
- End-to-End Confidentiality: Plaintext only appears locally on the receiver's device
- Unforgeable Update/Destroy: Only the admin can authorize writes/destruction
- Replay/Reorder/Concurrent-Overwrite Resistance: Monotonic version + nonce deduplication + DO serialization
- Minimal Metadata Leakage: Public endpoints cannot infer state; receiver_pub is not exposed to unauthorized parties
- Frontend Integrity Verification: CSP/Signed Manifest/zero third-party scripts/reproducible builds
- Non-Exportable Admin Private Key: WebAuthn private key resides in the system/hardware
- Controllable TOFU Lock-Sniping Risk: Preload crawlers cannot lock before the real receiver
- Significantly Reduced Ciphertext Length Leakage: Default padding to fixed block boundaries
- Client compromised by a malicious extension/trojan: may still abuse a single operation within the user confirmation window; cannot silently export the admin private key for long-term control
- The Web scenario cannot fundamentally solve the ultimate trust problem of "malicious server delivering JS": v2.5 provides self-hosting/verifiable release chain as optional "ceiling solutions"
- At creation, a lock_secret (32 random bytes) is generated and placed only in the share link's URL fragment (e.g., /s/UUID#k=...)
- The fragment is never sent with HTTP requests, so preload bots that access /s/UUID cannot obtain the lock_secret and therefore cannot lock
- Locking requires the lock_secret to participate in a challenge-response (Lock Challenge)
- Plaintext is uniformly padded to multiples of 4KB/8KB (default 4KB) before encryption
- Padding structure includes: original text length + random fill
- cipher_bundle remains AES-GCM ciphertext, but lengths become discrete buckets
- Default and mandatory: Argon2id (parameter target latency 250-500ms)
- PBKDF2 is not implemented
Two tiers available at creation:
- Quick Share: Password mode, locally generated ECDSA admin key (Argon2id-wrapped), no passkey required, 4KB padding
- Secure Share: Passkey mode, UV=required / RK=discouraged, 8KB padding
- Official Cloudflare version remains the default
- Docker Compose one-click self-hosting (current package: Caddy + Go API + PostgreSQL + Garage, with protocol-equivalent routes for the shipped frontend contract)
- Release chain: Signed Manifest + reproducible builds
Selectable securityProfile at creation (v3.0 two tiers):
- Admin Authority: Locally generated ECDSA P-256 keypair, wrapped by user password via Argon2id and encoded in the admin link's URL fragment (not stored in IndexedDB)
- WebAuthn: Not required
- Receiver: Argon2id enforced
- Padding: 4KB blocks
- adminMode:
password(internal protocol field) - Suitable for: Cross-device/cross-browser usage, environments without passkey support, or users who prefer password managers
- Admin Authority: WebAuthn passkey (device or platform), UV=required, RK=discouraged
- WebAuthn: Required, cannot be downgraded
- Receiver: Argon2id enforced
- Padding: 8KB blocks (higher privacy)
- adminMode:
webauthn(internal protocol field) - Suitable for: Highest security requirements, environments where passkey is available
- Choose mode: Quick Share (Password) or Secure Share (Passkey)
- Quick Share flow: Enter password -> locally generate ECDSA keypair -> Argon2id wrapping -> Create Finish (adminMode=password)
- Secure Share flow: Create Begin -> WebAuthn registration (UV=required, RK=discouraged) -> Create Finish
- The page displays two links:
- Share link (receiver): /s/:uuid#k=<lock_secret_b64url>[&af=<sender_auth_fpr>]
- Admin link (sender): /m/:uuid#wk=<wrapped_priv> (Quick Share) or /m/:uuid (Secure Share)
Mandatory UI notice: The share link must be copied in full (including the part after #), otherwise the receiver cannot lock
- After opening the share link, the page shows a minimal animation (3 frames):
- "Your passphrase stays only with you"
- "Your passphrase creates your personal decryption key — the sender never learns it"
- "After locking, only you can open the content"
- Enter password -> generate RSA keypair -> Argon2id-wrap private key and store locally
- Lock request must carry a lock challenge response (see protocol)
- After successful locking, display Safety Code:
- Emoji sequence (8 emoji)
- Color blocks (4x4 color grid)
- "Advanced" section expands to show raw hex fingerprint
- The admin page shows the same Safety Code (emoji/color blocks), with copy:
- "Please quickly verify that the safety code matches what the other party sent you (recommended via phone call/another messaging tool)"
- The default UI does not display terms like "fingerprint/hash/public key"; these appear only in advanced mode
- Click deliver: goes through compound_begin/commit, completing the write with a single system confirmation
When navigator.credentials is unavailable or the call fails:
- The page automatically detects WebAuthn support status
- Quick Share: Always available (does not depend on WebAuthn); auto-selected when WebAuthn is unavailable
- Secure Share: Displays "This environment does not support Passkey" warning; button is grayed out and unclickable
- UI shows prompt: "Secure Share requires WebAuthn support. Please switch browsers/devices, or use Quick Share"
v2.5 Hard Fix: Lock Secret + Lock Challenge
- Even if an attacker/crawler accesses /s/:uuid first, it cannot lock because it does not have the lock_secret from the fragment
- During locking, the DO issues a one-time challenge; the receiver must provide lock_proof = SHA256("GL-lock"||uuid||lock_challenge_id||lock_challenge||lock_key)
- The DO verifies lock_proof before accepting receiver_pub
The UX layer still recommends:
- Safety code verification via an out-of-band channel (phone call/another IM), but this is no longer the sole line of defense
v2.5 Default Padding: Plaintext is padded to fixed block multiples before encryption, reducing length inference precision.
- Quick Share: Does not use WebAuthn; no passkey synchronization issue
- Secure Share: Uses WebAuthn (UV=required, RK=discouraged); allows platform passkey synchronization; if the browser provides backupState/backupEligibility, it can be detected and flagged, but not forcibly rejected
v2.5 provides three layers of response:
- Verifiable release chain (Signed Manifest + reproducible builds): Increases the probability that "tampering can be detected"
- Self-hosting (current): Docker Compose package provides a protocol-equivalent implementation, completely handing the trust root to the user
- AES-256-GCM encrypts the body (ciphertext bundle remains cipher_bundle)
- RSA-OAEP-256 wraps the AES key (enc_content_key)
padded_plaintext format definition:
- len: uint32 (original text length, big-endian)
- data: original text bytes
- pad: random bytes, padded to ceil((4 + len)/PAD_BLOCK)*PAD_BLOCK
- Default PAD_BLOCK = 4096 (configurable to 8192)
The final encryption target is padded_plaintext; the receiver truncates to the original text by len after decryption.
For very large content (e.g., >1MB), padding may be disabled or a larger block (e.g., 64KB) may be used, but the default remains enabled.
- Argon2id parameters use a target latency strategy (250-500ms)
- Parameters are written to the local header: salt, m, t, p, version
- PBKDF2 is not implemented
Computed from receiver_pub_fpr = SHA256(SPKI(receiver_pub)):
-
Emoji Safety Code: lower nibble (4 bits) of each hash byte mapped to 16-entry emoji palette (fixed table, stable output)
-
Color Blocks: Hash nibbles mapped to a fixed color palette Display rules:
-
Default: Emoji or Color (switchable)
-
Advanced: Short fingerprint (first 6/last 6) + full hex (collapsed)
States and transitions remain as in v2.4, but locking requires the lock_begin/lock_commit challenge flow (see API).
State Set: Waiting, Locked, Delivered, Deleted, Expired
Allowed Transitions:
- Waiting -> Locked (lock_commit succeeds)
- Locked -> Delivered (compound_commit update)
- Delivered -> Delivered (compound_commit update)
- Waiting|Locked|Delivered -> Deleted (delete_commit)
- Waiting|Locked|Delivered -> Expired (expire)
Prohibited Transitions:
- Repeated lock_commit in non-Waiting state
- Any write operation after Deleted/Expired
- lock_commit without a prior lock_begin (challenge must match and is single-use)
Quick Share is the official user entry point in v3.0, replacing "Compatibility Mode" rather than being a fallback option:
- Admin Authority: Locally generated ECDSA P-256 private key (Admin-Priv), wrapped via user password Argon2id and encoded in the manage link's URL fragment (not stored in IndexedDB)
- Update/Delete Authorization: ECDSA signature payload mode (DO still handles version/nonce atomicity)
- Protocol Field:
adminMode: "password"(internal) - Padding: 4KB blocks (compared to Secure Share's 8KB, lower bandwidth but slightly less privacy)
- UI: Not labeled as "lower security"; presented as an independent, valid sharing mode
Note: Quick Share security depends on user password strength. The UI guides users to choose sufficiently strong passwords through a password strength indicator.
General requirements:
- All responses:
Cache-Control: no-store - All sensitive write operations go through DO serialization
- Error responses have a constant shape:
{ok: false, code: string} adminModevalues:"webauthn"|"password"|"softkey"(softkeyis a legacy alias forpassword, behaves identically)
Response:
{
"ok": true,
"state": "waiting|locked|delivered|deleted|expired",
"adminMode": "webauthn|password|softkey",
"securityProfile": "quick|secure",
"receiverPubFpr": "hex..."
}receiverPubFpris only returned after the receiver has locked- After a channel is physically deleted or expired, public reads return
404 NOT_FOUND
Request:
{
"uuid": "string(21)",
"timestamp": 1730000000000,
"securityProfile": "quick|secure"
}Response:
{
"ok": true,
"creationOptions": { "...": "WebAuthn PublicKeyCredentialCreationOptions" }
}- Creates a
waitingchannel and persistssecurityProfile - Quick Share frontend does not use the returned
creationOptions lock_secretis generated locally by the frontend; the server only persists the derivedlockKeyB64u
WebAuthn mode (adminMode: "webauthn"):
{
"adminMode": "webauthn",
"uuid": "string(21)",
"attestation": { "...": "WebAuthn AttestationJSON" },
"lockKeyB64u": "base64url(SHA256('GL-lockkey'||uuid||lock_secret))",
"timestamp": 1730000000000
}Quick Share mode (adminMode: "password"):
{
"adminMode": "password",
"uuid": "string(21)",
"softkeyPubJwk": { "...": "ECDSA P-256 public key JWK" },
"lockKeyB64u": "base64url(SHA256('GL-lockkey'||uuid||lock_secret))",
"timestamp": 1730000000000
}Response:
{
"ok": true,
"shareUrl": "https://...",
"manageUrl": "https://..."
}Response:
{
"ok": true,
"lockChallenge": {
"id": "base64url",
"challenge": "base64url(32 bytes)",
"expiresAt": 1730000000000
}
}Challenge TTL 60s, single-use.
Request:
{
"uuid": "string(21)",
"lockChallengeId": "base64url",
"lockProof": "hex(SHA256('GL-lock'||uuid||challengeId||challenge||lock_key))",
"receiverPubJwk": { "...": "RSA-OAEP-256 public key JWK" },
"receiverPubFpr": "hex(SHA256(SPKI(receiver_pub)))",
"lockedAt": 1730000000000
}DO verifies the challenge has not expired or been consumed and that lock_proof is correct, then writes receiver_pub, fpr, status=Locked.
All management operations share a two-phase flow: compound_begin to obtain a challenge, then compound_commit or delete_commit to submit.
Request:
{ "uuid": "string(21)" }Response:
{
"ok": true,
"challenge": {
"id": "base64url",
"seed": "base64url",
"expiresAt": 1730000000000
},
"allowCredentials": [ "...optional, WebAuthn allow list..." ],
"receiverPubFpr": "hex...",
"receiverPubJwk": { "...": "RSA-OAEP-256 public key JWK" },
"currentVersion": 0,
"securityProfile": "quick|secure",
"adminMode": "webauthn|password|softkey"
}WebAuthn mode:
{
"uuid": "string(21)",
"assertion": { "...": "WebAuthn AssertionJSON" },
"intentHash": "hex(SHA256(canonical(intent)))",
"intent": {
"op": "update",
"uuid": "string(21)",
"version": 1,
"timestamp": 1730000000000,
"nonce": "base64url(24 bytes)",
"receiverPubFpr": "hex...",
"payloadKind": "text|file",
"cipherBundle": { "...": "see below" },
"expireAt": 1730000000000
}
}Quick Share mode (adds adminMode + softkeySignature, no assertion):
{
"adminMode": "password|softkey",
"uuid": "string(21)",
"softkeySignature": "hex(ECDSA P-256 signature)",
"intentHash": "hex...",
"intent": { "...": "same as above" }
}cipherBundle structure (inline text payload):
{
"ciphertext": "base64url",
"iv": "base64url(12 bytes)",
"aad": "base64url",
"encContentKey": "base64url",
"ciphertextHash": "hex(SHA256(ciphertext))",
"padBlock": 4096
}New file payloads use fileRef (see § 10.6) instead of cipherBundle, with payloadKind: "file".
Delete reuses the compound_begin challenge, with intent.op set to "delete":
{
"uuid": "string(21)",
"assertion": { "...": "WebAuthn AssertionJSON" },
"intentHash": "hex...",
"intent": {
"op": "delete",
"uuid": "string(21)",
"version": 1,
"timestamp": 1730000000000,
"nonce": "base64url(24 bytes)"
}
}Quick Share mode substitutes softkeySignature for assertion.
Called by the receiver after locking and entering their passphrase to retrieve the cipher payload.
Response:
{
"ok": true,
"cipherBundle": { "...": "inline payload, same structure as § 10.4" },
"fileRef": { "...": "multipart file payload metadata for delivered file payloads" },
"receiverPubFpr": "hex...",
"cipherVersion": 1,
"deliveryAuth": { "...": "delivery proof, optional" },
"deliveredAt": 1730000000000
}cipherBundle and fileRef are mutually exclusive; exactly one must be present.
Returns the current deployment's file upload policy. Called by the frontend after a file is selected to confirm upload support and size limits.
Response:
{
"ok": true,
"policy": {
"maxFileBytes": 104857600,
"multipartThresholdBytes": 5242880,
"chunkSizeBytes": 5242880,
"maxChunks": 20,
"multipartSupported": true
}
}Request:
{
"channelUuid": "string(21)",
"chunkCount": 3,
"totalCiphertextBytes": 15728640
}Response:
{
"ok": true,
"uploadId": "base64url",
"chunks": [
{ "index": 0, "uploadUrl": "https://r2-presigned-url..." },
{ "index": 1, "uploadUrl": "https://..." },
{ "index": 2, "uploadUrl": "https://..." }
]
}The frontend PUTs each encrypted chunk directly to object storage using the provided uploadUrl.
Request:
{
"uploadId": "base64url",
"baseIv": "base64url(12 bytes)",
"encContentKey": "base64url",
"chunkSizeBytes": 5242880,
"totalPlaintextBytes": 15000000,
"totalCiphertextBytes": 15728640,
"chunks": [
{ "index": 0, "etag": "abc123", "ciphertextBytes": 5242896, "ciphertextHash": "hex..." },
{ "index": 1, "etag": "def456", "ciphertextBytes": 5242896, "ciphertextHash": "hex..." },
{ "index": 2, "etag": "ghi789", "ciphertextBytes": 5242848, "ciphertextHash": "hex..." }
]
}Response:
{
"ok": true,
"fileRef": { "...": "MultipartFileRef, submitted as the intent's fileRef field" }
}Called by the receiver during decryption to obtain pre-signed download URLs for each chunk.
Response:
{
"ok": true,
"chunks": [
{ "index": 0, "downloadUrl": "https://r2-presigned-url..." },
{ "index": 1, "downloadUrl": "https://..." }
]
}Real-time channel state subscription. After connecting, the server pushes state change events (e.g. receiver locked, sender delivered). The frontend uses this to detect channel changes without polling.
- origin, rpIdHash, UV/UP, challenge exact matching, COSE ES256 signature verification
- Secure Share:
- userVerification="required"
- residentKey="discouraged"
- attestation="none"
- At release, generate manifest.json containing:
- Version number
- SHA-256 of each static resource
- Build time, commit hash
- Sign the manifest with the project's offline signing private key (Ed25519), publishing manifest.sig
- The app displays the manifest hash at runtime (advanced users can verify)
Note: This cannot prevent an attacker from directly tampering with index.html to disable verification, but it makes "download + verification tool" viable.
- Provide Docker Compose:
- Frontend static files
- API service (protocol-equivalent implementation: challenge/nonce/version/lockkey/padding/WebAuthn verification)
- DB (Postgres/MySQL) or SQLite + transaction locks
- The self-hosted version must pass the same protocol test vectors (canonical, challenge, nonce)
- Default: Emoji Safety Code (e.g., 8 emoji)
- Secondary: Color Blocks (e.g., 4x4)
- Advanced: Short fingerprint + full hex (collapsed)
Copy principles:
- Do not use terms like "fingerprint/hash/public key" (except in advanced mode)
- Strongly suggest "out-of-band verification" but without creating anxiety (use gentle prompts)
- Animation within 3 frames + 1 strong prompt:
- "Your passphrase creates your decryption key — the sender never learns it"
- Password strength prompt (but not forcing overly strong passwords to avoid discouraging users; Secure Share is available separately as a higher security option)
- On failure, provide a clear reason classification (without leaking sensitive information):
- "Browser not supported"
- "Current page is insecure (not https / not same-origin)"
- "System has not enabled biometrics/security key"
- Quick Share: Remains available, with explanation that this is password mode
- Secure Share: Blocked, with suggestion to "switch devices/browsers"
New tests required:
- TOFU Lock-Sniping: Access without the fragment cannot complete lock_commit (lock_proof verification fails)
- lock_challenge Replay: Reusing the same challenge_id for lock_commit must fail
- Padding: Different plaintext lengths map to the same bucket-length ciphertext (at least 4KB buckets)
- Argon2id Enforcement: Receiver private key wrapping must use Argon2id; Quick Share admin key must also use Argon2id wrapping
- Secure Share Policy: secure must require UV=required and use non-discoverable credentials for registration (
residentKey="discouraged")
sequenceDiagram
autonumber
participant S as Sender (Browser)
participant R as Receiver (Browser)
participant W as Worker
participant D as DO(uuid)
rect rgb(240,240,240)
Note over S,D: Create (creationOptions + local lock_secret)
S->>W: POST /api/create_begin/{uuid} (securityProfile)
W->>D: forward
D-->>W: creationOptions
W-->>S: creationOptions
S->>S: generate local lock_secret
S->>S: lock_key = sha256("GL-lockkey"||uuid||lock_secret)
S->>S: build share URL: /s/{uuid}#k=lock_secret[&af=sender_auth_fpr]
S->>S: navigator.credentials.create(...) or generate local ECDSA admin key
S->>S: build manage URL: /m/{uuid}#wk=wrapped_priv [Quick Share] or /m/{uuid} [Secure Share]
S->>W: POST /api/create_finish/{uuid} (attestation or softkeyPubJwk + lockKeyB64u)
W->>D: forward
D->>D: store admin credential + lock_key + status=Waiting
D-->>W: ok
W-->>S: ok
end
rect rgb(240,240,240)
Note over R,D: Lock begin/commit (TOFU-safe)
R->>W: POST /api/lock_begin/{uuid}
W->>D: forward
D-->>W: lock_challenge_id + lock_challenge
W-->>R: lock_challenge_id + lock_challenge
R->>R: read lock_secret from URL fragment
R->>R: lock_key = sha256("GL-lockkey"||uuid||lock_secret)
R->>R: lock_proof = sha256("GL-lock"||uuid||cid||chal||lock_key)
R->>W: POST /api/lock_commit/{uuid} (receiver_pub + fpr + lock_proof)
W->>D: forward
D->>D: verify lock_proof using stored lock_key, then store receiver_pub/fpr, status=Locked
D-->>W: ok
W-->>R: ok + SafetyCode shown locally
end
rect rgb(240,240,240)
Note over S,D: Deliver (compound one-confirm)
S->>W: POST /api/manage/compound_begin/{uuid}
W->>D: forward
D-->>W: challenge_id/seed + receiver_pub/fpr + last_version (if locked)
W-->>S: begin
S->>S: pad plaintext (4KB buckets) + hybrid encrypt + intent_hash
S->>S: expected_challenge = sha256("GL-delivery-proof"||uuid||intent_hash)
S->>S: Secure Share: navigator.credentials.get(...) / Quick Share: ECDSA sign with Admin-Priv
S->>W: POST /api/manage/compound_commit/{uuid} (assertion or softkeySignature + update)
W->>D: forward
D->>D: verify intent_hash + delivery_proof challenge + admin signature (WebAuthn or ECDSA) + version/nonce
D->>D: write cipher_bundle + status=Delivered + last_version++
D-->>W: ok
W-->>S: ok
end
rect rgb(240,240,240)
Note over S,D: Delete (reuses compound_begin)
S->>W: POST /api/manage/compound_begin/{uuid}
W->>D: forward
D-->>W: challenge_id/seed + last_version
W-->>S: begin
S->>S: intent_hash + expected_challenge = sha256("GLv2.5"||uuid||cid||intent_hash||seed)
S->>S: admin sign (WebAuthn get or ECDSA sign)
S->>W: POST /api/delete_commit/{uuid}
W->>D: forward
D->>D: verify intent_hash + nonce-bound challenge + admin signature
D->>D: delete record
D-->>W: ok
W-->>S: ok
end
rect rgb(240,240,240)
Note over R,D: Decrypt (receiver reads delivered secret)
R->>W: GET /api/public/{uuid}
W->>D: forward
D-->>W: state=delivered
W-->>R: state=delivered
R->>W: GET /api/decrypt_fetch/{uuid}
W->>D: forward
D-->>W: cipherBundle + receiverPubFpr + cipherVersion + deliveryAuth
W-->>R: cipher payload
R->>R: load wrappedPrivateKey from IndexedDB
R->>R: Argon2id(passphrase) → unwrap receiver_priv
R->>R: RSA-OAEP unwrap AES content key
R->>R: AES-GCM decrypt + remove padding → plaintext
R->>R: verify deliveryAuth proof (if anchored channel)
end
- UUID_LENGTH = 21 (nanoid)
- TIMESTAMP_SKEW_MS = 120000 (+/-120s)
- NONCE_BYTES = 24 (base64url)
- NONCE_TTL_MS = 600000 (10min)
- CHALLENGE_BYTES = 32
- CHALLENGE_TTL_MS = 60000 (60s)
- LOCK_SECRET_BYTES = 32 (base64url, stored in URL fragment)
- LOCK_KEY_BYTES = 32 (server storage, sha256 output)
- PAD_BLOCK_DEFAULT = 4096 (configurable to 8192)
- PAD_BLOCK_MAX = 65536 (upper limit)
- MAX_PLAINTEXT_BYTES = 2MB (inline plaintext ceiling for text payloads and legacy compatibility; new file uploads require object-storage support)
- WebAuthn: default alg = -7 (ES256), UV required (Strict/HardwareOnly)
- Object keys sorted recursively by Unicode code point in ascending order
- Arrays preserve order
- Numbers must be integers, decimal, no scientific notation
- Output minified JSON, no whitespace
- UTF-8 bytes
Input object (conceptual):
{
"op": "update",
"uuid": "u",
"version": 1,
"timestamp": 1730000000000,
"nonce": "n",
"receiver_pub_fpr": "f",
"cipher_bundle": {
"ciphertext": "ct",
"iv": "iv",
"aad": "aad",
"enc_content_key": "ek",
"ciphertext_hash": "h"
},
"expire_at": null,
"pad_block": 4096
}Canonical output must be:
{"cipher_bundle":{"aad":"aad","ciphertext":"ct","ciphertext_hash":"h","enc_content_key":"ek","iv":"iv"},"expire_at":null,"nonce":"n","op":"update","pad_block":4096,"receiver_pub_fpr":"f","timestamp":1730000000000,"uuid":"u","version":1}Input object (conceptual):
{
"op": "delete",
"uuid": "u",
"version": 2,
"timestamp": 1730000000000,
"nonce": "n"
}Canonical output must be:
{"nonce":"n","op":"delete","timestamp":1730000000000,"uuid":"u","version":2}- Frontend locally generates lock_secret: 32 random bytes, written to the share link fragment:
share_url = /s/<uuid>#k=<b64url(lock_secret)> - Frontend computes lock_key:
lock_key = SHA256( UTF8("GL-lockkey") || UTF8(uuid) || lock_secret ) - Frontend sends lock_key_b64u back in create_finish
- Server stores lock_key (base64url or hex; must be consistent; base64url recommended)
Note: lock_secret must never be logged or stored as plaintext.
- lock_begin: DO issues {lock_challenge_id, lock_challenge} (32 random bytes, TTL 60s, single-use)
- lock_commit: Client submits receiver_pub + fpr + lock_proof
- Client reads lock_secret from the fragment, locally computes lock_key (same as C1)
- Then computes:
lock_proof = SHA256( UTF8("GL-lock") || UTF8(uuid) || b64url_decode(lock_challenge_id) || b64url_decode(lock_challenge) || lock_key ) - lock_commit only submits lock_proof (hex or base64url; lowercase hex recommended)
- Retrieves lock_key from DO storage
- Recomputes expected lock_proof using the same concatenation
- Only allows receiver_pub to be written if the values match
- Preload crawlers do not have the fragment -> cannot obtain lock_secret -> cannot obtain lock_key -> cannot forge lock_proof
- Even if lock_proof is obtained, it can only be used with the single-use lock_challenge; replay fails (challenge consumed)
Response:
{
"ok": true,
"uuid": "string(21)",
"lock_challenge_id": "base64url(16-32)",
"lock_challenge": "base64url(32)",
"expires_at": 1730000000000
}Request:
{
"uuid": "string(21)",
"lock_challenge_id": "base64url",
"lock_proof": "hex(lowercase)",
"receiver_pub_jwk": {
"kty": "RSA",
"alg": "RSA-OAEP-256",
"n": "...",
"e": "...",
"ext": true,
"key_ops": ["encrypt"]
},
"receiver_pub_fpr": "hex(lowercase)",
"locked_at": 1730000000000
}Response:
{
"ok": true
}Error semantics (coarse-grained):
- 401: Challenge expired/does not exist
- 403: lock_proof mismatch / no longer in Waiting state
- 409: Challenge already consumed (replay)
- orig_len: uint32 big-endian (4 bytes)
- orig_data: orig_len bytes
- pad_rand: random bytes, length such that total length is a multiple of PAD_BLOCK
- Total length: ceil((4+orig_len)/PAD_BLOCK) * PAD_BLOCK
- PAD_BLOCK defaults to 4096; can be included in the update payload as pad_block (for audit consistency; not recommended for public display)
- pad_rand must be cryptographically secure random numbers
- Text payloads stay on the inline path subject to
MAX_PLAINTEXT_BYTES. New file payloads do not use this size gate for transport selection: they must upload encrypted chunks and commit afileRef, or be rejected when the deployment does not advertise object-storage support
- Decrypt to obtain padded_plaintext
- Read the first 4 bytes to get orig_len
- Extract the subsequent orig_len bytes as the plaintext
- Ignore the remaining pad_rand
- AES-GCM is still used; padding does not introduce a padding oracle
- AAD continues to bind uuid/version/fpr, preventing substitution and context confusion
CipherBundle (base64url):
- enc_content_key (RSA-OAEP output, fixed length ~256 bytes)
- ciphertext (length ~= padded_plaintext_len + GCM tag)
- iv (12 bytes)
- aad (recommended: base64url AAD bytes)
- ciphertext_hash (SHA-256 hex)
Bucket strategy:
- Default PAD_BLOCK=4096, leakage granularity is 4KB buckets
- Higher security tiers can increase to 8KB/16KB (more private but more bandwidth-intensive)
- Does not use WebAuthn; fully password mode
- adminMode = "password"
- userVerification = "required" (mandatory)
- residentKey = "discouraged" (uses non-discoverable credential)
- attestation = "none"
- Suitable for platform passkeys and hardware security keys
Appendix H: WebAuthn Verification Byte-Level Steps (Continuing v2.4, with Supplementary Constraints for Lock/Profile)
Commit (compound/delete) verification order must include:
- Verify credentialId == stored cred_id
- clientDataJSON:
- type=="webauthn.get"
- origin strict match
- challenge strict match expected_challenge
- authenticatorData:
- rpIdHash == SHA256(rpId)
- flags: UP=1; UV per policy
- Signature verification:
- signedData = authenticatorData || SHA256(clientDataJSON)
- COSE ES256 -> P-256 public key
- signCount strategy: Log anomalies without hard-blocking (to avoid false positives from synchronization)
Quick Share is the official user entry point in v3.0 (no longer a degraded mode).
- Frontend generates ECDSA P-256 keypair
- Admin-Priv is Argon2id-wrapped and encoded in the manage link's URL fragment (not stored in IndexedDB; password provided by user)
- Server stores Admin-Pub (JWK) + adminMode="password"
- update/delete requests are based on ECDSA sig (Ghost Canon v1 canonical payload)
- DO still handles version/nonce/challenge serial consistency
- Displays "Quick Share (Password)" badge (not "Compatibility Mode")
- Does not force a secondary risk confirmation (password strength indicator guides the user)
- When password strength is low, the UI offers suggestions but does not forcibly block
Unified response body:
{
"ok": false
}Recommended status codes:
- 400: Malformed request
- 401: Timestamp window failure / challenge expired (unified)
- 403: Permission/state not allowed / WebAuthn failure / lock_proof failure (unified)
- 404: uuid does not exist (Deleted/Expired may also return 404 to further reduce leakage)
- 409: Nonce replay / version conflict / challenge already consumed (unified)
Public endpoint /api/public/:uuid:
- Returns current
state,adminMode,securityProfile, and optionalreceiverPubFpr - Returns
404 NOT_FOUNDafter physical deletion or expiration
- receiver_pub_fpr (32 bytes sha256)
- Split fpr bytes into 8 groups; for each group take lower nibble (4 bits) -> mapped to 16-entry emoji palette (fixed table)
- Output 8 emoji, consistent across platforms
- UI displays as: (example)
- Take the 32 bytes of fpr -> each nibble mapped to a 16-color fixed palette
- Output 4x4 color blocks (fixed layout), consistent across platforms
- Short fingerprint: first 6 bytes + last 6 bytes (hex)
- Full hex displayed collapsed (user must explicitly expand)