# 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.*
