Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ For more detail, check out the [Potok Docs](https://potok-docs.vercel.app/)
- **Multiple Vaults** — Manage and sync multiple vaults independently.
- **Automatic Sync** — Watches your vault folder for changes and pushes them automatically.
- **Cross-Platform** — Supports Windows and Linux. macOS is untested but might work?
- **Secure Key Storage** — Encryption passwords and API keys are stored in your OS keyring (Windows Credential Manager, macOS Keychain, Linux Secret Service).
- **Secure Key Storage** — The CLI keeps encryption passwords and API keys in your OS keyring (Windows Credential Manager, macOS Keychain, Linux Secret Service). The server stores only a SHA-256 hash of each API key, never the live secret.
- **Free & Open Source** — No file size limits, no file count limits, no paywalls.

## Commands
Expand Down Expand Up @@ -120,7 +120,7 @@ Long-running process that watches for local changes and pushes them automaticall

### Sensitive data

Passwords and API keys are stored in your OS keyring under the `potok` service — never in config files.
The CLI stores passwords and API keys in your OS keyring under the `potok` service — never in config files. If an older `config.json` still has an `api_key` field, the next `potok` command moves it into the keyring and rewrites the file.

| OS | Keyring backend |
|---|---|
Expand All @@ -136,9 +136,10 @@ Passwords and API keys are stored in your OS keyring under the `potok` service
## Security

- All encryption and decryption happens locally on your device.
- The server only stores encrypted blobs — it never sees your passwords or plaintext.
- Passwords and API keys are stored in your OS keyring, not in config files.
- Encryption uses AES via `golang.org/x/crypto`.
- The server only stores encrypted blobs — it never sees your passwords or note plaintext.
- Client secrets (the live API key and vault passphrases) live in the OS keyring, not in `config.json`.
- Server-side API keys are stored as `sha256:` digests. Keys are 256-bit values from `crypto/rand`, so a fast cryptographic hash is enough to protect them at rest (the same approach used for GitHub personal access tokens). User passwords stay on bcrypt. The live key is shown once at registration and cannot be recovered from the database.
- Encryption uses AES-GCM via `golang.org/x/crypto`; passphrases are stretched with Argon2id.

## Roadmap

Expand Down
32 changes: 32 additions & 0 deletions internal/client/cli/commands/legacy_apikey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package commands

import (
"errors"
"fmt"

"github.com/mtiluk/potok/internal/client/config"
"github.com/mtiluk/potok/internal/client/secrets"
)

// MigrateLegacyAPIKey moves a leftover config.json api_key into the OS
// keyring and rewrites the file without the secret. If the keyring already
// has an API key, the file value is discarded so the keyring stays canonical.
func MigrateLegacyAPIKey() error {
key, present, err := config.PeekLegacyAPIKey()
if err != nil || !present {
return err
}

if key != "" {
_, getErr := secrets.Get(secrets.APIKey)
if errors.Is(getErr, secrets.ErrNotFound) {
if err := secrets.Set(secrets.APIKey, key); err != nil {
return fmt.Errorf("failed to move API key into the OS keyring: %w", err)
}
} else if getErr != nil {
return getErr
}
}

return config.StripLegacyAPIKey()
}
113 changes: 113 additions & 0 deletions internal/client/cli/commands/legacy_apikey_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package commands

import (
"encoding/json"
"errors"
"os"
"testing"

"github.com/mtiluk/potok/internal/client/config"
"github.com/mtiluk/potok/internal/client/secrets"
"github.com/zalando/go-keyring"
)

func TestMigrateLegacyAPIKeyMovesPlaintextIntoKeyring(t *testing.T) {
t.Setenv("POTOK_CONFIG_DIR", t.TempDir())
keyring.MockInit()

path, err := config.Path()
if err != nil {
t.Fatalf("Path() = %v", err)
}
if err := os.WriteFile(path, []byte(`{
"server_url": "https://potok.example.com",
"api_key": "potok_legacy",
"vaults": []
}
`), 0o600); err != nil {
t.Fatalf("WriteFile() = %v", err)
}

if err := MigrateLegacyAPIKey(); err != nil {
t.Fatalf("MigrateLegacyAPIKey() = %v", err)
}

got, err := secrets.Get(secrets.APIKey)
if err != nil {
t.Fatalf("secrets.Get() = %v", err)
}
if got != "potok_legacy" {
t.Errorf("keyring API key = %q, want potok_legacy", got)
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() = %v", err)
}
if containsAPIKey(t, data) {
t.Errorf("config.json still has api_key:\n%s", data)
}
}

func TestMigrateLegacyAPIKeyDoesNotOverwriteExistingKeyring(t *testing.T) {
t.Setenv("POTOK_CONFIG_DIR", t.TempDir())
keyring.MockInit()

if err := secrets.Set(secrets.APIKey, "already-in-keyring"); err != nil {
t.Fatalf("secrets.Set() = %v", err)
}

path, err := config.Path()
if err != nil {
t.Fatalf("Path() = %v", err)
}
if err := os.WriteFile(path, []byte(`{
"server_url": "https://potok.example.com",
"api_key": "should-not-win"
}
`), 0o600); err != nil {
t.Fatalf("WriteFile() = %v", err)
}

if err := MigrateLegacyAPIKey(); err != nil {
t.Fatalf("MigrateLegacyAPIKey() = %v", err)
}

got, err := secrets.Get(secrets.APIKey)
if err != nil {
t.Fatalf("secrets.Get() = %v", err)
}
if got != "already-in-keyring" {
t.Errorf("keyring API key = %q, want the existing value", got)
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() = %v", err)
}
if containsAPIKey(t, data) {
t.Errorf("config.json still has api_key:\n%s", data)
}
}

func TestMigrateLegacyAPIKeyNoopsWhenMissing(t *testing.T) {
t.Setenv("POTOK_CONFIG_DIR", t.TempDir())
keyring.MockInit()

if err := MigrateLegacyAPIKey(); err != nil {
t.Fatalf("MigrateLegacyAPIKey() on missing config = %v", err)
}
if _, err := secrets.Get(secrets.APIKey); !errors.Is(err, secrets.ErrNotFound) {
t.Errorf("secrets.Get() = %v, want ErrNotFound", err)
}
}

func containsAPIKey(t *testing.T, data []byte) bool {
t.Helper()
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal rewritten config: %v", err)
}
_, ok := raw["api_key"]
return ok
}
4 changes: 4 additions & 0 deletions internal/client/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ func newRootCmd() *cobra.Command {
SilenceUsage: true,
SilenceErrors: true,

PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return commands.MigrateLegacyAPIKey()
},

RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
Expand Down
44 changes: 44 additions & 0 deletions internal/client/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,50 @@ func Load() (*Config, error) {
return &cfg, nil
}

// PeekLegacyAPIKey returns a leftover plaintext api_key from config.json.
// Older clients may have written the secret into the file; current clients
// keep it in the OS keyring instead. Missing files are not an error.
func PeekLegacyAPIKey() (key string, present bool, err error) {
path, err := Path()
if err != nil {
return "", false, err
}

data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("config: read %s: %w", path, err)
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return "", false, fmt.Errorf("config: parse %s: %w", path, err)
}
value, ok := raw["api_key"]
if !ok {
return "", false, nil
}
if err := json.Unmarshal(value, &key); err != nil {
return "", true, fmt.Errorf("config: api_key in %s must be a string", path)
}
return strings.TrimSpace(key), true, nil
}

// StripLegacyAPIKey rewrites config.json without an api_key field.
func StripLegacyAPIKey() error {
_, present, err := PeekLegacyAPIKey()
if err != nil || !present {
return err
}
cfg, err := Load()
if err != nil {
return err
}
return Save(cfg)
}

func Save(cfg *Config) error {
if err := cfg.Validate(); err != nil {
return err
Expand Down
54 changes: 54 additions & 0 deletions internal/client/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

Expand Down Expand Up @@ -193,6 +194,59 @@ func TestVaultReturnsPointer(t *testing.T) {
}
}

func TestPeekAndStripLegacyAPIKey(t *testing.T) {
tempConfig(t)
path, err := Path()
if err != nil {
t.Fatalf("Path() = %v", err)
}

key, present, err := PeekLegacyAPIKey()
if err != nil || present || key != "" {
t.Fatalf("PeekLegacyAPIKey() on missing file = (%q, %v, %v)", key, present, err)
}

if err := os.WriteFile(path, []byte(`{
"server_url": "https://potok.example.com",
"api_key": "potok_plain_from_old_client",
"vaults": [{"name":"notes","path":"/home/user/notes"}]
}
`), 0o600); err != nil {
t.Fatalf("WriteFile() = %v", err)
}

key, present, err = PeekLegacyAPIKey()
if err != nil {
t.Fatalf("PeekLegacyAPIKey() = %v", err)
}
if !present || key != "potok_plain_from_old_client" {
t.Fatalf("PeekLegacyAPIKey() = (%q, %v), want the leftover secret", key, present)
}

if err := StripLegacyAPIKey(); err != nil {
t.Fatalf("StripLegacyAPIKey() = %v", err)
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() = %v", err)
}
if strings.Contains(string(data), "api_key") || strings.Contains(string(data), "potok_plain_from_old_client") {
t.Errorf("config.json still contains the leftover API key:\n%s", data)
}

got, err := Load()
if err != nil {
t.Fatalf("Load() after strip = %v", err)
}
if got.ServerURL != "https://potok.example.com" {
t.Errorf("ServerURL = %q after strip", got.ServerURL)
}
if len(got.Vaults) != 1 || got.Vaults[0].Name != "notes" {
t.Errorf("Vaults = %+v, want notes preserved", got.Vaults)
}
}

func TestDirPrecedence(t *testing.T) {
t.Setenv("POTOK_CONFIG_DIR", "/custom/potok")
t.Setenv("XDG_CONFIG_HOME", "/xdg")
Expand Down
28 changes: 27 additions & 1 deletion internal/server/auth/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ package auth

import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
)

var Prefix = "potok_"

const keyBytes = 32
// APIKeyHashPrefix marks a SHA-256 digest stored in users.api_key_hash.
// Generated keys are 256-bit CSPRNG values, so a fast cryptographic hash is
// enough to protect them at rest (same approach GitHub uses for PATs).
// User passwords stay on bcrypt.
const APIKeyHashPrefix = "sha256:"

const (
keyBytes = 32
sha256HexLen = sha256.Size * 2
)

func GenerateAPIKey() (key string, err error) {
raw := make([]byte, keyBytes)
Expand All @@ -18,3 +30,17 @@ func GenerateAPIKey() (key string, err error) {
key = Prefix + base64.RawURLEncoding.EncodeToString(raw)
return key, nil
}

func HashAPIKey(key string) string {
sum := sha256.Sum256([]byte(key))
return APIKeyHashPrefix + hex.EncodeToString(sum[:])
}

func IsHashedAPIKey(stored string) bool {
digest, ok := strings.CutPrefix(stored, APIKeyHashPrefix)
if !ok || len(digest) != sha256HexLen {
return false
}
_, err := hex.DecodeString(digest)
return err == nil
}
Loading