Yi-Ting Chiu 2 days ago committed by GitHub
commit 6119ea75ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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! **Pro Tip**: The data directory stores all your notes, uploads, and settings. Include it in your backup strategy!
### 🔒 Database Encryption (for SQLite)
<details>
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`.
</details>
## Sponsors ## Sponsors
Memos is made possible by the generous support of our sponsors. Their contributions help ensure the project's continued development, maintenance, and growth. Memos is made possible by the generous support of our sponsors. Their contributions help ensure the project's continued development, maintenance, and growth.

@ -25,15 +25,16 @@ var (
Short: `An open source, lightweight note-taking service. Easily capture and share your great thoughts.`, Short: `An open source, lightweight note-taking service. Easily capture and share your great thoughts.`,
Run: func(_ *cobra.Command, _ []string) { Run: func(_ *cobra.Command, _ []string) {
instanceProfile := &profile.Profile{ instanceProfile := &profile.Profile{
Mode: viper.GetString("mode"), Mode: viper.GetString("mode"),
Addr: viper.GetString("addr"), Addr: viper.GetString("addr"),
Port: viper.GetInt("port"), Port: viper.GetInt("port"),
UNIXSock: viper.GetString("unix-sock"), UNIXSock: viper.GetString("unix-sock"),
Data: viper.GetString("data"), Data: viper.GetString("data"),
Driver: viper.GetString("driver"), Driver: viper.GetString("driver"),
DSN: viper.GetString("dsn"), DSN: viper.GetString("dsn"),
InstanceURL: viper.GetString("instance-url"), SQLiteEncryptionKey: viper.GetString("sqlite-encryption-key"),
Version: version.GetCurrentVersion(viper.GetString("mode")), InstanceURL: viper.GetString("instance-url"),
Version: version.GetCurrentVersion(viper.GetString("mode")),
} }
if err := instanceProfile.Validate(); err != nil { if err := instanceProfile.Validate(); err != nil {
panic(err) panic(err)
@ -100,6 +101,7 @@ func init() {
rootCmd.PersistentFlags().String("data", "", "data directory") rootCmd.PersistentFlags().String("data", "", "data directory")
rootCmd.PersistentFlags().String("driver", "sqlite", "database driver") rootCmd.PersistentFlags().String("driver", "sqlite", "database driver")
rootCmd.PersistentFlags().String("dsn", "", "database source name(aka. DSN)") 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") rootCmd.PersistentFlags().String("instance-url", "", "the url of your memos instance")
if err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")); err != nil { 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 { if err := viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn")); err != nil {
panic(err) 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 { if err := viper.BindPFlag("instance-url", rootCmd.PersistentFlags().Lookup("instance-url")); err != nil {
panic(err) panic(err)
} }
@ -132,6 +137,9 @@ func init() {
if err := viper.BindEnv("instance-url", "MEMOS_INSTANCE_URL"); err != nil { if err := viper.BindEnv("instance-url", "MEMOS_INSTANCE_URL"); err != nil {
panic(err) panic(err)
} }
if err := viper.BindEnv("sqlite-encryption-key", "MEMOS_SQLITE_ENCRYPTION_KEY"); err != nil {
panic(err)
}
} }
func printGreetings(profile *profile.Profile) { func printGreetings(profile *profile.Profile) {

@ -19,6 +19,7 @@ require (
github.com/labstack/echo/v4 v4.13.4 github.com/labstack/echo/v4 v4.13.4
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/lithammer/shortuuid/v4 v4.2.0 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/pkg/errors v0.9.1
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1

@ -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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-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/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/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=

@ -28,6 +28,8 @@ type Profile struct {
// Driver is the database driver // Driver is the database driver
// sqlite, mysql // sqlite, mysql
Driver string Driver string
// SQLiteEncryptionKey unlocks SQLCipher-protected SQLite databases when provided.
SQLiteEncryptionKey string
// Version is the current version of server // Version is the current version of server
Version string Version string
// InstanceURL is the url of your memos instance. // InstanceURL is the url of your memos instance.
@ -88,5 +90,9 @@ func (p *Profile) Validate() error {
p.DSN = filepath.Join(dataDir, dbFile) 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 return nil
} }

@ -1,4 +1,12 @@
FROM golang:1.25-alpine AS backend 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 WORKDIR /backend-build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@ -7,13 +15,50 @@ COPY . .
# Refer to `pnpm release` in package.json for the build command. # Refer to `pnpm release` in package.json for the build command.
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \ --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. # Make workspace with above generated files.
FROM alpine:latest AS monolithic FROM alpine:latest AS monolithic
ARG MEMOS_BUILD_TAGS=""
WORKDIR /usr/local/memos WORKDIR /usr/local/memos
RUN apk add --no-cache tzdata 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" ENV TZ="UTC"
COPY --from=backend /backend-build/memos /usr/local/memos/ COPY --from=backend /backend-build/memos /usr/local/memos/

@ -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
}

@ -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
}

@ -3,12 +3,10 @@ package sqlite
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
// Import the SQLite driver.
_ "modernc.org/sqlite"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
@ -21,29 +19,21 @@ type DB struct {
// NewDB opens a database specified by its database driver name and a // NewDB opens a database specified by its database driver name and a
// driver-specific data source name, usually consisting of at least a // driver-specific data source name, usually consisting of at least a
// database name and connection information. // database name and connection information.
const (
sqliteBusyTimeout = 10000
sqliteModernDriver = "sqlite"
sqliteCipherDriver = "sqlite3"
)
func NewDB(profile *profile.Profile) (store.Driver, error) { func NewDB(profile *profile.Profile) (store.Driver, error) {
// Ensure a DSN is set before attempting to open the database. // Ensure a DSN is set before attempting to open the database.
if profile.DSN == "" { if profile.DSN == "" {
return nil, errors.New("dsn required") return nil, errors.New("dsn required")
} }
// Connect to the database with some sane settings: sqliteDB, err := openSQLiteDB(profile)
// - 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)")
if err != nil { 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} driver := DB{db: sqliteDB, profile: profile}
@ -51,6 +41,20 @@ func NewDB(profile *profile.Profile) (store.Driver, error) {
return &driver, nil 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 { func (d *DB) GetDB() *sql.DB {
return d.db return d.db
} }

Loading…
Cancel
Save