Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions DESIGN-E2EE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# End-to-End Encryption (E2EE) Design — Option A Sign-off

Status: Ready for sign-off
Owner: Kyaw

## 1. Scope and Goals
- Web-first PWA with future native iOS/Android.
- Protect messages, files/assets (3D/logo/media), docs, and analytics events.
- Privacy-preserving analytics client-side; server-side limited to minimized metadata.
- Retrofit behind feature flags; backward-compatible bridging for non-E2EE content.

## 2. Threat Model
- Adversaries: curious/compromised servers, external attackers (MITM), malicious clients, stolen devices.
- Trust boundaries: Only clients see plaintext/keys. Servers handle ciphertext, minimal metadata, and capability tokens. IdP proves identity only.
- Security goals: Confidentiality & integrity; forward secrecy and post-compromise security; deniable auth for messages; verifiable membership/state.
- Out of scope initial: traffic analysis resistance; plaintext content scanning; hardware tamper beyond platform enclaves.

## 3. Cryptographic Primitives and Libraries

### 3.1 Argon2id Parameters (desktop vs mobile)
- Desktop/Web: m=64 MiB, t=3, p=1 — balances user-perceived latency with robust GPU/ASIC resistance for key wrapping. Typical derivation latency ~200–500 ms on contemporary laptops.
- Mobile: m=32 MiB, t=3, p=1 — reduces memory pressure and thermal impact while keeping meaningful resistance. Target latency ~300–700 ms depending on device class.
- Salt length: 16 bytes (random per vault). Output length: 32 bytes (KEK).
- Rationale: Memory-hardness is the dominant cost lever; t=3 provides reasonable compute amplification without excessive energy draw on battery devices.

- Ed25519 (signing) for identities and devices.
- X25519 (ECDH) for key agreement; sealed boxes for key wrapping (HPKE-ready abstraction).
- Messaging: Signal/Double Ratchet via libsignal-client (WASM) for 1:1/small groups.
- Large groups: Signal sender keys with periodic rotation; MLS on roadmap.
- Files: AES-256-GCM streaming with HKDF-derived per-chunk nonces; BLAKE3 for chunk and whole-file digests.
- KDF: HKDF-SHA256; Password KDF: Argon2id (m=64 MiB, t=3, p=1; mobile fallback m=32 MiB). Salt: 16 bytes. Output: 32 bytes.
- Hashing: BLAKE3 for content addressing and integrity.

## 4. Identity & Device State Machines
### 4.1 Identity Keys
States: uninitialized -> generated -> backed_up (optional) -> compromised(revoked)
Transitions:
- generate: create Ed25519 identity key pair
- backup: wrap private key with Argon2id-derived KEK; store vault in IndexedDB
- revoke: mark identity compromised; re-enroll devices

### 4.2 Device Enrollment
States: new -> pending_attestation -> verified -> revoked
Transitions:
- new: device generates Ed25519 (sign) + X25519 (DH)
- provision: QR shows {device_pubkeys, nonce}; trusted device scans and verifies SAS
- attest: trusted device signs attestation binding device to identity
- verify: server records attestation; device becomes verified
- revoke: immediate revocation; triggers rotations

## 5. Messaging Sessions
- 1:1 and small groups: Double Ratchet with prekeys from libsignal.
- Device revocation: peers refuse messages from revoked devices.
- Group sender keys: per-room sender key rotated on membership change and every 7 days; per-recipient key wraps.

## 6. File Encryption and Sharing
See also Mermaid diagrams in docs/diagrams for provisioning, file-share, and rekey flows.

### 5.1 Canonicalization (signatures & tokens)
- Manifest signing: Canonical JSON per RFC 8785 (JSON Canonicalization Scheme, JCS). Remove the `sig` field before canonicalization; sign the result with device Ed25519. Verification recomputes canonical JSON and verifies the signature.
- PASETO payload normalization: When computing or verifying detached request signatures or audit hashes, canonicalize the JSON payload using the same JCS rules and sort header fields if applicable. Avoid including transient fields (e.g., `iat` skew-adjusted values) in hash commitments.

### 6.1 Streaming Encryption
- Per-file random DEK (256-bit).
- Chunk size 512KB–2MB (adaptive). Max single-object size: 5 GB (resumable uploads).
- Nonce derivation: nonce_i = HKDF(DEK, info="file-chunk" || chunk_index)[0..12]
- AES-256-GCM over each chunk; produce per-chunk BLAKE3 and cumulative whole-file BLAKE3.

Comment on lines +66 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Clarify and harden AES-GCM nonce derivation; bind AAD to prevent misuse

The current nonce formula is ambiguous and risks incorrect implementations. For AES-GCM, nonce uniqueness per DEK is critical. Also, include AAD to bind chunk context to the ciphertext.

Apply this diff to specify an unambiguous, misuse-resistant construction and fix the markdown “reversed link” warning:

- - Nonce derivation: nonce_i = HKDF(DEK, info="file-chunk" || chunk_index)[0..12]
- - AES-256-GCM over each chunk; produce per-chunk BLAKE3 and cumulative whole-file BLAKE3.
+ - Nonce derivation (96-bit): Let PRK = HKDF-Extract(salt=blake3_file, IKM=DEK). For each chunk index i:
+   nonce_i = HKDF-Expand(PRK, info = "nonce:file-chunk:v1" || LE64(i), L = 12)  # first 12 bytes only (0..11)
+   Notes:
+   - Use LE64(i) as an 8-byte little-endian value. i must be < 2^32 in practice.
+   - "||" denotes byte concatenation.
+ - AES-256-GCM over each chunk with AAD = concat("e2ee-file:v1", version, chunk_index, object_id, algo, chunk_size)
+   to bind context and detect cross-context replays.
+ - Produce per-chunk BLAKE3 and cumulative whole-file BLAKE3 (for content addressing; not relied upon for AEAD integrity).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Nonce derivation: nonce_i = HKDF(DEK, info="file-chunk" || chunk_index)[0..12]
- AES-256-GCM over each chunk; produce per-chunk BLAKE3 and cumulative whole-file BLAKE3.
- Nonce derivation (96-bit): Let PRK = HKDF-Extract(salt=blake3_file, IKM=DEK). For each chunk index i:
nonce_i = HKDF-Expand(PRK, info = "nonce:file-chunk:v1" || LE64(i), L = 12) # first 12 bytes only (0..11)
Notes:
- Use LE64(i) as an 8-byte little-endian value. i must be < 2^32 in practice.
- "||" denotes byte concatenation.
- AES-256-GCM over each chunk with AAD = concat("e2ee-file:v1", version, chunk_index, object_id, algo, chunk_size)
to bind context and detect cross-context replays.
- Produce per-chunk BLAKE3 and cumulative whole-file BLAKE3 (for content addressing; not relied upon for AEAD integrity).
🧰 Tools
🪛 LanguageTool

[grammar] ~53-~53: There might be a mistake here.
Context: ...info="file-chunk" || chunk_index)[0..12] - AES-256-GCM over each chunk; produce per...

(QB_NEW_EN)

🪛 markdownlint-cli2 (0.17.2)

53-53: Reversed link syntax
(DEK, info="file-chunk" || chunk_index)[0..12]

(MD011, no-reversed-links)

🤖 Prompt for AI Agents
In DESIGN-E2EE.md around lines 53–55, the nonce derivation and AAD are ambiguous
and the markdown has a reversed-link warning; change the spec to derive a 96-bit
(12-byte) GCM nonce by HKDF-Expand using the file DEK as input keying material,
an explicit salt (or zero salt if none), and an info string formed
deterministically (e.g. "file-chunk-v1" || chunk_index || 0x01) and take the
first 12 bytes as the nonce to guarantee uniqueness per DEK+chunk; require
AES-256-GCM to use Additional Authenticated Data that binds chunk_index and a
file identifier (and version/algorithm tag) to the ciphertext (e.g. AAD =
"file-chunk-v1" || file_id || chunk_index) so chunk context is authenticated;
update the text to specify HKDF parameters (hash, salt behavior, info
composition), the exact nonce length (12 bytes), and that implementations must
fail if nonce collisions would occur; and fix the reversed-markdown link by
swapping the link text and URL into the canonical [text](url) form.

### 6.2 Manifest Format (signed by device Ed25519)
```
version: 1
algo: aes-256-gcm
chunk_size: <bytes>
length: <bytes>
blake3_file: <hex>
chunks:
- index: 0
offset: 0
size: <bytes>
blake3: <hex>
- ...
key_wraps: omitted in manifest; stored adjacent by object_id
sig: ed25519(signing_device_pubkey, JCS(manifest_without_sig))
```

### 6.3 DEK Sharing
- For each recipient device X25519 pubkey, create sealed box of DEK.
- Store wraps: key_wraps(object_id, device_id, wrap_ciphertext)
- Rekey on membership change; rewrap to active devices.

Comment on lines +86 to +90
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Bind DEK wraps to object and recipient; prefer HPKE-ready structure over bare sealed boxes

Sealed boxes don’t carry AAD; pairing a ciphertext with a device_id in the DB without binding risks substitution by a malicious server. Define a wrap payload that includes object_id, alg, and recipient key id, and migrate to HPKE where AAD is first-class.

Apply this diff:

-### 6.3 DEK Sharing
-- For each recipient device X25519 pubkey, create sealed box of DEK.
-- Store wraps: key_wraps(object_id, device_id, wrap_ciphertext)
-- Rekey on membership change; rewrap to active devices.
+### 6.3 DEK Sharing
+- For each recipient device (recipient_kid, X25519 pubkey), create a wrap over:
+  wrap_payload = CBOR({
+    "v": 1,
+    "object_id": <object_id>,
+    "alg": "aes-256-gcm",
+    "dek": <32 bytes>,
+    "recipient_kid": <recipient_kid>
+  })
+  Prefer HPKE(KEM=X25519, KDF=HKDF-SHA256, AEAD=ChaCha20-Poly1305 or AES-GCM) so that AAD = object_id || "dek-wrap:v1"
+  is authenticated. If using libsodium sealed boxes as a stopgap, include the above fields inside the sealed payload.
+- Store wraps: key_wraps(object_id, recipient_kid, wrap_ciphertext, alg="hpke-v1")
+- Rekey on membership change; rewrap to active devices only.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### 6.3 DEK Sharing
- For each recipient device X25519 pubkey, create sealed box of DEK.
- Store wraps: key_wraps(object_id, device_id, wrap_ciphertext)
- Rekey on membership change; rewrap to active devices.
### 6.3 DEK Sharing
- For each recipient device (recipient_kid, X25519 pubkey), create a wrap over:
wrap_payload = CBOR({
"v": 1,
"object_id": <object_id>,
"alg": "aes-256-gcm",
"dek": <32 bytes>,
"recipient_kid": <recipient_kid>
})
Prefer HPKE(KEM=X25519, KDF=HKDF-SHA256, AEAD=ChaCha20-Poly1305 or AES-GCM) so that AAD = object_id || "dek-wrap:v1"
is authenticated. If using libsodium sealed boxes as a stopgap, include the above fields inside the sealed payload.
- Store wraps: key_wraps(object_id, recipient_kid, wrap_ciphertext, alg="hpke-v1")
- Rekey on membership change; rewrap to active devices only.
🧰 Tools
🪛 LanguageTool

[grammar] ~73-~73: There might be a mistake here.
Context: ...t_without_sig)) ``` ### 6.3 DEK Sharing - For each recipient device X25519 pubkey,...

(QB_NEW_EN)


[grammar] ~74-~74: There might be a mistake here.
Context: ...or each recipient device X25519 pubkey, create sealed box of DEK. - Store wraps: key_w...

(QB_NEW_EN)


[grammar] ~74-~74: There might be a mistake here.
Context: ...X25519 pubkey, create sealed box of DEK. - Store wraps: key_wraps(object_id, device...

(QB_NEW_EN)


[grammar] ~75-~75: There might be a mistake here.
Context: ...s(object_id, device_id, wrap_ciphertext) - Rekey on membership change; rewrap to ac...

(QB_NEW_EN)

🤖 Prompt for AI Agents
In DESIGN-E2EE.md around lines 73 to 77, the DEK wrap scheme currently suggests
storing bare sealed-box ciphertexts keyed only by device_id, which allows
server-side substitution and lacks AAD; update the design to bind each wrap to
both object and recipient by defining a wrap payload structure that contains
object_id, algorithm identifier (alg), and recipient_key_id and to replace
sealed boxes with an HPKE-ready envelope that uses AAD; specify that stored rows
(key_wraps) must include object_id, device_id, recipient_key_id, alg, and
wrap_ciphertext (HPKE-encrypted blob) and that unwrapping must verify AAD
matches the object_id/recipient_key_id/alg before accepting the DEK; update
rekey/migration notes to rewrap existing sealed-box entries into the new HPKE
payload format and validate integrity on migration.

## 7. Capability Tokens
- Format: PASETO v4.public (Ed25519-signed by server capability key).
Claims:
- sub: user or device id
- scope: [object:get|put, room:read, room:write, membership:manage]
- resource: URI or prefix (e.g., s3://bucket/path/object-id)
- exp: expiry; iat/nbf
- region: enforced data residency region (controls bucket/prefix routing)
- tid/nonce: unique token id to prevent replay
- TTLs: object GET/PUT 15 minutes; room/event scopes 1 hour.

## 8. APIs (Server)
- POST /devices/attest
- POST /devices/revoke
- POST /rooms
- POST /rooms/:id/members
- POST /rooms/:id/rotate
- POST /capabilities
- PUT /objects/:id (requires capability)
- GET /objects/:id (requires capability)
- WS /events

## 9. Storage Schema (Postgres + S3-compatible)
Residency enforcement: region claim in capabilities is validated and mapped to storage bucket/prefix; mismatches are rejected at capability validation and storage routing.
- users(id, identity_pubkey_hash, oidc_sub, region)
- devices(id, user_id, ed25519_pub, x25519_pub, attestation_sig, status)
- rooms(id, created_by, policy)
- memberships(room_id, device_id, role, since, status)
- sender_keys(room_id, epoch, key_id, wrapped_keys jsonb, created_at)
- objects(id, owner, room_id, bucket, path, blake3_digest, size, manifest_sig, created_at)
- key_wraps(object_id, device_id, wrap_ciphertext)
- audit_events(id, actor, type, target, ts, meta)

## 10. Backup & Recovery
SAS & QR: Use emoji SAS (7-emoji sequence from a 64-emoji table) for human-friendly verification. QR payload schema: base64url(JSON { device_pubkeys, nonce, sig, ts }).
- Key vault: private keys wrapped by Argon2id-derived KEK; IndexedDB on web; Secure Enclave/Keystore on mobile.
- Optional Shamir 2-of-3 recovery (user + admin escrow + HSM) with approvals and audit.

## 11. Metadata Minimization
Audit taxonomy and retention: capture key lifecycle events, membership changes, capability issuance/revocation, and recovery attempts. Retention: 1 year (then purge or anonymize per policy).
- Store hashed identity references; coarse timestamps; encrypted membership maps when feasible.
- Avoid plaintext titles/tags. No plaintext in logs.

## 12. Request Signing & Replay Protection
- Client signs sensitive requests with device Ed25519 over canonical payload + timestamp.
- Server enforces skew window and tid uniqueness.

Comment on lines +134 to +137
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Request signing: specify canonicalization, covered fields, and replay window

Define canonical JSON (e.g., JCS), exact covered headers/query params, and the replay/skew windows to ensure interoperability and security.

Apply this diff:

-## 12. Request Signing & Replay Protection
-- Client signs sensitive requests with device Ed25519 over canonical payload + timestamp.
-- Server enforces skew window and tid uniqueness.
+## 12. Request Signing & Replay Protection
+- Canonicalization: JCS (RFC 8785) over a payload that includes method, path, sorted query, selected headers (host, content-type, content-length), body hash (BLAKE3), timestamp, and tid.
+- Signature: Ed25519 device key over sign_payload; header: X-Signature: base64(sig), X-Signature-KID: <device_kid>.
+- Server enforces clock skew window ≤ 2 minutes and tid uniqueness for 10 minutes (per tenant).
+- GET/HEAD requests sign an empty body hash; all signed fields must be verified before processing.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## 12. Request Signing & Replay Protection
- Client signs sensitive requests with device Ed25519 over canonical payload + timestamp.
- Server enforces skew window and tid uniqueness.
## 12. Request Signing & Replay Protection
- Canonicalization: JCS (RFC 8785) over a payload that includes method, path, sorted query, selected headers (host, content-type, content-length), body hash (BLAKE3), timestamp, and tid.
- Signature: Ed25519 device key over sign_payload; header: X-Signature: base64(sig), X-Signature-KID: <device_kid>.
- Server enforces clock skew window ≤ 2 minutes and tid uniqueness for 10 minutes (per tenant).
- GET/HEAD requests sign an empty body hash; all signed fields must be verified before processing.

## 13. Performance Targets
- p95 decrypt < 120 ms for 10 MB on desktop.
- Streaming crypto in Web Workers; backpressure-managed I/O.

## 14. Rollout & Kill Switch
- Feature flags per tenant/room.
- Canary cohorts; schema uses sidecar tables for isolation.
- Instant kill-switch disables capability issuance for E2EE objects/rooms; existing ciphertext remains intact.

## 15. CI/CD and Supply Chain
- Renovate/Dependabot with grouped patch/minor; majors manual.
- GitHub Actions: lint/typecheck/tests, CodeQL, SCA, SBOM (Syft), container scanning.
- Signed commits and releases.

## 16. Test Plan (Acceptance Gates)
- Unit and property tests for: keygen, provisioning, sealed box wraps, AES-GCM streaming (vectors), manifest sign/verify, PASETO claims/validation, rotation flows.
- Integration: 1:1 E2EE chat, file upload/download, membership change triggers rewrap/rotation.
- Data residency pinning tests; GDPR DSR exercises.

## 17. Open Items / Future Work
- Evaluate MLS migration path for large rooms.
- HPKE support behind wrapping abstraction.
- Privacy-preserving analytics with DP budget management per org.
19 changes: 19 additions & 0 deletions docs/diagrams/file-share.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
sequenceDiagram
autonumber
participant C as Client
participant O as Object Store
participant S as Server

C->>C: Generate DEK
loop Encrypt chunks
C->>C: nonce_i = HKDF(DEK, "file-chunk"||i)[0..12]
C->>C: c_i = AES-GCM(plaintext_i, nonce_i)
C->>C: blake3_i = BLAKE3(c_i)
end
C->>C: blake3_file = BLAKE3(c_*) ; manifest+sig
C->>O: PUT chunks + manifest (ciphertext only)
par share DEK
C->>S: POST key_wrap(device_pkX, sealed_box(DEK))
and for each recipient
C->>S: POST key_wrap(device_pkY, sealed_box(DEK))
end
13 changes: 13 additions & 0 deletions docs/diagrams/provisioning.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
sequenceDiagram
autonumber
participant ND as New Device
participant TD as Trusted Device
participant S as Server

ND->>ND: Generate Ed25519_sign_D, X25519_D, nonce N, ts
ND->>TD: Display QR: base64url(JSON{device_pubkeys, N, ts, sig=Sign_D(N||ts)})
TD->>TD: Compute SAS = hash(identity_pub, device_pubkeys, N, ts) -> 7 emojis
ND-->>TD: Human verifies SAS matches
TD->>S: POST /devices/attest(attestation=Sign_T{identity_pub, device_pubkeys, N, ts})
S-->>TD: 200 OK
S-->>ND: WS event device-verified
11 changes: 11 additions & 0 deletions docs/diagrams/rekey.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
sequenceDiagram
autonumber
participant A as Admin
participant S as Server
participant C as Clients (room)

A->>S: Update membership
S-->>C: WS membership-change
C->>C: Rotate sender key; rewrap DEKs
C->>S: POST new wraps
S-->>C: Ack; old wraps invalidated
14 changes: 14 additions & 0 deletions docs/examples/paseto-v4-public.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"v": 4,
"purpose": "public",
"claims": {
"sub": "device:9b1d2...",
"scope": "object:get",
"resource": "s3://brand-intel-eu/tenant123/objects/abc123",
"exp": 1723939200,
"iat": 1723935600,
"nbf": 1723935600,
"region": "eu",
"tid": "d7b2f0f8-7e2a-4d31-8b4e-7e7c2f0b9d51"
}
}
17 changes: 17 additions & 0 deletions docs/schemas/capability.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/capability.schema.json",
"title": "Capability Claims (PASETO v4.public payload)",
"type": "object",
"required": ["sub", "scope", "resource", "exp", "region", "tid"],
"properties": {
"sub": {"type": "string"},
"scope": {"type": "string", "enum": ["object:get", "object:put", "room:read", "room:write", "membership:manage"]},
"resource": {"type": "string"},
"exp": {"type": "integer"},
"iat": {"type": "integer"},
"nbf": {"type": "integer"},
"region": {"type": "string", "description": "enforced data residency region (controls bucket/prefix routing)"},
"tid": {"type": "string"}
}
}
28 changes: 28 additions & 0 deletions docs/schemas/manifest.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/manifest.schema.json",
"title": "E2EE File Manifest",
"type": "object",
"required": ["version", "algo", "chunk_size", "length", "blake3_file", "chunks", "sig"],
"properties": {
"version": {"type": "integer", "enum": [1]},
"algo": {"type": "string", "const": "aes-256-gcm"},
"chunk_size": {"type": "integer", "minimum": 262144, "maximum": 4194304},
"length": {"type": "integer", "minimum": 0},
"blake3_file": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"chunks": {
"type": "array",
"items": {
"type": "object",
"required": ["index", "offset", "size", "blake3"],
"properties": {
"index": {"type": "integer", "minimum": 0},
"offset": {"type": "integer", "minimum": 0},
"size": {"type": "integer", "minimum": 1},
"blake3": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
}
}
},
"sig": {"type": "string", "description": "Ed25519 signature over canonicalized manifest without sig"}
}
}
32 changes: 32 additions & 0 deletions docs/sequences/e2ee-sequences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# E2EE Sequence Diagrams (Textual)

## 1) Device Provisioning (QR + SAS)
1. New device: gen (Ed25519_sign_D, X25519_D), create nonce N and ts
2. New device -> QR payload: base64url(JSON { device_pubkeys, nonce: N, ts, sig: sign_D(N||ts) })
3. Trusted device scans QR
4. Both devices derive SAS from transcript (hash of {identity_pubkey, device_pubkeys, N, ts}) -> 7 emojis
5. Human verifies SAS match
6. Trusted device signs attestation: sign_T({ user_identity_pub, device_pubkeys, N, ts })
7. Trusted device -> Server: POST /devices/attest(attestation)
8. Server records device; broadcasts WS event

## 2) File Share (Encrypt + Upload + Share DEK)
1. Client picks file; gen random DEK
2. For each chunk i:
- nonce_i = HKDF(DEK, info="file-chunk"||i)[0..12]
- c_i = AES-256-GCM(plaintext_i, nonce_i)
- blake3_i = BLAKE3(c_i)
3. blake3_file = BLAKE3(c_0||c_1||...)
4. Manifest = { version, algo, chunk_size, length, blake3_file, chunks[] }
5. sig = Ed25519.sign(manifest_without_sig)
Comment on lines +16 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Nonce derivation is ambiguous and MD lint flags reversed-link; specify 96-bit HKDF construction and bind AAD

For AES-GCM, nonce uniqueness is critical. Also bind context via AAD to prevent cross-object replay. Fix the “reversed link” warning by avoiding bracket slicing syntax.

Apply:

-   - nonce_i = HKDF(DEK, info="file-chunk"||i)[0..12]
-   - c_i = AES-256-GCM(plaintext_i, nonce_i)
+   - Derive 96-bit nonce_i deterministically:
+     Let PRK = HKDF-Extract(salt = blake3_file, IKM = DEK)
+     nonce_i = HKDF-Expand(PRK, info = "nonce:file-chunk:v1" || LE64(i), L = 12)  # bytes 0..11
+   - c_i = AES-256-GCM(plaintext_i, nonce_i, AAD = concat("e2ee-file:v1", version, chunk_index=i, object_id, algo, chunk_size))
    - blake3_i = BLAKE3(c_i)
-3. blake3_file = BLAKE3(c_0||c_1||...)
+3. blake3_file = BLAKE3(c_0 || c_1 || ...)
-4. Manifest = { version, algo, chunk_size, length, blake3_file, chunks[] }
+4. Manifest = { version, algo, chunk_size, length, blake3_file, chunks[] }  # signature excludes sig field
-5. sig = Ed25519.sign(manifest_without_sig)
+5. sig = Ed25519.sign(JCS(manifest_without_sig))  # RFC 8785 canonical JSON
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- nonce_i = HKDF(DEK, info="file-chunk"||i)[0..12]
- c_i = AES-256-GCM(plaintext_i, nonce_i)
- blake3_i = BLAKE3(c_i)
3. blake3_file = BLAKE3(c_0||c_1||...)
4. Manifest = { version, algo, chunk_size, length, blake3_file, chunks[] }
5. sig = Ed25519.sign(manifest_without_sig)
- Derive 96-bit nonce_i deterministically:
Let PRK = HKDF-Extract(salt = blake3_file, IKM = DEK)
nonce_i = HKDF-Expand(PRK, info = "nonce:file-chunk:v1" || LE64(i), L = 12) # bytes 0..11
- c_i = AES-256-GCM(plaintext_i, nonce_i,
AAD = concat("e2ee-file:v1", version, chunk_index = i, object_id, algo, chunk_size))
- blake3_i = BLAKE3(c_i)
3. blake3_file = BLAKE3(c_0 || c_1 || ...)
4. Manifest = { version, algo, chunk_size, length, blake3_file, chunks[] } # signature excludes sig field
5. sig = Ed25519.sign(JCS(manifest_without_sig)) # RFC 8785 canonical JSON
🧰 Tools
🪛 LanguageTool

[grammar] ~16-~16: There might be a mistake here.
Context: ...= HKDF(DEK, info="file-chunk"||i)[0..12] - c_i = AES-256-GCM(plaintext_i, nonce_i) ...

(QB_NEW_EN)


[grammar] ~17-~17: There might be a mistake here.
Context: ... c_i = AES-256-GCM(plaintext_i, nonce_i) - blake3_i = BLAKE3(c_i) 3. blake3_file = ...

(QB_NEW_EN)


[grammar] ~18-~18: There might be a mistake here.
Context: ..._i, nonce_i) - blake3_i = BLAKE3(c_i) 3. blake3_file = BLAKE3(c_0||c_1||...) 4. M...

(QB_NEW_EN)


[grammar] ~19-~19: There might be a mistake here.
Context: ...) 3. blake3_file = BLAKE3(c_0||c_1||...) 4. Manifest = { version, algo, chunk_size, ...

(QB_NEW_EN)


[grammar] ~20-~20: There might be a mistake here.
Context: ...nk_size, length, blake3_file, chunks[] } 5. sig = Ed25519.sign(manifest_without_sig)...

(QB_NEW_EN)


[grammar] ~21-~21: There might be a mistake here.
Context: ...sig = Ed25519.sign(manifest_without_sig) 6. Upload chunks and manifest (ciphertext o...

(QB_NEW_EN)

🪛 markdownlint-cli2 (0.17.2)

16-16: Reversed link syntax
(DEK, info="file-chunk"||i)[0..12]

(MD011, no-reversed-links)

🤖 Prompt for AI Agents
In docs/sequences/e2ee-sequences.md around lines 16–21, the nonce derivation and
AES-GCM usage are ambiguous and trigger MD lint warnings: replace the
bracket-slice notation and clearly specify a 96-bit (12‑byte) HKDF-based nonce
construction (e.g. use HKDF-Extract/Expand and take the first 12 bytes of
HKDF-Expand output), and explicitly bind additional authenticated data (AAD)
that includes per-object context (for example file identifier, manifest version
or chunk index) to AES-256-GCM to prevent cross-object replay; update the text
to show the HKDF construction name (HKDF-Extract/Expand), specify which input is
salt/IKM and info, remove the [0..12] slice syntax, and note that AAD should
include the manifest (or file-id||version) when encrypting each chunk before
computing BLAKE3 and signing the manifest.

6. Upload chunks and manifest (ciphertext only)
7. For each recipient device pk_X:
- wrap = sealed_box(DEK, pk_X)
- POST key_wraps(object_id, device_id, wrap)

## 3) Membership change -> Rekey
1. Admin changes room members
2. Server emits WS membership-change event
3. Clients rotate sender key and/or rewrap DEKs
4. New wraps stored; old wraps invalidated
5. Capability issuance follows narrow scopes with TTL
Loading