Skip to content

Commit d356210

Browse files
authored
Add operations supporting encrypted databases on Cloud (#1000)
This patch adds operations for encrypted databases: - create (fresh db, upload) - fork - shell
2 parents 212b53a + e35ce5e commit d356210

10 files changed

Lines changed: 160 additions & 56 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ require (
1515
github.com/google/uuid v1.3.0
1616
github.com/hashicorp/go-version v1.6.0
1717
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
18-
github.com/libsql/libsql-shell-go v0.10.6
18+
github.com/libsql/libsql-shell-go v0.10.7-0.20251205123613-0342ea2584be
1919
github.com/manifoldco/promptui v0.9.0
2020
github.com/mitchellh/mapstructure v1.5.0
2121
github.com/olekukonko/tablewriter v0.0.5
@@ -58,7 +58,7 @@ require (
5858
github.com/spf13/jwalterweatherman v1.1.0 // indirect
5959
github.com/spf13/pflag v1.0.5 // indirect
6060
github.com/subosito/gotenv v1.4.2 // indirect
61-
github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect
61+
github.com/tursodatabase/libsql-client-go v0.0.0-20251205113610-b69dd6e475fc // indirect
6262
golang.org/x/crypto v0.14.0 // indirect
6363
golang.org/x/net v0.17.0 // indirect
6464
golang.org/x/sys v0.13.0 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
203203
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
204204
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
205205
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
206-
github.com/libsql/libsql-shell-go v0.10.6 h1:Ck/OAqdYnp4vAsBFyaS73lV8Kg6+mp60sv4XhQpLF/Y=
207-
github.com/libsql/libsql-shell-go v0.10.6/go.mod h1:CzfyMwVmQhHxs85xglrNgkdBasrcd8XN5QTpg4A6JFQ=
206+
github.com/libsql/libsql-shell-go v0.10.7-0.20251205123613-0342ea2584be h1:01jRtOlo3emaVJIgPvyvTSanxt8Q9oe7u+DLQBGpVtY=
207+
github.com/libsql/libsql-shell-go v0.10.7-0.20251205123613-0342ea2584be/go.mod h1:hO7V4+aqYxjLP742up3TN8hcS88xlbdCiLHWjtWAtE8=
208208
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
209209
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
210210
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
@@ -294,8 +294,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
294294
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
295295
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
296296
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
297-
github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU=
298-
github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s=
297+
github.com/tursodatabase/libsql-client-go v0.0.0-20251205113610-b69dd6e475fc h1:uhpFwk9G+wp9JpPnaABzwyIUz1P4EYIkEKKivyJVO14=
298+
github.com/tursodatabase/libsql-client-go v0.0.0-20251205113610-b69dd6e475fc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
299299
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
300300
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
301301
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

internal/cmd/db_create.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"encoding/base64"
45
"fmt"
56
"strings"
67
"time"
@@ -37,6 +38,8 @@ func init() {
3738
addSchemaFlag(createCmd)
3839
addTypeFlag(createCmd)
3940
addSizeLimitFlag(createCmd)
41+
addRemoteEncryptionCipherFlag(createCmd)
42+
addRemoteEncryptionKeyFlag(createCmd)
4043
}
4144

4245
var createCmd = &cobra.Command{
@@ -93,6 +96,10 @@ func CreateDatabase(name string) error {
9396
version = "canary"
9497
}
9598

99+
if err = validateEncryptionFlags(); err != nil {
100+
return err
101+
}
102+
96103
if err := ensureGroup(client, groupName, groups, location, version); err != nil {
97104
return err
98105
}
@@ -101,7 +108,7 @@ func CreateDatabase(name string) error {
101108
spinner := prompt.Spinner(fmt.Sprintf("Creating database %s in group %s...", internal.Emph(name), internal.Emph(groupName)))
102109
defer spinner.Stop()
103110

104-
if _, err = client.Databases.Create(name, location, "", "", groupName, schemaFlag, typeFlag == "schema", seed, sizeLimitFlag, spinner); err != nil {
111+
if _, err = client.Databases.Create(name, location, "", "", groupName, schemaFlag, typeFlag == "schema", seed, sizeLimitFlag, remoteEncryptionCipherFlag, remoteEncryptionKeyFlag(), spinner); err != nil {
105112
return fmt.Errorf("could not create database %s: %w", name, err)
106113
}
107114

@@ -209,3 +216,30 @@ func shouldAutoCreateGroup(name string, groups []turso.Group) bool {
209216
// we only create the default group automatically
210217
return name == "default" && len(groups) == 0
211218
}
219+
220+
func validateEncryptionFlags() error {
221+
remoteEncryptionKey := remoteEncryptionKeyFlag()
222+
if remoteEncryptionKey == "" && remoteEncryptionCipherFlag == "" {
223+
return nil
224+
}
225+
// if key flag is empty, then user passed only the cipher, which is invalid
226+
if remoteEncryptionKey == "" {
227+
return fmt.Errorf("remote encryption key must be provided when remote encryption cipher is set")
228+
}
229+
230+
// if key is provided, lets verify its in base64 encoded
231+
_, err := base64.StdEncoding.DecodeString(remoteEncryptionKey)
232+
if err != nil {
233+
return fmt.Errorf("encryption key (%s) is not valid base64: %w", remoteEncryptionKey, err)
234+
}
235+
236+
if remoteEncryptionCipherFlag != "" {
237+
return nil
238+
}
239+
240+
// if cipher is empty, then it is only valid in case of forks and for everything else we need to have it set
241+
if fromDBFlag == "" {
242+
return fmt.Errorf("remote encryption cipher must be provided when remote encryption key is set")
243+
}
244+
return nil
245+
}

internal/cmd/db_export.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func ExportDatabase(dbName, outputFile string, withMetadata bool, overwrite bool
5050
return fmt.Errorf("failed to find database: %w", err)
5151
}
5252
dbUrl := getDatabaseHttpUrl(&db)
53-
err = client.Databases.Export(dbName, dbUrl, outputFile, withMetadata, overwrite)
53+
err = client.Databases.Export(dbName, dbUrl, outputFile, withMetadata, overwrite, remoteEncryptionKeyFlag())
5454
if err != nil {
5555
return err
5656
}
@@ -61,5 +61,6 @@ func init() {
6161
exportCmd.Flags().BoolVar(&withMetadata, "with-metadata", false, "Include metadata in the export.")
6262
exportCmd.Flags().BoolVar(&overwriteExport, "overwrite", false, "Overwrite output file if it exists.")
6363
exportCmd.Flags().StringVar(&outputFile, "output-file", "", "Specify the output file name (default: <database>.db)")
64+
addRemoteEncryptionKeyFlag(exportCmd)
6465
dbCmd.AddCommand(exportCmd)
6566
}

internal/cmd/db_shell.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func init() {
2727
dbCmd.AddCommand(shellCmd)
2828
addInstanceFlag(shellCmd, "Connect to the database at the specified instance.")
2929
addLocationFlag(shellCmd, "Connect to the database at the specified location.")
30+
addRemoteEncryptionKeyFlag(shellCmd)
3031
shellCmd.Flags().StringVar(&proxy, "proxy", "", "Proxy to use for the connection.")
3132
shellCmd.RegisterFlagCompletionFunc("proxy", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
3233
return []string{}, cobra.ShellCompDirectiveNoFileComp
@@ -227,15 +228,16 @@ var shellCmd = &cobra.Command{
227228
}
228229

229230
shellConfig := shell.ShellConfig{
230-
DbUri: dbUrl,
231-
Proxy: proxy,
232-
AuthToken: authToken,
233-
InF: cmd.InOrStdin(),
234-
OutF: cmd.OutOrStdout(),
235-
ErrF: cmd.ErrOrStderr(),
236-
HistoryMode: enums.PerDatabaseHistory,
237-
HistoryName: "turso",
238-
WelcomeMessage: &connectionInfo,
231+
DbUri: dbUrl,
232+
Proxy: proxy,
233+
AuthToken: authToken,
234+
RemoteEncryptionKey: remoteEncryptionKeyFlag(),
235+
InF: cmd.InOrStdin(),
236+
OutF: cmd.OutOrStdout(),
237+
ErrF: cmd.ErrOrStderr(),
238+
HistoryMode: enums.PerDatabaseHistory,
239+
HistoryName: "turso",
240+
WelcomeMessage: &connectionInfo,
239241
AfterDbConnectionCallback: func() {
240242
spinner.Stop()
241243
},

internal/cmd/db_show.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ var showCmd = &cobra.Command{
149149
if db.Schema != "" {
150150
fmt.Println("Schema: ", db.Schema)
151151
}
152+
if db.EncryptionCipher != "" {
153+
fmt.Println("Encryption: ", db.EncryptionCipher)
154+
}
152155

153156
fmt.Println()
154157

internal/cmd/encryption_flag.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
// unexported, don't use directly. Consider using remoteEncryptionKeyFlag()
10+
var remoteEncryptionKeyArg string
11+
var remoteEncryptionCipherFlag string
12+
13+
func addRemoteEncryptionKeyFlag(cmd *cobra.Command) {
14+
cmd.Flags().StringVar(&remoteEncryptionKeyArg, "remote-encryption-key", "", "Encryption key (in base64) for accessing encrypted databases on Turso cloud")
15+
}
16+
17+
func addRemoteEncryptionCipherFlag(cmd *cobra.Command) {
18+
cmd.Flags().StringVar(&remoteEncryptionCipherFlag, "remote-encryption-cipher", "", "Cipher to use for database encryption")
19+
}
20+
21+
func remoteEncryptionKeyFlag() string {
22+
if remoteEncryptionKeyArg != "" {
23+
return remoteEncryptionKeyArg
24+
}
25+
return os.Getenv("TURSO_DB_REMOTE_ENCRYPTION_KEY")
26+
}

internal/turso/databases.go

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@ import (
1616
)
1717

1818
type Database struct {
19-
ID string `json:"dbId" mapstructure:"dbId"`
20-
Name string
21-
Regions []string
22-
PrimaryRegion string
23-
Hostname string
24-
Version string
25-
Group string
26-
Sleeping bool
27-
Schema string
28-
IsSchema bool `json:"is_schema" mapstructure:"is_schema"`
29-
Parent *Database `json:"parent,omitempty"`
19+
ID string `json:"dbId" mapstructure:"dbId"`
20+
Name string
21+
Regions []string
22+
PrimaryRegion string
23+
Hostname string
24+
Version string
25+
Group string
26+
Sleeping bool
27+
Schema string
28+
IsSchema bool `json:"is_schema" mapstructure:"is_schema"`
29+
Parent *Database `json:"parent,omitempty"`
30+
EncryptionCipher string `json:"encryption_cipher,omitempty"`
3031
}
3132

3233
type DatabasesClient client
@@ -133,19 +134,25 @@ type DBSeed struct {
133134
Filepath string `json:"-"`
134135
}
135136

137+
type RemoteEncryption struct {
138+
EncryptionKey string `json:"encryption_key"`
139+
EncryptionCipher string `json:"encryption_cipher"`
140+
}
141+
136142
type CreateDatabaseBody struct {
137-
Name string `json:"name"`
138-
Location string `json:"location"`
139-
Image string `json:"image,omitempty"`
140-
Extensions string `json:"extensions,omitempty"`
141-
Group string `json:"group,omitempty"`
142-
Seed *DBSeed `json:"seed,omitempty"`
143-
Schema string `json:"schema,omitempty"`
144-
IsSchema bool `json:"is_schema,omitempty"`
145-
SizeLimit string `json:"size_limit,omitempty"`
146-
}
147-
148-
func (d *DatabasesClient) Create(name, location, image, extensions, group string, schema string, isSchema bool, seed *DBSeed, sizeLimit string, spinner *prompt.SpinnerT) (*CreateDatabaseResponse, error) {
143+
Name string `json:"name"`
144+
Location string `json:"location"`
145+
Image string `json:"image,omitempty"`
146+
Extensions string `json:"extensions,omitempty"`
147+
Group string `json:"group,omitempty"`
148+
Seed *DBSeed `json:"seed,omitempty"`
149+
Schema string `json:"schema,omitempty"`
150+
IsSchema bool `json:"is_schema,omitempty"`
151+
SizeLimit string `json:"size_limit,omitempty"`
152+
RemoteEncryption *RemoteEncryption `json:"remote_encryption,omitempty"`
153+
}
154+
155+
func (d *DatabasesClient) Create(name, location, image, extensions, group string, schema string, isSchema bool, seed *DBSeed, sizeLimit, remoteEncryptionCipher, remoteEncryptionKey string, spinner *prompt.SpinnerT) (*CreateDatabaseResponse, error) {
149156
isTursoServerUpload := seed != nil && seed.Type == "database_upload" && seed.Filepath != ""
150157
var uploadFilepath string
151158
var params CreateDatabaseBody
@@ -163,7 +170,14 @@ func (d *DatabasesClient) Create(name, location, image, extensions, group string
163170
Seed: seed,
164171
}
165172
} else {
166-
params = CreateDatabaseBody{name, location, image, extensions, group, seed, schema, isSchema, sizeLimit}
173+
params = CreateDatabaseBody{name, location, image, extensions, group, seed, schema, isSchema, sizeLimit, nil}
174+
}
175+
176+
if remoteEncryptionKey != "" {
177+
params.RemoteEncryption = &RemoteEncryption{
178+
EncryptionKey: remoteEncryptionKey,
179+
EncryptionCipher: remoteEncryptionCipher,
180+
}
167181
}
168182

169183
body, err := marshal(params)
@@ -196,7 +210,7 @@ func (d *DatabasesClient) Create(name, location, image, extensions, group string
196210
}
197211

198212
if isTursoServerUpload {
199-
if _, err = d.UploadDatabaseAWS(data, group, uploadFilepath, spinner); err != nil {
213+
if _, err = d.UploadDatabaseAWS(data, group, uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey, spinner); err != nil {
200214
// Clean up the database if the upload fails
201215
if deleteErr := d.Delete(data.Database.Name); deleteErr != nil {
202216
fmt.Printf("%v", deleteErr)
@@ -217,7 +231,7 @@ func (d *DatabasesClient) Create(name, location, image, extensions, group string
217231
// This call happens in DatabasesClient.Create() above, after which it calls this function.
218232
// 2. This function creates a DB token for the newly-created DB, and then calls turso-server to upload the database file.
219233
// turso-server will perform validations on the file and 'activate' the db if everything is ok.
220-
func (d *DatabasesClient) UploadDatabaseAWS(resp *CreateDatabaseResponse, group string, uploadFilepath string, spinner *prompt.SpinnerT) (*CreateDatabaseResponse, error) {
234+
func (d *DatabasesClient) UploadDatabaseAWS(resp *CreateDatabaseResponse, group, uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey string, spinner *prompt.SpinnerT) (*CreateDatabaseResponse, error) {
221235
// Create a short-lived DB token for the newly created database to facilitate the upload
222236
token, err := d.Token(resp.Database.Name, "1h", false, nil, nil)
223237
if err != nil {
@@ -235,7 +249,7 @@ func (d *DatabasesClient) UploadDatabaseAWS(resp *CreateDatabaseResponse, group
235249

236250
// Upload the database file
237251
spinner.Text(fmt.Sprintf("Uploading database %s in group %s, this may take a while...", internal.Emph(resp.Database.Name), internal.Emph(group)))
238-
err = tursoServerClient.UploadFile(uploadFilepath, func(progressPct int, uploadedBytes int64, totalBytes int64, elapsedTime time.Duration, done bool) {
252+
err = tursoServerClient.UploadFile(uploadFilepath, remoteEncryptionCipher, remoteEncryptionKey, func(progressPct int, uploadedBytes int64, totalBytes int64, elapsedTime time.Duration, done bool) {
239253
totalSeconds := int(elapsedTime.Seconds())
240254
minutes := totalSeconds / 60
241255
seconds := totalSeconds % 60
@@ -267,7 +281,7 @@ func (d *DatabasesClient) UploadDatabaseAWS(resp *CreateDatabaseResponse, group
267281
return resp, nil
268282
}
269283

270-
func (d *DatabasesClient) Export(dbName, dbUrl, outputFile string, withMetadata bool, overwrite bool) error {
284+
func (d *DatabasesClient) Export(dbName, dbUrl, outputFile string, withMetadata bool, overwrite bool, remoteEncryptionKey string) error {
271285
if !overwrite {
272286
if _, err := os.Stat(outputFile); err == nil {
273287
return fmt.Errorf("file %s already exists, use `--overwrite` flag to overwrite it", outputFile)
@@ -285,7 +299,7 @@ func (d *DatabasesClient) Export(dbName, dbUrl, outputFile string, withMetadata
285299
if err != nil {
286300
return fmt.Errorf("could not create Turso server client: %w", err)
287301
}
288-
return tursoServerClient.Export(outputFile, withMetadata)
302+
return tursoServerClient.Export(outputFile, withMetadata, remoteEncryptionKey)
289303
}
290304

291305
func (d *DatabasesClient) Seed(name string, dbFile *os.File) error {

internal/turso/turso.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ func (t *Client) Get(path string, body io.Reader) (*http.Response, error) {
148148
}
149149

150150
func (t *Client) GetWithHeaders(path string, body io.Reader, headers map[string]string) (*http.Response, error) {
151+
if headers == nil {
152+
headers = make(map[string]string)
153+
}
151154
headers["Content-Type"] = "application/json"
152155
return t.do("GET", path, body, headers)
153156
}
@@ -156,8 +159,12 @@ func (t *Client) Post(path string, body io.Reader) (*http.Response, error) {
156159
return t.do("POST", path, body, Header("Content-Type", "application/json"))
157160
}
158161

159-
func (t *Client) PostBinary(path string, body io.Reader) (*http.Response, error) {
160-
return t.do("POST", path, body, Header("Content-Type", "application/octet-stream"))
162+
func (t *Client) PostBinary(path string, body io.Reader, headers map[string]string) (*http.Response, error) {
163+
if headers == nil {
164+
headers = make(map[string]string)
165+
}
166+
headers["Content-Type"] = "application/octet-stream"
167+
return t.do("POST", path, body, headers)
161168
}
162169

163170
func (t *Client) Patch(path string, body io.Reader) (*http.Response, error) {

0 commit comments

Comments
 (0)