Files
cairnquire/.project/specs/security-specification.md

12 KiB

Security Specification

Threat Model

Assets

  1. Document Content: Markdown files and attachments
  2. User Credentials: Passkeys, passwords, session tokens
  3. Collaboration Data: Comments, notifications, history
  4. System State: Database, configuration, logs

Threat Actors

  1. External Attacker: No system access, attempts remote exploitation
  2. Malicious User: Authenticated, attempts unauthorized access
  3. Compromised Client: Legitimate user's device infected
  4. Insider: Administrator or developer with system access

Threats

  1. Supply Chain Attack: Malicious dependency injection
  2. Authentication Bypass: Session hijacking, credential theft
  3. Authorization Bypass: Horizontal/vertical privilege escalation
  4. Data Exfiltration: Unauthorized document access
  5. Data Tampering: Modification without authorization
  6. Denial of Service: Resource exhaustion
  7. Information Disclosure: Secrets in logs/responses

Security Controls

1. Supply Chain Security

Policy:

  • Only dependencies with >1M downloads/month or official Go team
  • All versions pinned in go.sum
  • No transitive dependencies without review
  • Prefer standard library when possible

Implementation:

# go.mod
require (
    github.com/go-chi/chi/v5 v5.0.12
    github.com/tursodatabase/libsql-client-go v0.0.0-20240416075031-555ce55511f7
    // All versions pinned
)

# Build
RUN go mod verify  # Verify checksums
RUN go mod vendor  # Optional: vendor for air-gapped

Audit:

  • Weekly govulncheck scan
  • Dependency review in CI
  • No build-time network access (vendored or proxy with checksum)

2. Authentication Security

Passkey (WebAuthn)

// Registration
func registerPasskey(user User) (*Credential, error) {
    options, session, err := webauthn.BeginRegistration(user)
    // Store session data server-side (never client-side)
    // Return options to client
}

func verifyRegistration(sessionID string, response ProtocolCredentialCreation) (*Credential, error) {
    session := getSessionData(sessionID)
    credential, err := webauthn.FinishRegistration(user, session, response)
    // Verify attestation
    // Store credential: ID, public key, sign count
}

Password (Argon2id)

import "golang.org/x/crypto/argon2"

const (
    timeCost    = 3
    memoryCost  = 64 * 1024  // 64MB
    parallelism = 4
    saltLength  = 16
    keyLength   = 32
)

func hashPassword(password string) (string, error) {
    salt := make([]byte, saltLength)
    if _, err := rand.Read(salt); err != nil {
        return "", err
    }
    
    hash := argon2.IDKey([]byte(password), salt, timeCost, memoryCost, parallelism, keyLength)
    
    // Encode to string: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
    return encodeHash(salt, hash), nil
}

func verifyPassword(password, encodedHash string) (bool, error) {
    salt, hash, params, err := decodeHash(encodedHash)
    if err != nil {
        return false, err
    }
    
    computedHash := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.parallelism, uint32(len(hash)))
    
    // Constant-time comparison
    return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil
}

Session Management

func createSession(userID string, r *http.Request) (*Session, error) {
    token := make([]byte, 32)
    if _, err := rand.Read(token); err != nil {
        return nil, err
    }
    
    session := &Session{
        ID:        uuid.New().String(),
        UserID:    userID,
        TokenHash: sha256.Sum256(token),
        CreatedAt: time.Now(),
        ExpiresAt: time.Now().Add(24 * time.Hour),
        IP:        r.RemoteAddr,
        UserAgent: r.UserAgent(),
    }
    
    // Store in database
    if err := db.CreateSession(session); err != nil {
        return nil, err
    }
    
    // Set cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "session",
        Value:    base64.URLEncoding.EncodeToString(token),
        Expires:  session.ExpiresAt,
        HttpOnly: true,
        Secure:   true,        // HTTPS only
        SameSite: http.SameSiteStrictMode,
        Path:     "/",
    })
    
    return session, nil
}

3. Authorization

Permission Model

type Permission string

const (
    PermissionRead  Permission = "read"
    PermissionWrite Permission = "write"
    PermissionAdmin Permission = "admin"
)

type ResourceType string

const (
    ResourceGlobal     ResourceType = "global"
    ResourceCollection ResourceType = "collection"
    ResourceDocument   ResourceType = "document"
)

func checkPermission(userID string, resourceType ResourceType, resourceID string, required Permission) (bool, error) {
    // Check explicit permission
    perm, err := db.GetPermission(userID, resourceType, resourceID)
    if err == nil && hasPermission(perm.Permission, required) {
        return true, nil
    }
    
    // Check collection permission for documents
    if resourceType == ResourceDocument {
        collectionID := getDocumentCollection(resourceID)
        perm, err = db.GetPermission(userID, ResourceCollection, collectionID)
        if err == nil && hasPermission(perm.Permission, required) {
            return true, nil
        }
    }
    
    // Check global permission
    perm, err = db.GetPermission(userID, ResourceGlobal, "")
    if err == nil && hasPermission(perm.Permission, required) {
        return true, nil
    }
    
    // Check document public status
    if resourceType == ResourceDocument && required == PermissionRead {
        doc, err := db.GetDocument(resourceID)
        if err == nil && doc.PublicRead {
            return true, nil
        }
    }
    
    return false, nil
}

Middleware

func RequirePermission(resourceType ResourceType, required Permission) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            userID := getUserID(r.Context())
            resourceID := chi.URLParam(r, "id")
            
            allowed, err := checkPermission(userID, resourceType, resourceID, required)
            if err != nil || !allowed {
                http.Error(w, "Forbidden", http.StatusForbidden)
                return
            }
            
            next.ServeHTTP(w, r)
        })
    }
}

// Usage:
r.With(RequirePermission(ResourceDocument, PermissionRead)).
    Get("/docs/{id}", getDocumentHandler)

4. Input Validation

Markdown Sanitization

import "github.com/yuin/goldmark"
import "github.com/yuin/goldmark/extension"
import "github.com/yuin/goldmark/renderer/html"

func renderMarkdown(source []byte) ([]byte, error) {
    md := goldmark.New(
        goldmark.WithExtensions(
            extension.Table,
            extension.Strikethrough,
            extension.Linkify,
            // No raw HTML extension — we control HTML output
        ),
        goldmark.WithRendererOptions(
            html.WithHardWraps(),
            html.WithXHTML(),
            html.WithUnsafe(), // DISABLED — no raw HTML passthrough
        ),
    )
    
    var buf bytes.Buffer
    if err := md.Convert(source, &buf); err != nil {
        return nil, err
    }
    
    return buf.Bytes(), nil
}

File Upload Validation

func validateUpload(file io.Reader, filename string, maxSize int64) ([]byte, string, error) {
    // Size limit
    limited := io.LimitReader(file, maxSize+1)
    data, err := io.ReadAll(limited)
    if err != nil {
        return nil, "", err
    }
    if int64(len(data)) > maxSize {
        return nil, "", fmt.Errorf("file too large")
    }
    
    // Magic number validation
    contentType := detectContentType(data)
    allowedTypes := map[string]bool{
        "image/jpeg": true,
        "image/png": true,
        "image/gif": true,
        "image/webp": true,
        "application/pdf": true,
    }
    if !allowedTypes[contentType] {
        return nil, "", fmt.Errorf("file type not allowed")
    }
    
    // Extension whitelist
    ext := strings.ToLower(filepath.Ext(filename))
    allowedExts := map[string]bool{
        ".jpg": true, ".jpeg": true, ".png": true,
        ".gif": true, ".webp": true, ".pdf": true,
    }
    if !allowedExts[ext] {
        return nil, "", fmt.Errorf("file extension not allowed")
    }
    
    // Compute hash
    hash := sha256.Sum256(data)
    hashStr := hex.EncodeToString(hash[:])
    
    return data, hashStr, nil
}

Path Sanitization

func sanitizePath(userPath string) (string, error) {
    // Clean the path
    clean := filepath.Clean(userPath)
    
    // Prevent traversal
    if strings.Contains(clean, "..") {
        return "", fmt.Errorf("path traversal detected")
    }
    
    // Ensure it's relative
    if filepath.IsAbs(clean) {
        return "", fmt.Errorf("absolute paths not allowed")
    }
    
    // Join with base and verify it's within base
    fullPath := filepath.Join(baseDir, clean)
    if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(baseDir)) {
        return "", fmt.Errorf("path outside allowed directory")
    }
    
    return clean, nil
}

5. Transport Security

TLS Configuration

tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS13,
    CurvePreferences: []tls.CurveID{
        tls.X25519,
        tls.CurveP256,
    },
    CipherSuites: []uint16{
        tls.TLS_AES_256_GCM_SHA384,
        tls.TLS_CHACHA20_POLY1305_SHA256,
        tls.TLS_AES_128_GCM_SHA256,
    },
    PreferServerCipherSuites: true,
}

Security Headers

func securityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Security-Policy",
            "default-src 'self'; "+
            "script-src 'self' 'nonce-{nonce}'; "+
            "style-src 'self' 'unsafe-inline'; "+
            "img-src 'self' data:; "+
            "connect-src 'self' wss:; "+
            "frame-ancestors 'none'; "+
            "base-uri 'self'; "+
            "form-action 'self';")
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("X-XSS-Protection", "1; mode=block")
        w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
        w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
        w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
        
        next.ServeHTTP(w, r)
    })
}

6. Operational Security

Logging

func logAudit(event AuditEvent) {
    entry := slog.With(
        "event_type", event.Type,
        "actor_id", redactIfSecret(event.ActorID),
        "resource_type", event.ResourceType,
        "resource_id", redactIfSecret(event.ResourceID),
        "ip", event.IP,
        "timestamp", event.Timestamp,
    )
    
    // Never log: passwords, tokens, session IDs, API keys
    entry.Info("audit event")
}

func redactIfSecret(value string) string {
    if looksLikeSecret(value) {
        return "[REDACTED]"
    }
    return value
}

Container Security

# Dockerfile
FROM golang:1.22-alpine AS builder
# ... build ...

FROM alpine:3.19
RUN addgroup -g 1000 appgroup && \
    adduser -u 1000 -G appgroup -s /bin/sh -D appuser

# Read-only root filesystem
COPY --from=builder /app/server /server
COPY --from=builder /app/web/dist /web

# Data directory (writable)
RUN mkdir /data && chown appuser:appgroup /data
VOLUME /data

USER appuser
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s \
    CMD wget -q --spider http://localhost:8080/health || exit 1

ENTRYPOINT ["/server"]

Incident Response

Detection

  • Automated alerts on: failed auth spikes, permission errors, large file uploads
  • Log analysis for anomalies
  • Health check failures

Response

  1. Isolate: Block IP, revoke sessions, disable account if compromised
  2. Assess: Determine scope of breach, affected data
  3. Contain: Patch vulnerability, rotate secrets
  4. Recover: Restore from clean backup if necessary
  5. Document: Post-incident review, update security measures

Compliance

GDPR

  • Data minimization: only email stored, no other PII
  • Right to erasure: /api/user/delete endpoint
  • Data portability: /api/user/export endpoint
  • Consent: explicit opt-in for email notifications

Audit Trail

All security-relevant events logged:

  • Authentication attempts (success/failure)
  • Permission changes
  • Document access (if private)
  • Admin actions
  • Configuration changes

Retention: 90 days active, 1 year archive