1482 lines
43 KiB
Markdown
1482 lines
43 KiB
Markdown
# Dealspace — Security Architecture Specification
|
|
|
|
**Version:** 1.0 — 2026-02-28
|
|
**Status:** Authoritative. Addresses all P0/P1 gaps identified in SPEC-REVIEW.md.
|
|
**Scope:** Authentication, authorization, cryptography, session management, channel security, threat model.
|
|
|
|
---
|
|
|
|
## 1. Threat Model
|
|
|
|
### 1.1 Adversaries
|
|
|
|
| Adversary | Motivation | Capability | Examples |
|
|
|-----------|------------|------------|----------|
|
|
| **Nation-state APT** | Economic espionage, strategic intelligence | Unlimited budget, zero-days, supply chain | Acquiring competitor intel, blocking foreign acquisition |
|
|
| **Competing bidder** | Deal advantage, price manipulation | Social engineering, insider recruitment | Learning seller's reserve price, sabotaging rival bids |
|
|
| **Insider threat** | Financial gain, revenge | Legitimate access, knowledge of systems | Disgruntled IB analyst, seller employee with grudge |
|
|
| **Opportunistic attacker** | Financial gain | Automated scanning, credential stuffing | Ransomware operators, data brokers |
|
|
| **Activist/hacktivist** | Disruption, exposure | DDoS, data leaks, defacement | Anti-M&A groups, short sellers |
|
|
|
|
### 1.2 Crown Jewels
|
|
|
|
**Tier 1 — Catastrophic if leaked:**
|
|
- Deal terms (valuation, structure, conditions)
|
|
- Financial statements before public filing
|
|
- Due diligence findings (legal exposure, hidden liabilities)
|
|
- Buyer interest/bid information
|
|
- Communication threads revealing negotiation strategy
|
|
|
|
**Tier 2 — Serious if leaked:**
|
|
- Request lists (reveals deal scope and concerns)
|
|
- Participant identities (who's involved in the deal)
|
|
- Timeline information (deal velocity, closing targets)
|
|
- Internal routing (who's responsible for what)
|
|
|
|
**Tier 3 — Embarrassing but recoverable:**
|
|
- System configuration details
|
|
- User metadata (login times, session patterns)
|
|
- Performance metrics
|
|
|
|
### 1.3 Top 5 Attack Vectors & Mitigations
|
|
|
|
| # | Attack Vector | Impact | Mitigation |
|
|
|---|--------------|--------|------------|
|
|
| 1 | **Email spoofing** — attacker sends email as `cfo@seller.com` to inject false answers | Critical: false data in deal room | DKIM mandatory, channel_participants table, quarantine flow (§5) |
|
|
| 2 | **Session hijacking** — stolen JWT used to access deal data | Critical: full account takeover | Short-lived tokens, fingerprint binding, single session (§6) |
|
|
| 3 | **Privilege escalation** — seller_member grants themselves seller_admin | High: unauthorized access expansion | Role hierarchy enforcement, audit logging (§3.4) |
|
|
| 4 | **Cryptographic key compromise** — master key leaked | Critical: all data exposed | Key versioning, rotation procedure, HSM escrow (§4) |
|
|
| 5 | **Insider data exfiltration** — authorized user bulk-downloads deal room | High: deal data leaked to competitors | Rate limiting, watermarking, anomaly detection (§9, SPEC.md §6.2) |
|
|
|
|
---
|
|
|
|
## 2. Authentication Flow
|
|
|
|
### 2.1 JWT Structure
|
|
|
|
All JWTs use **HMAC-SHA256** (HS256) for signing. The signing key is derived per environment.
|
|
|
|
```json
|
|
{
|
|
"header": {
|
|
"alg": "HS256",
|
|
"typ": "JWT"
|
|
},
|
|
"payload": {
|
|
"sub": "user_id (UUID)",
|
|
"iss": "dealspace",
|
|
"iat": 1709100000,
|
|
"exp": 1709103600,
|
|
"jti": "unique_token_id (UUID)",
|
|
"sid": "session_id (UUID)",
|
|
"fp": "device_fingerprint_hash",
|
|
"org": "organization_id",
|
|
"roles": ["ib_admin"],
|
|
"mfa": true,
|
|
"ver": 1
|
|
}
|
|
}
|
|
```
|
|
|
|
**Field definitions:**
|
|
|
|
| Field | Purpose | Notes |
|
|
|-------|---------|-------|
|
|
| `sub` | User identifier | UUID, never email |
|
|
| `jti` | Token identifier | For revocation lookup |
|
|
| `sid` | Session identifier | Links access + refresh tokens |
|
|
| `fp` | Fingerprint hash | HMAC-SHA256 of device characteristics |
|
|
| `mfa` | MFA completed | Boolean, must be true for sensitive ops |
|
|
| `ver` | Token schema version | For forward compatibility |
|
|
|
|
### 2.2 Token Lifetimes
|
|
|
|
| Token Type | Lifetime | Renewal |
|
|
|------------|----------|---------|
|
|
| Access token | **1 hour** | Via refresh token |
|
|
| Refresh token | **7 days** | Sliding window on use |
|
|
| MFA challenge | **5 minutes** | One-time use |
|
|
| Password reset | **15 minutes** | One-time use |
|
|
| Invite token | **72 hours** | One-time use |
|
|
|
|
### 2.3 Token Refresh Flow
|
|
|
|
```
|
|
Client Server
|
|
| |
|
|
|-- POST /auth/refresh ------------>|
|
|
| { refresh_token, fingerprint } |
|
|
| |
|
|
| Validate: |
|
|
| - refresh_token not revoked |
|
|
| - fingerprint matches sid |
|
|
| - user still has access |
|
|
| |
|
|
|<-- 200 { access_token, --------- |
|
|
| refresh_token (rotated) } |
|
|
| |
|
|
| Old refresh_token invalidated |
|
|
```
|
|
|
|
**Refresh token rotation:** Every refresh issues a new refresh token and invalidates the old one. Detects token theft (attacker's stolen token fails on next use).
|
|
|
|
### 2.4 Token Revocation
|
|
|
|
Revocation is immediate and enforced at the API gateway.
|
|
|
|
**Revocation triggers:**
|
|
- User logs out
|
|
- Password change
|
|
- Access grant revoked
|
|
- MFA reset
|
|
- Admin force-logout
|
|
- Anomaly detection trigger
|
|
|
|
**Implementation:**
|
|
|
|
```sql
|
|
CREATE TABLE revoked_tokens (
|
|
jti TEXT PRIMARY KEY,
|
|
revoked_at INTEGER NOT NULL,
|
|
reason TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL -- cleanup after original expiry
|
|
);
|
|
|
|
CREATE INDEX idx_revoked_expires ON revoked_tokens(expires_at);
|
|
```
|
|
|
|
Every request validates `jti` against `revoked_tokens`. Cache in Redis with TTL matching token expiry to avoid DB hit per request.
|
|
|
|
### 2.5 MFA — Mandatory for IB Admins
|
|
|
|
**Requirement:** All users with `ib_admin` or `ib_member` roles MUST have MFA enabled. Login without MFA succeeds but returns `mfa: false` token that can only access `/auth/mfa/*` endpoints.
|
|
|
|
**TOTP Implementation (RFC 6238):**
|
|
|
|
```go
|
|
type TOTPConfig struct {
|
|
Secret []byte // 20 bytes, base32 encoded for user
|
|
Algorithm string // "SHA1" (compatibility) or "SHA256" (preferred)
|
|
Digits int // 6
|
|
Period int // 30 seconds
|
|
}
|
|
```
|
|
|
|
**Enrollment flow:**
|
|
1. User initiates MFA setup
|
|
2. Server generates 160-bit secret, stores encrypted in `users.mfa_secret`
|
|
3. Server returns QR code (otpauth:// URI) + recovery codes
|
|
4. User scans with authenticator app
|
|
5. User enters current TOTP to confirm
|
|
6. MFA enabled; recovery codes stored hashed
|
|
|
|
**Recovery codes:**
|
|
- 10 codes, 8 alphanumeric characters each
|
|
- Stored as bcrypt hashes
|
|
- Each code single-use
|
|
- Regenerate all codes if <3 remain
|
|
|
|
**Verification:**
|
|
```go
|
|
func VerifyTOTP(secret []byte, code string) bool {
|
|
// Accept current window ± 1 (90-second tolerance for clock drift)
|
|
for offset := -1; offset <= 1; offset++ {
|
|
expected := generateTOTP(secret, time.Now().Add(time.Duration(offset*30)*time.Second))
|
|
if subtle.ConstantTimeCompare([]byte(code), []byte(expected)) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
```
|
|
|
|
### 2.6 SSO/SAML Integration
|
|
|
|
Enterprise buyers and large sell-side firms will require SSO. Architecture supports SAML 2.0 federation.
|
|
|
|
**Database schema:**
|
|
|
|
```sql
|
|
CREATE TABLE sso_providers (
|
|
id TEXT PRIMARY KEY,
|
|
org_id TEXT NOT NULL REFERENCES organizations(id),
|
|
provider_type TEXT NOT NULL, -- "saml" | "oidc"
|
|
metadata_url TEXT, -- SAML metadata URL
|
|
entity_id TEXT NOT NULL,
|
|
certificate TEXT NOT NULL, -- packed: IdP signing cert
|
|
acs_url TEXT NOT NULL, -- Assertion Consumer Service URL
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE sso_sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
provider_id TEXT NOT NULL REFERENCES sso_providers(id),
|
|
saml_session_id TEXT, -- IdP session for SLO
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
```
|
|
|
|
**SAML flow:**
|
|
1. User clicks "Sign in with [Company] SSO"
|
|
2. Server generates AuthnRequest, redirects to IdP
|
|
3. IdP authenticates user, returns signed Assertion
|
|
4. Server validates signature against stored certificate
|
|
5. Server extracts user identity from Assertion
|
|
6. If user exists with matching SSO link → issue JWT
|
|
7. If user unknown → check if org allows JIT provisioning
|
|
8. JIT provisioning: create user with default role, require admin approval for elevated access
|
|
|
|
**Attribute mapping:**
|
|
```go
|
|
type SAMLAttributeMap struct {
|
|
Email string // e.g., "urn:oid:0.9.2342.19200300.100.1.3"
|
|
FirstName string
|
|
LastName string
|
|
Groups string // optional, for role mapping
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Invitation Flow
|
|
|
|
### 3.1 Invite Token Structure
|
|
|
|
Invitations use cryptographically secure, time-limited, single-use tokens.
|
|
|
|
```go
|
|
type Invite struct {
|
|
ID string // UUID
|
|
Token string // 32-byte random, base64url encoded
|
|
TokenHash string // SHA-256 of token (stored, never the raw token)
|
|
ProjectID string
|
|
WorkstreamID string // optional, null = all workstreams
|
|
Role string // assigned at invite time
|
|
Email string // target email
|
|
InvitedBy string // user ID
|
|
ExpiresAt int64 // unix ms, default +72h
|
|
AcceptedAt int64 // null until used
|
|
RevokedAt int64 // null unless revoked
|
|
}
|
|
```
|
|
|
|
**Database schema:**
|
|
|
|
```sql
|
|
CREATE TABLE invites (
|
|
id TEXT PRIMARY KEY,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
workstream_id TEXT REFERENCES entries(entry_id),
|
|
role TEXT NOT NULL,
|
|
email TEXT NOT NULL,
|
|
invited_by TEXT NOT NULL REFERENCES users(id),
|
|
expires_at INTEGER NOT NULL,
|
|
accepted_at INTEGER,
|
|
revoked_at INTEGER,
|
|
accepted_by TEXT REFERENCES users(id),
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE INDEX idx_invites_email ON invites(email);
|
|
CREATE INDEX idx_invites_project ON invites(project_id);
|
|
```
|
|
|
|
### 3.2 Invite Generation
|
|
|
|
```go
|
|
func CreateInvite(ctx context.Context, projectID, workstreamID, role, email, inviterID string) (*Invite, error) {
|
|
// Validate inviter can grant this role (see §3.4)
|
|
if !canGrantRole(ctx, inviterID, projectID, role) {
|
|
return nil, ErrInsufficientPrivilege
|
|
}
|
|
|
|
// Generate cryptographically secure token
|
|
tokenBytes := make([]byte, 32)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
return nil, err
|
|
}
|
|
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
|
tokenHash := sha256Hex(tokenBytes)
|
|
|
|
invite := &Invite{
|
|
ID: uuid.New().String(),
|
|
Token: token, // returned to caller, NOT stored
|
|
TokenHash: tokenHash,
|
|
ProjectID: projectID,
|
|
WorkstreamID: workstreamID,
|
|
Role: role,
|
|
Email: strings.ToLower(strings.TrimSpace(email)),
|
|
InvitedBy: inviterID,
|
|
ExpiresAt: time.Now().Add(72 * time.Hour).UnixMilli(),
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
|
|
// Store invite (token_hash, not token)
|
|
if err := storeInvite(ctx, invite); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Send invite email
|
|
if err := sendInviteEmail(ctx, invite); err != nil {
|
|
// Log but don't fail — invite is valid, email is best-effort
|
|
log.Warn("invite email failed", "invite_id", invite.ID, "error", err)
|
|
}
|
|
|
|
return invite, nil
|
|
}
|
|
```
|
|
|
|
### 3.3 Invite Acceptance
|
|
|
|
```go
|
|
func AcceptInvite(ctx context.Context, token string, userID string) error {
|
|
tokenHash := sha256Hex([]byte(token))
|
|
|
|
invite, err := getInviteByTokenHash(ctx, tokenHash)
|
|
if err != nil {
|
|
return ErrInvalidInvite
|
|
}
|
|
|
|
// Validate invite state
|
|
now := time.Now().UnixMilli()
|
|
if invite.AcceptedAt != 0 {
|
|
return ErrInviteAlreadyUsed
|
|
}
|
|
if invite.RevokedAt != 0 {
|
|
return ErrInviteRevoked
|
|
}
|
|
if invite.ExpiresAt < now {
|
|
return ErrInviteExpired
|
|
}
|
|
|
|
// Verify email matches (if user already exists)
|
|
user, err := getUser(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.EqualFold(user.Email, invite.Email) {
|
|
return ErrEmailMismatch
|
|
}
|
|
|
|
// Grant access atomically with invite acceptance
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Mark invite as accepted
|
|
if err := markInviteAccepted(ctx, tx, invite.ID, userID, now); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create access grant
|
|
access := &Access{
|
|
ID: uuid.New().String(),
|
|
ProjectID: invite.ProjectID,
|
|
WorkstreamID: invite.WorkstreamID,
|
|
UserID: userID,
|
|
Role: invite.Role,
|
|
Ops: defaultOpsForRole(invite.Role),
|
|
GrantedBy: invite.InvitedBy,
|
|
GrantedAt: now,
|
|
}
|
|
if err := createAccess(ctx, tx, access); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
```
|
|
|
|
### 3.4 Role Hierarchy & Grant Permissions
|
|
|
|
Users can only grant roles at or below their level:
|
|
|
|
```go
|
|
var RoleHierarchy = map[string]int{
|
|
"ib_admin": 100,
|
|
"ib_member": 80,
|
|
"seller_admin": 70,
|
|
"seller_member": 50,
|
|
"buyer_admin": 40,
|
|
"buyer_member": 30,
|
|
"observer": 10,
|
|
}
|
|
|
|
func canGrantRole(ctx context.Context, granterID, projectID, targetRole string) bool {
|
|
granterAccess, err := getAccess(ctx, granterID, projectID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
granterLevel := RoleHierarchy[granterAccess.Role]
|
|
targetLevel := RoleHierarchy[targetRole]
|
|
|
|
// Can only grant roles at or below your level
|
|
if targetLevel > granterLevel {
|
|
return false
|
|
}
|
|
|
|
// Cross-org restrictions
|
|
// IB can grant any role
|
|
if strings.HasPrefix(granterAccess.Role, "ib_") {
|
|
return true
|
|
}
|
|
// Sellers can only grant seller roles
|
|
if strings.HasPrefix(granterAccess.Role, "seller_") {
|
|
return strings.HasPrefix(targetRole, "seller_") || targetRole == "observer"
|
|
}
|
|
// Buyers can only grant buyer roles
|
|
if strings.HasPrefix(granterAccess.Role, "buyer_") {
|
|
return strings.HasPrefix(targetRole, "buyer_") || targetRole == "observer"
|
|
}
|
|
|
|
return false
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. FIPS 140-3 Cryptography
|
|
|
|
### 4.1 Validated Module Selection
|
|
|
|
**Primary:** Google BoringCrypto (FIPS 140-2 validated, 140-3 in progress)
|
|
|
|
Build Go with BoringCrypto:
|
|
```bash
|
|
GOEXPERIMENT=boringcrypto go build
|
|
```
|
|
|
|
**Alternative for HSM integration:** AWS CloudHSM or Azure Dedicated HSM for master key storage.
|
|
|
|
**Verification:**
|
|
```go
|
|
import "crypto/boring"
|
|
|
|
func init() {
|
|
if !boring.Enabled() {
|
|
panic("BoringCrypto not enabled — FIPS compliance requires boringcrypto build")
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.2 Key Derivation — HKDF-SHA256
|
|
|
|
All derived keys use HKDF (RFC 5869):
|
|
|
|
```go
|
|
import "golang.org/x/crypto/hkdf"
|
|
|
|
func DeriveKey(master []byte, context string, length int) []byte {
|
|
// info = domain separator + context
|
|
info := []byte("dealspace:" + context)
|
|
|
|
reader := hkdf.New(sha256.New, master, nil, info)
|
|
key := make([]byte, length)
|
|
if _, err := io.ReadFull(reader, key); err != nil {
|
|
panic(err)
|
|
}
|
|
return key
|
|
}
|
|
|
|
// Specific derivation functions
|
|
func DeriveProjectKey(master []byte, projectID string) []byte {
|
|
return DeriveKey(master, "project:"+projectID, 32)
|
|
}
|
|
|
|
func DeriveSearchIndexKey(master []byte) []byte {
|
|
return DeriveKey(master, "search-index", 32)
|
|
}
|
|
|
|
func DeriveJWTSigningKey(master []byte) []byte {
|
|
return DeriveKey(master, "jwt-signing", 32)
|
|
}
|
|
```
|
|
|
|
### 4.3 Blind Indexes (Replacing Deterministic Encryption)
|
|
|
|
**Problem:** Deterministic encryption enables frequency analysis and known-plaintext attacks.
|
|
|
|
**Solution:** Blind indexes — HMAC-SHA256 of plaintext, truncated for storage efficiency.
|
|
|
|
```go
|
|
func BlindIndex(key []byte, plaintext string, bits int) string {
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write([]byte(strings.ToLower(strings.TrimSpace(plaintext))))
|
|
hash := mac.Sum(nil)
|
|
|
|
// Truncate to requested bits (must be multiple of 8)
|
|
bytes := bits / 8
|
|
if bytes > len(hash) {
|
|
bytes = len(hash)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(hash[:bytes])
|
|
}
|
|
|
|
// Usage for SearchKey/SearchKey2
|
|
func PackEntry(entry *Entry, projectKey, indexKey []byte) error {
|
|
// SearchKey: blind index of ref number (e.g., "FIN-042")
|
|
if entry.Data.Ref != "" {
|
|
entry.SearchKey = BlindIndex(indexKey, entry.Data.Ref, 64) // 64-bit index
|
|
}
|
|
|
|
// SearchKey2: blind index of requester org
|
|
if entry.Data.RequesterOrg != "" {
|
|
entry.SearchKey2 = BlindIndex(indexKey, entry.Data.RequesterOrg, 64)
|
|
}
|
|
|
|
// Summary and Data use AES-256-GCM (non-deterministic)
|
|
summaryJSON, _ := json.Marshal(entry.SummaryData)
|
|
entry.Summary = Encrypt(projectKey, summaryJSON)
|
|
|
|
dataJSON, _ := json.Marshal(entry.Data)
|
|
entry.Data = Encrypt(projectKey, dataJSON)
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
**Collision probability:** 64-bit blind index = 2^32 entries before 50% collision probability. For deal platforms: sufficient with monitoring.
|
|
|
|
### 4.4 AES-256-GCM Encryption
|
|
|
|
```go
|
|
func Encrypt(key, plaintext []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 12-byte random nonce (standard for AES-GCM)
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Prepend nonce to ciphertext
|
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
return ciphertext, nil
|
|
}
|
|
|
|
func Decrypt(key, ciphertext []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nonceSize := gcm.NonceSize()
|
|
if len(ciphertext) < nonceSize {
|
|
return nil, ErrCiphertextTooShort
|
|
}
|
|
|
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
|
return gcm.Open(nil, nonce, ciphertext, nil)
|
|
}
|
|
```
|
|
|
|
### 4.5 Key Versioning
|
|
|
|
All encrypted data carries a key version for rotation support:
|
|
|
|
```sql
|
|
ALTER TABLE entries ADD COLUMN key_version INTEGER NOT NULL DEFAULT 1;
|
|
```
|
|
|
|
**Ciphertext format:**
|
|
```
|
|
[1 byte: version] [12 bytes: nonce] [N bytes: ciphertext] [16 bytes: tag]
|
|
```
|
|
|
|
```go
|
|
const CurrentKeyVersion = 1
|
|
|
|
func EncryptVersioned(key []byte, plaintext []byte) ([]byte, error) {
|
|
ciphertext, err := Encrypt(key, plaintext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Prepend version byte
|
|
versioned := make([]byte, 1+len(ciphertext))
|
|
versioned[0] = CurrentKeyVersion
|
|
copy(versioned[1:], ciphertext)
|
|
return versioned, nil
|
|
}
|
|
|
|
func DecryptVersioned(keys map[int][]byte, ciphertext []byte) ([]byte, error) {
|
|
if len(ciphertext) < 1 {
|
|
return nil, ErrCiphertextTooShort
|
|
}
|
|
|
|
version := int(ciphertext[0])
|
|
key, ok := keys[version]
|
|
if !ok {
|
|
return nil, ErrUnknownKeyVersion
|
|
}
|
|
|
|
return Decrypt(key, ciphertext[1:])
|
|
}
|
|
```
|
|
|
|
### 4.6 Key Rotation Procedure
|
|
|
|
**Trigger conditions:**
|
|
- Master key suspected compromised
|
|
- Scheduled rotation (annual minimum)
|
|
- Personnel departure (key custodian)
|
|
- Compliance requirement
|
|
|
|
**Rotation process:**
|
|
|
|
```go
|
|
type KeyRotation struct {
|
|
ID string
|
|
OldVersion int
|
|
NewVersion int
|
|
StartedAt int64
|
|
CompletedAt int64
|
|
Status string // "pending" | "in_progress" | "completed" | "failed"
|
|
ProjectID string // null = all projects
|
|
EntriesTotal int64
|
|
EntriesRotated int64
|
|
ObjectsTotal int64
|
|
ObjectsRotated int64
|
|
}
|
|
```
|
|
|
|
**Step 1: Generate new master key**
|
|
```bash
|
|
# Generate in HSM (preferred) or secure environment
|
|
openssl rand -hex 32 > new_master_key.txt
|
|
# Store in HSM / Vault / secure key management
|
|
```
|
|
|
|
**Step 2: Derive new project keys**
|
|
```go
|
|
func RotateProjectKey(ctx context.Context, projectID string, oldMaster, newMaster []byte) error {
|
|
oldKey := DeriveProjectKey(oldMaster, projectID)
|
|
newKey := DeriveProjectKey(newMaster, projectID)
|
|
newVersion := CurrentKeyVersion + 1
|
|
|
|
// Process entries in batches
|
|
batchSize := 100
|
|
offset := 0
|
|
|
|
for {
|
|
entries, err := getEntriesByProject(ctx, projectID, batchSize, offset)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(entries) == 0 {
|
|
break
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
// Decrypt with old key
|
|
summary, err := Decrypt(oldKey, entry.Summary)
|
|
if err != nil {
|
|
return fmt.Errorf("decrypt summary: %w", err)
|
|
}
|
|
data, err := Decrypt(oldKey, entry.Data)
|
|
if err != nil {
|
|
return fmt.Errorf("decrypt data: %w", err)
|
|
}
|
|
|
|
// Re-encrypt with new key
|
|
entry.Summary, _ = Encrypt(newKey, summary)
|
|
entry.Data, _ = Encrypt(newKey, data)
|
|
entry.KeyVersion = newVersion
|
|
|
|
if err := updateEntry(ctx, entry); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
offset += batchSize
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
**Step 3: Rotate object store**
|
|
```go
|
|
func RotateObjects(ctx context.Context, projectID string, oldMaster, newMaster []byte) error {
|
|
oldKey := DeriveProjectKey(oldMaster, projectID)
|
|
newKey := DeriveProjectKey(newMaster, projectID)
|
|
|
|
objects, err := listObjectsByProject(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, objID := range objects {
|
|
data, err := objectStore.Read(objID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
plaintext, err := Decrypt(oldKey, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
newData, err := Encrypt(newKey, plaintext)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// New object ID (content-addressable)
|
|
newObjID := sha256Hex(newData)
|
|
if err := objectStore.Write(newObjID, newData); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update references in entries
|
|
if err := updateObjectReferences(ctx, projectID, objID, newObjID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete old object after references updated
|
|
objectStore.Delete(objID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
**Step 4: Update key version**
|
|
```go
|
|
// After all entries and objects rotated
|
|
CurrentKeyVersion++
|
|
// Old key can be archived after grace period (30 days)
|
|
```
|
|
|
|
### 4.7 Key Escrow & Recovery
|
|
|
|
**Master key custody:** Split across multiple custodians using Shamir's Secret Sharing.
|
|
|
|
```go
|
|
import "github.com/hashicorp/vault/shamir"
|
|
|
|
func SplitMasterKey(master []byte, shares, threshold int) ([][]byte, error) {
|
|
return shamir.Split(master, shares, threshold)
|
|
}
|
|
|
|
func RecoverMasterKey(shares [][]byte) ([]byte, error) {
|
|
return shamir.Combine(shares)
|
|
}
|
|
```
|
|
|
|
**Recommended configuration:**
|
|
- 5 shares, 3 required to reconstruct
|
|
- Shares stored in separate secure locations:
|
|
- Corporate Vault (CTO)
|
|
- Legal escrow (law firm safe deposit)
|
|
- Backup HSM (geographically separate)
|
|
- CEO secure storage
|
|
- Board-designated custodian
|
|
|
|
**Recovery procedure (documented and tested annually):**
|
|
1. Incident declared by authorized personnel
|
|
2. Two custodians authenticate to key management portal
|
|
3. Each provides their share
|
|
4. System reconstructs master key in HSM
|
|
5. Rotation triggered immediately (compromised key assumed)
|
|
6. Audit log captures all recovery events
|
|
|
|
---
|
|
|
|
## 5. Email Channel Authentication
|
|
|
|
### 5.1 DKIM Verification — Mandatory
|
|
|
|
All inbound email MUST pass DKIM verification. Failed DKIM = quarantine, never process.
|
|
|
|
```go
|
|
import "github.com/emersion/go-msgauth/dkim"
|
|
|
|
func VerifyDKIM(rawEmail []byte) (*DKIMResult, error) {
|
|
verifications, err := dkim.Verify(bytes.NewReader(rawEmail))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &DKIMResult{
|
|
Passed: false,
|
|
Verifications: make([]DKIMVerification, 0),
|
|
}
|
|
|
|
for _, v := range verifications {
|
|
dv := DKIMVerification{
|
|
Domain: v.Domain,
|
|
Selector: v.Identifier,
|
|
Status: v.Err == nil,
|
|
Error: "",
|
|
}
|
|
if v.Err != nil {
|
|
dv.Error = v.Err.Error()
|
|
} else {
|
|
result.Passed = true // At least one valid signature
|
|
}
|
|
result.Verifications = append(result.Verifications, dv)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
```
|
|
|
|
### 5.2 Channel Participants Table
|
|
|
|
Only authorized participants can interact via email. Unknown senders → quarantine.
|
|
|
|
```sql
|
|
CREATE TABLE channel_participants (
|
|
id TEXT PRIMARY KEY,
|
|
entry_id TEXT NOT NULL REFERENCES entries(entry_id),
|
|
channel TEXT NOT NULL, -- "email" | "slack" | "teams"
|
|
external_id TEXT NOT NULL, -- email address, slack user ID, teams user ID
|
|
user_id TEXT REFERENCES users(id), -- linked internal user, if known
|
|
verified INTEGER NOT NULL DEFAULT 0,
|
|
added_by TEXT NOT NULL REFERENCES users(id),
|
|
added_at INTEGER NOT NULL,
|
|
last_used_at INTEGER,
|
|
UNIQUE(entry_id, channel, external_id)
|
|
);
|
|
|
|
CREATE INDEX idx_channel_participants_entry ON channel_participants(entry_id);
|
|
CREATE INDEX idx_channel_participants_external ON channel_participants(channel, external_id);
|
|
```
|
|
|
|
### 5.3 Inbound Message Flow
|
|
|
|
```go
|
|
func ProcessInboundEmail(ctx context.Context, rawEmail []byte) error {
|
|
// Step 1: Parse email
|
|
msg, err := mail.ReadMessage(bytes.NewReader(rawEmail))
|
|
if err != nil {
|
|
return quarantine(ctx, rawEmail, "parse_error", err.Error())
|
|
}
|
|
|
|
// Step 2: DKIM verification (MANDATORY)
|
|
dkimResult, err := VerifyDKIM(rawEmail)
|
|
if err != nil || !dkimResult.Passed {
|
|
return quarantine(ctx, rawEmail, "dkim_failed", "No valid DKIM signature")
|
|
}
|
|
|
|
// Step 3: Extract sender and thread ID
|
|
from := msg.Header.Get("From")
|
|
senderEmail := extractEmail(from)
|
|
inReplyTo := msg.Header.Get("In-Reply-To")
|
|
references := msg.Header.Get("References")
|
|
|
|
// Step 4: Find thread mapping
|
|
threadID := findThreadID(inReplyTo, references)
|
|
thread, err := getChannelThread(ctx, "email", threadID)
|
|
if err != nil || thread == nil {
|
|
return quarantine(ctx, rawEmail, "unknown_thread", "No matching thread found")
|
|
}
|
|
|
|
// Step 5: Verify sender is participant
|
|
participant, err := getChannelParticipant(ctx, thread.EntryID, "email", senderEmail)
|
|
if err != nil || participant == nil {
|
|
return quarantine(ctx, rawEmail, "unknown_sender", "Sender not authorized for this thread")
|
|
}
|
|
if !participant.Verified {
|
|
return quarantine(ctx, rawEmail, "unverified_sender", "Sender not verified")
|
|
}
|
|
|
|
// Step 6: Create entry_event
|
|
event := &EntryEvent{
|
|
ID: uuid.New().String(),
|
|
EntryID: thread.EntryID,
|
|
ActorID: participant.UserID,
|
|
Channel: "email",
|
|
Action: "message",
|
|
Data: packEmailData(msg),
|
|
DKIMResult: dkimResult,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
|
|
if err := createEntryEvent(ctx, event); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 7: Update routing chain if applicable
|
|
return processRoutingTrigger(ctx, thread.EntryID, event)
|
|
}
|
|
```
|
|
|
|
### 5.4 Quarantine Flow
|
|
|
|
```sql
|
|
CREATE TABLE quarantine (
|
|
id TEXT PRIMARY KEY,
|
|
channel TEXT NOT NULL,
|
|
raw_data BLOB NOT NULL, -- packed: original message
|
|
reason TEXT NOT NULL,
|
|
details TEXT,
|
|
sender TEXT,
|
|
thread_id TEXT,
|
|
project_id TEXT, -- if determinable
|
|
reviewed_by TEXT REFERENCES users(id),
|
|
reviewed_at INTEGER,
|
|
disposition TEXT, -- "approved" | "rejected" | "spam"
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE INDEX idx_quarantine_unreviewed ON quarantine(reviewed_at) WHERE reviewed_at IS NULL;
|
|
```
|
|
|
|
**Quarantine review workflow:**
|
|
1. IB admin sees quarantine queue
|
|
2. Reviews message content, sender, DKIM status
|
|
3. Options:
|
|
- **Approve:** Add sender to `channel_participants`, process message
|
|
- **Reject:** Discard, optionally notify sender
|
|
- **Spam:** Blacklist sender domain
|
|
|
|
---
|
|
|
|
## 6. Session Management
|
|
|
|
### 6.1 Session Model
|
|
|
|
```sql
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
fingerprint TEXT NOT NULL, -- hashed device fingerprint
|
|
ip_address TEXT NOT NULL,
|
|
user_agent TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
last_active_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
revoked_at INTEGER,
|
|
revoke_reason TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
|
CREATE INDEX idx_sessions_active ON sessions(user_id, revoked_at) WHERE revoked_at IS NULL;
|
|
```
|
|
|
|
### 6.2 Single Active Session Enforcement
|
|
|
|
Users may have only ONE active session at a time (configurable per organization).
|
|
|
|
```go
|
|
func CreateSession(ctx context.Context, userID, fingerprint, ip, userAgent string) (*Session, error) {
|
|
// Check org session policy
|
|
org, _ := getUserOrg(ctx, userID)
|
|
|
|
if org.SingleSessionPolicy {
|
|
// Revoke all existing sessions for this user
|
|
if err := revokeUserSessions(ctx, userID, "new_session"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
session := &Session{
|
|
ID: uuid.New().String(),
|
|
UserID: userID,
|
|
Fingerprint: hashFingerprint(fingerprint),
|
|
IPAddress: ip,
|
|
UserAgent: userAgent,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
LastActiveAt: time.Now().UnixMilli(),
|
|
ExpiresAt: time.Now().Add(7 * 24 * time.Hour).UnixMilli(),
|
|
}
|
|
|
|
return session, createSession(ctx, session)
|
|
}
|
|
```
|
|
|
|
### 6.3 Device Fingerprint Binding
|
|
|
|
Tokens are bound to device fingerprint. Fingerprint mismatch = token rejected.
|
|
|
|
```go
|
|
type Fingerprint struct {
|
|
UserAgent string
|
|
AcceptLanguage string
|
|
Timezone string
|
|
ScreenRes string // optional, JS-provided
|
|
ColorDepth int // optional
|
|
}
|
|
|
|
func HashFingerprint(fp *Fingerprint) string {
|
|
data, _ := json.Marshal(fp)
|
|
hash := sha256.Sum256(data)
|
|
return base64.RawURLEncoding.EncodeToString(hash[:16]) // 128-bit
|
|
}
|
|
|
|
func ValidateTokenFingerprint(token *JWT, currentFP *Fingerprint) error {
|
|
currentHash := HashFingerprint(currentFP)
|
|
if token.Claims.Fingerprint != currentHash {
|
|
return ErrFingerprintMismatch
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
### 6.4 Immediate Revocation on Access Change
|
|
|
|
When access is revoked, all sessions for that user in that project are invalidated.
|
|
|
|
```go
|
|
func RevokeAccess(ctx context.Context, accessID, revokedBy, reason string) error {
|
|
access, err := getAccess(ctx, accessID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Mark access as revoked
|
|
if err := markAccessRevoked(ctx, tx, accessID, revokedBy, time.Now().UnixMilli()); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Revoke all user's tokens for this project
|
|
if err := revokeTokensForUserProject(ctx, tx, access.UserID, access.ProjectID, "access_revoked"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Audit log
|
|
if err := auditLog(ctx, tx, "access_revoked", revokedBy, access.UserID, map[string]interface{}{
|
|
"project_id": access.ProjectID,
|
|
"role": access.Role,
|
|
"reason": reason,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
```
|
|
|
|
### 6.5 Session Timeout Behavior
|
|
|
|
| Activity State | Timeout |
|
|
|----------------|---------|
|
|
| Active (recent request) | Extend by access token lifetime (1h) |
|
|
| Idle | 15 minutes |
|
|
| Locked screen | Session paused, resume with re-auth |
|
|
| Browser closed | Session continues until refresh token expires |
|
|
|
|
---
|
|
|
|
## 7. Concurrency Control
|
|
|
|
### 7.1 Optimistic Locking with Version Field
|
|
|
|
All entries have a version field. Updates require matching version.
|
|
|
|
```sql
|
|
ALTER TABLE entries ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
|
|
```
|
|
|
|
### 7.2 Update Pattern
|
|
|
|
```go
|
|
func EntryUpdate(ctx context.Context, entry *Entry, expectedVersion int) error {
|
|
result, err := db.ExecContext(ctx, `
|
|
UPDATE entries
|
|
SET summary = ?, data = ?, updated_at = ?, version = version + 1
|
|
WHERE entry_id = ? AND version = ?
|
|
`, entry.Summary, entry.Data, time.Now().UnixMilli(), entry.EntryID, expectedVersion)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return ErrConcurrentModification
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
### 7.3 ETag Implementation
|
|
|
|
HTTP responses include ETag based on version + updated_at.
|
|
|
|
```go
|
|
func EntryETag(entry *Entry) string {
|
|
data := fmt.Sprintf("%s:%d:%d", entry.EntryID, entry.Version, entry.UpdatedAt)
|
|
hash := sha256.Sum256([]byte(data))
|
|
return fmt.Sprintf(`"%s"`, base64.RawURLEncoding.EncodeToString(hash[:8]))
|
|
}
|
|
```
|
|
|
|
**Request flow:**
|
|
1. GET returns `ETag: "abc123"` header
|
|
2. PUT/PATCH includes `If-Match: "abc123"` header
|
|
3. Server validates ETag matches current version
|
|
4. If mismatch: `412 Precondition Failed`
|
|
|
|
```go
|
|
func handleEntryUpdate(w http.ResponseWriter, r *http.Request) {
|
|
entryID := chi.URLParam(r, "id")
|
|
|
|
// Get current entry
|
|
entry, err := EntryRead(r.Context(), actorID, projectID, entryID)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
|
|
// Validate ETag
|
|
ifMatch := r.Header.Get("If-Match")
|
|
currentETag := EntryETag(entry)
|
|
if ifMatch != "" && ifMatch != currentETag {
|
|
w.WriteHeader(http.StatusPreconditionFailed)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": "concurrent_modification",
|
|
"current_etag": currentETag,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Parse update
|
|
var update EntryUpdate
|
|
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
|
writeError(w, ErrBadRequest)
|
|
return
|
|
}
|
|
|
|
// Apply update with version check
|
|
if err := EntryUpdate(r.Context(), entry, entry.Version); err != nil {
|
|
if err == ErrConcurrentModification {
|
|
w.WriteHeader(http.StatusConflict)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": "conflict",
|
|
"message": "Entry was modified by another user",
|
|
})
|
|
return
|
|
}
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
|
|
// Return updated entry with new ETag
|
|
w.Header().Set("ETag", EntryETag(entry))
|
|
json.NewEncoder(w).Encode(entry)
|
|
}
|
|
```
|
|
|
|
### 7.4 Conflict Resolution UI Guidance
|
|
|
|
When conflict occurs, client should:
|
|
1. Fetch current version
|
|
2. Show diff between user's changes and current state
|
|
3. Allow user to merge or overwrite
|
|
4. Retry with new version
|
|
|
|
---
|
|
|
|
## 8. Rate Limiting
|
|
|
|
### 8.1 Limit Tiers
|
|
|
|
| Tier | Per-User | Per-IP | Per-Project | Notes |
|
|
|------|----------|--------|-------------|-------|
|
|
| Authentication | 5/min | 20/min | — | Prevents credential stuffing |
|
|
| API Read | 300/min | 1000/min | 5000/min | Standard operations |
|
|
| API Write | 60/min | 200/min | 1000/min | Mutations |
|
|
| File Upload | 10/min | 30/min | 100/min | Bandwidth protection |
|
|
| File Download | 50/min | 100/min | 500/min | Exfiltration protection |
|
|
| AI Matching | 20/min | 50/min | 200/min | Embedding cost control |
|
|
| Search | 30/min | 100/min | 500/min | Query cost control |
|
|
|
|
### 8.2 Implementation
|
|
|
|
Using sliding window counter with Redis:
|
|
|
|
```go
|
|
type RateLimiter struct {
|
|
redis *redis.Client
|
|
}
|
|
|
|
func (rl *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
|
|
now := time.Now().UnixMilli()
|
|
windowStart := now - window.Milliseconds()
|
|
|
|
pipe := rl.redis.Pipeline()
|
|
|
|
// Remove old entries
|
|
pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(windowStart, 10))
|
|
|
|
// Count current window
|
|
countCmd := pipe.ZCard(ctx, key)
|
|
|
|
// Add current request
|
|
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: strconv.FormatInt(now, 10)})
|
|
|
|
// Set expiry
|
|
pipe.Expire(ctx, key, window)
|
|
|
|
if _, err := pipe.Exec(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
count := countCmd.Val()
|
|
return count < int64(limit), nil
|
|
}
|
|
|
|
// Rate limit keys
|
|
func userRateLimitKey(userID, action string) string {
|
|
return fmt.Sprintf("rl:user:%s:%s", userID, action)
|
|
}
|
|
|
|
func ipRateLimitKey(ip, action string) string {
|
|
return fmt.Sprintf("rl:ip:%s:%s", ip, action)
|
|
}
|
|
|
|
func projectRateLimitKey(projectID, action string) string {
|
|
return fmt.Sprintf("rl:project:%s:%s", projectID, action)
|
|
}
|
|
```
|
|
|
|
### 8.3 Middleware Integration
|
|
|
|
```go
|
|
func RateLimitMiddleware(limiter *RateLimiter, action string, userLimit, ipLimit int, window time.Duration) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
userID := getUserID(ctx)
|
|
ip := getClientIP(r)
|
|
|
|
// Check user limit
|
|
if userID != "" {
|
|
allowed, err := limiter.Allow(ctx, userRateLimitKey(userID, action), userLimit, window)
|
|
if err != nil {
|
|
log.Error("rate limit check failed", "error", err)
|
|
} else if !allowed {
|
|
w.Header().Set("Retry-After", "60")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "rate_limit_exceeded",
|
|
"message": "Too many requests. Please wait before retrying.",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check IP limit
|
|
allowed, err := limiter.Allow(ctx, ipRateLimitKey(ip, action), ipLimit, window)
|
|
if err != nil {
|
|
log.Error("rate limit check failed", "error", err)
|
|
} else if !allowed {
|
|
w.Header().Set("Retry-After", "60")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "rate_limit_exceeded",
|
|
"message": "Too many requests from this IP.",
|
|
})
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.4 Anomaly Detection Integration
|
|
|
|
Rate limiting feeds anomaly detection:
|
|
|
|
```go
|
|
type AnomalyEvent struct {
|
|
UserID string
|
|
IP string
|
|
Action string
|
|
Count int
|
|
Window time.Duration
|
|
Threshold int
|
|
Timestamp int64
|
|
}
|
|
|
|
func (rl *RateLimiter) CheckAnomaly(ctx context.Context, userID, ip, action string) {
|
|
// If user exceeds 80% of limit, log for analysis
|
|
// Pattern: bulk downloads, rapid enumeration, credential stuffing attempts
|
|
|
|
userKey := userRateLimitKey(userID, action)
|
|
count, _ := rl.redis.ZCard(ctx, userKey).Result()
|
|
|
|
// Get limit for this action
|
|
limit := getLimitForAction(action)
|
|
|
|
if float64(count) > float64(limit)*0.8 {
|
|
anomaly := &AnomalyEvent{
|
|
UserID: userID,
|
|
IP: ip,
|
|
Action: action,
|
|
Count: int(count),
|
|
Threshold: limit,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
publishAnomaly(ctx, anomaly)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Audit Logging
|
|
|
|
### 9.1 Audit Events
|
|
|
|
All security-relevant events are logged immutably.
|
|
|
|
```sql
|
|
CREATE TABLE audit (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT,
|
|
actor_id TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT, -- "entry" | "user" | "access" | "session" | "file"
|
|
target_id TEXT,
|
|
details TEXT NOT NULL, -- packed: JSON with action-specific data
|
|
ip_address TEXT NOT NULL,
|
|
user_agent TEXT,
|
|
timestamp INTEGER NOT NULL,
|
|
|
|
-- Immutability enforcement
|
|
hash TEXT NOT NULL, -- SHA-256 of (previous_hash + event_data)
|
|
previous_id TEXT -- chain reference
|
|
);
|
|
|
|
CREATE INDEX idx_audit_project ON audit(project_id, timestamp);
|
|
CREATE INDEX idx_audit_actor ON audit(actor_id, timestamp);
|
|
CREATE INDEX idx_audit_target ON audit(target_type, target_id, timestamp);
|
|
```
|
|
|
|
### 9.2 Event Types
|
|
|
|
| Action | Target | Details |
|
|
|--------|--------|---------|
|
|
| `auth.login` | user | mfa_used, ip, fingerprint |
|
|
| `auth.logout` | session | reason |
|
|
| `auth.mfa_enabled` | user | method |
|
|
| `auth.password_changed` | user | source (user, admin, reset) |
|
|
| `access.granted` | access | role, workstream, granted_by |
|
|
| `access.revoked` | access | reason, revoked_by |
|
|
| `entry.created` | entry | type, parent_id |
|
|
| `entry.updated` | entry | changed_fields |
|
|
| `entry.deleted` | entry | soft/hard |
|
|
| `entry.published` | entry | broadcast_to |
|
|
| `file.uploaded` | file | size, type, entry_id |
|
|
| `file.downloaded` | file | entry_id |
|
|
| `file.deleted` | file | reason |
|
|
| `session.created` | session | fingerprint, ip |
|
|
| `session.revoked` | session | reason |
|
|
| `key.rotated` | key_rotation | version, scope |
|
|
| `quarantine.reviewed` | quarantine | disposition |
|
|
|
|
### 9.3 Hash Chain Integrity
|
|
|
|
Each audit entry includes a hash of itself plus the previous entry, creating a tamper-evident chain.
|
|
|
|
```go
|
|
func CreateAuditEntry(ctx context.Context, entry *AuditEntry) error {
|
|
// Get previous entry hash
|
|
var previousHash string
|
|
var previousID string
|
|
prev, err := getLatestAuditEntry(ctx)
|
|
if err == nil && prev != nil {
|
|
previousHash = prev.Hash
|
|
previousID = prev.ID
|
|
}
|
|
|
|
// Calculate hash
|
|
data := fmt.Sprintf("%s:%s:%s:%s:%d:%s",
|
|
entry.ActorID, entry.Action, entry.TargetID,
|
|
entry.Details, entry.Timestamp, previousHash)
|
|
hash := sha256.Sum256([]byte(data))
|
|
|
|
entry.Hash = hex.EncodeToString(hash[:])
|
|
entry.PreviousID = previousID
|
|
|
|
return insertAuditEntry(ctx, entry)
|
|
}
|
|
|
|
func VerifyAuditChain(ctx context.Context, from, to int64) error {
|
|
entries, err := getAuditEntriesInRange(ctx, from, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var previousHash string
|
|
for _, entry := range entries {
|
|
data := fmt.Sprintf("%s:%s:%s:%s:%d:%s",
|
|
entry.ActorID, entry.Action, entry.TargetID,
|
|
entry.Details, entry.Timestamp, previousHash)
|
|
expectedHash := sha256.Sum256([]byte(data))
|
|
|
|
if entry.Hash != hex.EncodeToString(expectedHash[:]) {
|
|
return fmt.Errorf("audit chain broken at entry %s", entry.ID)
|
|
}
|
|
previousHash = entry.Hash
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Security Checklist for Implementation
|
|
|
|
### 10.1 Pre-Launch
|
|
|
|
- [ ] BoringCrypto build verified (`crypto/boring.Enabled()` returns true)
|
|
- [ ] Master key stored in HSM/Vault with Shamir split
|
|
- [ ] All JWT signing keys derived via HKDF-SHA256
|
|
- [ ] Blind indexes implemented (no deterministic encryption)
|
|
- [ ] MFA mandatory for all ib_admin and ib_member roles
|
|
- [ ] DKIM verification enabled for email ingestion
|
|
- [ ] Rate limiting active on all endpoints
|
|
- [ ] Audit logging capturing all security events
|
|
- [ ] Session single-login policy configurable per org
|
|
- [ ] ETags implemented on all entry mutations
|
|
|
|
### 10.2 Ongoing
|
|
|
|
- [ ] Key rotation drill performed quarterly
|
|
- [ ] Audit chain integrity verified weekly
|
|
- [ ] Rate limit thresholds reviewed monthly
|
|
- [ ] Quarantine queue reviewed daily
|
|
- [ ] Session anomaly reports reviewed weekly
|
|
- [ ] Penetration test annually
|
|
- [ ] SOC 2 Type II audit annually
|
|
|
|
---
|
|
|
|
*This specification is the authoritative source for Dealspace security architecture. If implementation diverges from this spec, the implementation is wrong.*
|