diff --git a/README.md b/README.md index e88162910..63c97c6db 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,78 @@ Access Memos at `http://localhost:5230` and complete the initial setup. **Pro Tip**: The data directory stores all your notes, uploads, and settings. Include it in your backup strategy! +### 🔒 Database Encryption (for SQLite) + +
+ +Memos can protect its SQLite database with **SQLCipher** so that the on-disk file is unreadable without a passphrase. This is *encryption at rest*: the server keeps the key in memory while running, so it does not provide end-to-end encryption for clients. + +> [!IMPORTANT] +> Losing the passphrase means losing your data. Store it safely (for example, in a password manager or a hardware secret vault). + +#### Enable SQLCipher Builds + +- **Docker (recommended)** + ```bash + docker build \ + --build-arg CGO_ENABLED=1 \ + --build-arg MEMOS_BUILD_TAGS="memos_sqlcipher libsqlite3 sqlite_omit_load_extension" \ + -t memos-sqlcipher \ + -f scripts/Dockerfile . + docker run -d \ + --name memos \ + -p 5230:5230 \ + -v ~/.memos:/var/opt/memos \ + -e MEMOS_SQLITE_ENCRYPTION_KEY="your-super-secret-key" \ + memos-sqlcipher + ``` + +- **Manual build** + ```bash + CGO_ENABLED=1 \ + CGO_CFLAGS="-I/usr/include/sqlcipher -DSQLITE_HAS_CODEC" \ + CGO_LDFLAGS="-lsqlcipher" \ + go build -tags "memos_sqlcipher libsqlite3 sqlite_omit_load_extension" -o memos-sqlcipher ./bin/memos + ./memos-sqlcipher --sqlite-encryption-key "your-super-secret-key" ... + ``` + +#### Migration Plan for Existing Deployments + +1. **Full backup** + ```bash + cp ~/.memos/memos_prod.db ~/.memos/memos_prod.db.bak + cp ~/.memos/memos_prod.db-wal ~/.memos/memos_prod.db-wal.bak 2>/dev/null || true + cp ~/.memos/memos_prod.db-shm ~/.memos/memos_prod.db-shm.bak 2>/dev/null || true + ``` + +2. **Stop every Memos instance** touching the database. + +3. **Build the SQLCipher-capable binary or Docker image** using the instructions above. The resulting image already contains the `sqlcipher` CLI. + +4. **Convert the database** using the SQLCipher CLI. You can do this without installing anything on the host: + ```bash + docker run --rm \ + -v ~/.memos:/data \ + memos-sqlcipher \ + sh -c "cd /data && sqlcipher memos_prod.db <<'EOS'\nATTACH DATABASE 'memos_encrypted.db' AS encrypted KEY 'your-super-secret-key';\nSELECT sqlcipher_export('encrypted');\nDETACH DATABASE encrypted;\nEOS" + ``` + If you prefer to run the command directly on the host, install `sqlcipher` (e.g. `brew install sqlcipher`, `apt install sqlcipher`) and execute the same `ATTACH ... sqlcipher_export` sequence locally. + +6. **Swap the files** + ```bash + mv memos_prod.db memos_prod.db.plaintext + mv memos_encrypted.db memos_prod.db + rm -f memos_prod.db-wal memos_prod.db-shm + ``` + +7. **Start the SQLCipher build of Memos** and pass the same key (`MEMOS_SQLITE_ENCRYPTION_KEY` or `--sqlite-encryption-key`). + +8. **Verify the upgrade** + - Log in and ensure your memos/attachments are intact. + - Confirm the file is encrypted: `sqlite3 memos_prod.db '.tables'` should now print `Error: file is not a database`. + +
+ ## Sponsors Memos is made possible by the generous support of our sponsors. Their contributions help ensure the project's continued development, maintenance, and growth. diff --git a/bin/memos/main.go b/bin/memos/main.go index 48dad202d..a5eabd3d2 100644 --- a/bin/memos/main.go +++ b/bin/memos/main.go @@ -25,15 +25,16 @@ var ( Short: `An open source, lightweight note-taking service. Easily capture and share your great thoughts.`, Run: func(_ *cobra.Command, _ []string) { instanceProfile := &profile.Profile{ - Mode: viper.GetString("mode"), - Addr: viper.GetString("addr"), - Port: viper.GetInt("port"), - UNIXSock: viper.GetString("unix-sock"), - Data: viper.GetString("data"), - Driver: viper.GetString("driver"), - DSN: viper.GetString("dsn"), - InstanceURL: viper.GetString("instance-url"), - Version: version.GetCurrentVersion(viper.GetString("mode")), + Mode: viper.GetString("mode"), + Addr: viper.GetString("addr"), + Port: viper.GetInt("port"), + UNIXSock: viper.GetString("unix-sock"), + Data: viper.GetString("data"), + Driver: viper.GetString("driver"), + DSN: viper.GetString("dsn"), + SQLiteEncryptionKey: viper.GetString("sqlite-encryption-key"), + InstanceURL: viper.GetString("instance-url"), + Version: version.GetCurrentVersion(viper.GetString("mode")), } if err := instanceProfile.Validate(); err != nil { panic(err) @@ -100,6 +101,7 @@ func init() { rootCmd.PersistentFlags().String("data", "", "data directory") rootCmd.PersistentFlags().String("driver", "sqlite", "database driver") rootCmd.PersistentFlags().String("dsn", "", "database source name(aka. DSN)") + rootCmd.PersistentFlags().String("sqlite-encryption-key", "", "SQLCipher key used to unlock the SQLite database (requires binary built with memos_sqlcipher)") rootCmd.PersistentFlags().String("instance-url", "", "the url of your memos instance") if err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")); err != nil { @@ -123,6 +125,9 @@ func init() { if err := viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn")); err != nil { panic(err) } + if err := viper.BindPFlag("sqlite-encryption-key", rootCmd.PersistentFlags().Lookup("sqlite-encryption-key")); err != nil { + panic(err) + } if err := viper.BindPFlag("instance-url", rootCmd.PersistentFlags().Lookup("instance-url")); err != nil { panic(err) } @@ -132,6 +137,9 @@ func init() { if err := viper.BindEnv("instance-url", "MEMOS_INSTANCE_URL"); err != nil { panic(err) } + if err := viper.BindEnv("sqlite-encryption-key", "MEMOS_SQLITE_ENCRYPTION_KEY"); err != nil { + panic(err) + } } func printGreetings(profile *profile.Profile) { diff --git a/go.mod b/go.mod index d0d058c9a..94bd0475b 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/labstack/echo/v4 v4.13.4 github.com/lib/pq v1.10.9 github.com/lithammer/shortuuid/v4 v4.2.0 + github.com/mattn/go-sqlite3 v1.14.32 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.20.1 diff --git a/go.sum b/go.sum index ccd23bb15..db8973931 100644 --- a/go.sum +++ b/go.sum @@ -285,6 +285,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 8d551d669..72727eb67 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -28,6 +28,8 @@ type Profile struct { // Driver is the database driver // sqlite, mysql Driver string + // SQLiteEncryptionKey unlocks SQLCipher-protected SQLite databases when provided. + SQLiteEncryptionKey string // Version is the current version of server Version string // InstanceURL is the url of your memos instance. @@ -88,5 +90,9 @@ func (p *Profile) Validate() error { p.DSN = filepath.Join(dataDir, dbFile) } + if p.SQLiteEncryptionKey != "" && p.Driver != "sqlite" { + return errors.New("sqlite encryption key is only supported when using the sqlite driver") + } + return nil } diff --git a/scripts/Dockerfile b/scripts/Dockerfile index 38456b37e..469970016 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -1,4 +1,12 @@ FROM golang:1.25-alpine AS backend +ARG MEMOS_BUILD_TAGS="" +ARG CGO_ENABLED=0 +ARG CGO_CFLAGS="" +ARG CGO_LDFLAGS="" +ENV CGO_ENABLED=${CGO_ENABLED} +ENV MEMOS_BUILD_TAGS=${MEMOS_BUILD_TAGS} +ENV CGO_CFLAGS=${CGO_CFLAGS} +ENV CGO_LDFLAGS=${CGO_LDFLAGS} WORKDIR /backend-build COPY go.mod go.sum ./ RUN go mod download @@ -7,13 +15,50 @@ COPY . . # Refer to `pnpm release` in package.json for the build command. RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -o memos ./bin/memos/main.go + /bin/sh -eux <<'EOF' +if [ "${CGO_ENABLED}" = "1" ]; then + apk add --no-cache --virtual .build-deps build-base pkgconf + if printf "%s" "${MEMOS_BUILD_TAGS}" | grep -q "memos_sqlcipher"; then + apk add --no-cache --virtual .sqlcipher-build sqlcipher-dev + SQLCIPHER_CFLAGS="$(pkg-config --cflags sqlcipher)" + SQLCIPHER_LDFLAGS="$(pkg-config --libs sqlcipher)" + if [ ! -e /usr/lib/libsqlite3.so ]; then + ln -s /usr/lib/libsqlcipher.so /usr/lib/libsqlite3.so + fi + if [ -z "${CGO_CFLAGS}" ]; then + export CGO_CFLAGS="${SQLCIPHER_CFLAGS} -DSQLITE_HAS_CODEC" + else + export CGO_CFLAGS="${CGO_CFLAGS} ${SQLCIPHER_CFLAGS} -DSQLITE_HAS_CODEC" + fi + if [ -z "${CGO_LDFLAGS}" ]; then + export CGO_LDFLAGS="${SQLCIPHER_LDFLAGS}" + else + export CGO_LDFLAGS="${CGO_LDFLAGS} ${SQLCIPHER_LDFLAGS}" + fi + fi +fi + +go build -ldflags="-s -w" -tags="${MEMOS_BUILD_TAGS}" -o memos ./bin/memos/main.go + +if [ "${CGO_ENABLED}" = "1" ]; then + if apk info -e .sqlcipher-build >/dev/null 2>&1; then + apk del .sqlcipher-build + fi + apk del .build-deps +fi +EOF # Make workspace with above generated files. FROM alpine:latest AS monolithic +ARG MEMOS_BUILD_TAGS="" WORKDIR /usr/local/memos RUN apk add --no-cache tzdata +RUN if printf "%s" "$MEMOS_BUILD_TAGS" | grep -q "memos_sqlcipher"; then \ + apk add --no-cache sqlcipher sqlcipher-libs && \ + if [ -e /usr/lib/libsqlcipher.so ]; then ln -sf /usr/lib/libsqlcipher.so /usr/lib/libsqlite3.so; fi && \ + if [ -e /usr/lib/libsqlcipher.so.0 ]; then ln -sf /usr/lib/libsqlcipher.so.0 /usr/lib/libsqlcipher.so; fi; \ + fi ENV TZ="UTC" COPY --from=backend /backend-build/memos /usr/local/memos/ diff --git a/store/db/sqlite/sqlcipher_disabled.go b/store/db/sqlite/sqlcipher_disabled.go new file mode 100644 index 000000000..ca0a376d1 --- /dev/null +++ b/store/db/sqlite/sqlcipher_disabled.go @@ -0,0 +1,32 @@ +//go:build !memos_sqlcipher + +package sqlite + +import ( + "database/sql" + + "github.com/pkg/errors" + + "github.com/usememos/memos/internal/profile" + + // Import the pure-Go SQLite driver. + _ "modernc.org/sqlite" +) + +func openSQLiteDB(profile *profile.Profile) (*sql.DB, error) { + if profile.SQLiteEncryptionKey != "" { + return nil, errors.New("sqlite encryption key provided but binary is not built with SQLCipher support; rebuild with -tags memos_sqlcipher") + } + + sqliteDB, err := sql.Open(sqliteModernDriver, profile.DSN) + if err != nil { + return nil, errors.Wrapf(err, "failed to open db with dsn: %s", profile.DSN) + } + + if err := configureSQLiteConnection(sqliteDB); err != nil { + sqliteDB.Close() + return nil, err + } + + return sqliteDB, nil +} diff --git a/store/db/sqlite/sqlcipher_enabled.go b/store/db/sqlite/sqlcipher_enabled.go new file mode 100644 index 000000000..d6b65f9e9 --- /dev/null +++ b/store/db/sqlite/sqlcipher_enabled.go @@ -0,0 +1,49 @@ +//go:build memos_sqlcipher + +package sqlite + +import ( + "database/sql" + "fmt" + "strings" + + "github.com/pkg/errors" + + "github.com/usememos/memos/internal/profile" + + // Import the CGO-backed SQLCipher-compatible SQLite driver. + _ "github.com/mattn/go-sqlite3" +) + +func openSQLiteDB(profile *profile.Profile) (*sql.DB, error) { + sqliteDB, err := sql.Open(sqliteCipherDriver, profile.DSN) + if err != nil { + return nil, errors.Wrapf(err, "failed to open db with dsn: %s", profile.DSN) + } + + if err := applySQLiteEncryptionKey(sqliteDB, profile.SQLiteEncryptionKey); err != nil { + sqliteDB.Close() + return nil, err + } + + if err := configureSQLiteConnection(sqliteDB); err != nil { + sqliteDB.Close() + return nil, err + } + + return sqliteDB, nil +} + +func applySQLiteEncryptionKey(db *sql.DB, key string) error { + if key == "" { + return nil + } + + escapedKey := strings.ReplaceAll(key, "'", "''") + pragma := fmt.Sprintf("PRAGMA key = '%s'", escapedKey) + if _, err := db.Exec(pragma); err != nil { + return errors.Wrap(err, "failed to apply sqlite encryption key; verify the binary is linked against SQLCipher") + } + + return nil +} diff --git a/store/db/sqlite/sqlite.go b/store/db/sqlite/sqlite.go index 3b4a30f8d..31737fee4 100644 --- a/store/db/sqlite/sqlite.go +++ b/store/db/sqlite/sqlite.go @@ -3,12 +3,10 @@ package sqlite import ( "context" "database/sql" + "fmt" "github.com/pkg/errors" - // Import the SQLite driver. - _ "modernc.org/sqlite" - "github.com/usememos/memos/internal/profile" "github.com/usememos/memos/store" ) @@ -21,29 +19,21 @@ type DB struct { // NewDB opens a database specified by its database driver name and a // driver-specific data source name, usually consisting of at least a // database name and connection information. +const ( + sqliteBusyTimeout = 10000 + sqliteModernDriver = "sqlite" + sqliteCipherDriver = "sqlite3" +) + func NewDB(profile *profile.Profile) (store.Driver, error) { // Ensure a DSN is set before attempting to open the database. if profile.DSN == "" { return nil, errors.New("dsn required") } - // Connect to the database with some sane settings: - // - No shared-cache: it's obsolete; WAL journal mode is a better solution. - // - No foreign key constraints: it's currently disabled by default, but it's a - // good practice to be explicit and prevent future surprises on SQLite upgrades. - // - Journal mode set to WAL: it's the recommended journal mode for most applications - // as it prevents locking issues. - // - // Notes: - // - When using the `modernc.org/sqlite` driver, each pragma must be prefixed with `_pragma=`. - // - // References: - // - https://pkg.go.dev/modernc.org/sqlite#Driver.Open - // - https://www.sqlite.org/sharedcache.html - // - https://www.sqlite.org/pragma.html - sqliteDB, err := sql.Open("sqlite", profile.DSN+"?_pragma=foreign_keys(0)&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)") + sqliteDB, err := openSQLiteDB(profile) if err != nil { - return nil, errors.Wrapf(err, "failed to open db with dsn: %s", profile.DSN) + return nil, err } driver := DB{db: sqliteDB, profile: profile} @@ -51,6 +41,20 @@ func NewDB(profile *profile.Profile) (store.Driver, error) { return &driver, nil } +func configureSQLiteConnection(db *sql.DB) error { + pragmas := []string{ + "PRAGMA foreign_keys = OFF", + fmt.Sprintf("PRAGMA busy_timeout = %d", sqliteBusyTimeout), + "PRAGMA journal_mode = WAL", + } + for _, pragma := range pragmas { + if _, err := db.Exec(pragma); err != nil { + return errors.Wrapf(err, "failed to execute %s", pragma) + } + } + return nil +} + func (d *DB) GetDB() *sql.DB { return d.db }