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