Documentation
¶
Overview ¶
Package vault provides recipients.txt management for multi-user encryption support. Recipients can be added to enable multiple parties to decrypt vault entries.
Package vault provides encrypted storage and search for OpenPass entries.
============================================================================= DESIGN DOCUMENT: Scalable Search for Large Vaults =============================================================================
Current Bottleneck: ------------------- Find() uses a two-pass approach. The first pass (path matching) is fast O(n). The second pass (field search) requires decrypting ALL entries where the path didn't match. This sequential decryption is the primary bottleneck for large vaults. For a vault with 50k entries where no paths match, we must sequentially decrypt 50k entries before returning results.
Why Concurrent Decryption Helps: -------------------------------- Modern CPUs have multiple cores. Sequential decryption leaves cores idle. By decrypting entries in parallel using a bounded worker pool, we can utilize multiple cores simultaneously, proportionally reducing wall-clock time.
Bounded Parallelism Rationale: ----------------------------- We use a bounded worker pool (default: 4 concurrent decryptions) rather than unbounded parallelism for these reasons:
Memory Pressure: Each decrypted entry consumes memory. With unbounded parallelism on a 50k entry vault, we could spawn 50k goroutines all trying to decrypt simultaneously, causing memory exhaustion.
I/O Saturation: The underlying storage (SSD/HDD) has finite read throughput. Beyond a certain concurrency level, additional workers simply compete for the same I/O bandwidth without improvement.
Cryptographic Operations: Age decryption involves CPU-intensive operations (X25519 key exchange, ChaCha20-Poly1305). Too many concurrent operations can cause CPU cache thrashing.
Practical Results: Testing shows 4 workers provides near-optimal throughput for typical user machines while keeping memory bounded.
Performance Targets (4 workers): -------------------------------- - 50k entries, path-only: ~500ms (no decryption needed) - 50k entries, field-search: ~2-4s (bounded by decrypt parallelism) - 10k entries, field-search: ~500ms-1s - 1k entries, field-search: ~100-200ms
Security Tradeoffs of Persistent Encrypted Index: ------------------------------------------------ An alternative approach would be to build a persistent index that maps search terms to encrypted entry references. This would enable O(1) or O(log n) searches without decryption.
Security Considerations:
- A persistent index MUST be encrypted at rest to prevent leakage of entry relationships and search patterns
- The index encryption key must be derived from the user's passphrase (like the vault identity key) ensuring only authorized users can access it
- Even with encryption, an index reveals search patterns over time:
- Which terms are searched frequently
- Which entries are accessed together
- Temporal patterns of access
- If the index is stored alongside the vault (e.g., in the vault directory), it could be stolen alongside encrypted entries
Attack Scenarios:
- A passive observer who steals the vault but not the passphrase: encrypted index provides no additional advantage over encrypted entries
- An active observer with passphrase but no vault: reveals search history but not content
- A side-channel attack on a compromised machine: index access patterns could reveal search behavior even with encrypted index
For these reasons, we currently prefer the on-demand decrypt approach which provides no persistent search metadata to steal.
Future encrypted index implementation would need to: 1. Use a key derived from the vault's master key 2. Store only term->entry mapping, never plaintext content 3. Consider adding plausible deniability via fake entries
============================================================================= END DESIGN DOCUMENT =============================================================================
Performance Characteristics:
List is O(n) where n is the number of entries. It performs no decryption, only directory walking. For large vaults (1k+ entries), List is fast.
Find uses a two-pass approach:
- First pass: O(n) path-only comparison (no decryption)
- Second pass: O(k) decryption + field search where k = entries where path didn't match
The fast path optimization means:
- Queries matching paths (e.g., "github" matching "github.com/user") avoid decryption entirely
- Only field content searches require decrypting entries
FindConcurrent uses a bounded worker pool for parallel decryption:
- Same two-pass logic as Find, but decrypts entries concurrently
- Default 4 concurrent workers balances throughput vs memory pressure
- Best for field-search queries on large vaults (10k+ entries)
Limits:
- 100 entries: ~10ms (path-only when possible)
- 1,000 entries: ~100ms (path-only when possible)
- 10,000 entries: ~1s (path-only when possible)
- Field searches scale with number of non-path-matching entries
Index ¶
- Variables
- func CollectFieldMatches(matches map[string]struct{}, prefix string, value any, needle string)
- func DeleteEntry(vaultDir, path string) error
- func EnsureDir(v *Vault, path string) error
- func EntryPath(v *Vault, path string) string
- func Init(vaultDir string, identity *age.X25519Identity, cfg *vaultconfig.Config) error
- func InitWithPassphrase(vaultDir string, passphrase string, cfg *vaultconfig.Config) (*age.X25519Identity, error)
- func IsInitialized(vaultDir string) bool
- func IsStructuredEntry(data map[string]any) bool
- func List(vaultDir string, prefix string) ([]string, error)
- func SafeMkdirAll(path string, perm os.FileMode) error
- func SafeRemove(path string) error
- func SafeWriteFile(path string, data []byte, perm os.FileMode) error
- func WriteEntry(vaultDir, path string, entry *Entry, identity *age.X25519Identity) error
- func WriteEntryV2(vaultDir, path string, entry *EntryV2, identity *age.X25519Identity) error
- func WriteEntryWithRecipients(vaultDir, path string, entry *Entry, identity *age.X25519Identity) error
- type CustomField
- type CustomFieldType
- type Entry
- type EntryMetadata
- type EntryV2
- func (e *EntryV2) AddCustomField(field CustomField)
- func (e *EntryV2) AddTag(tag string)
- func (e *EntryV2) GetCustomField(name string) (CustomField, bool)
- func (e EntryV2) MarshalJSON() ([]byte, error)
- func (e *EntryV2) RemoveCustomField(name string)
- func (e *EntryV2) RemoveTag(tag string)
- func (e *EntryV2) ToLegacyEntry() *Entry
- func (e *EntryV2) UnmarshalJSON(data []byte) error
- func (e *EntryV2) UpdateTimestamps()
- type Match
- type RecipientInfo
- type RecipientsManager
- func (rm *RecipientsManager) AddRecipient(recipientStr string) error
- func (rm *RecipientsManager) ListRecipients() ([]RecipientInfo, error)
- func (rm *RecipientsManager) LoadRecipientStrings() ([]string, error)
- func (rm *RecipientsManager) LoadRecipients() ([]*age.X25519Recipient, error)
- func (rm *RecipientsManager) RecipientsFileExists() bool
- func (rm *RecipientsManager) RecipientsFilePath() string
- func (rm *RecipientsManager) RemoveRecipient(recipientStr string) error
- type TOTPConfig
- type Vault
Constants ¶
This section is empty.
Variables ¶
var ( ErrRecipientAlreadyExists = errors.New("recipient already exists") ErrRecipientNotFound = errors.New("recipient not found") ErrInvalidRecipient = errors.New("invalid recipient") ErrEmptyRecipientFile = errors.New("recipients file is empty") )
Common recipients errors
var ( ErrVaultDirEmpty = errors.New("vault directory is empty") ErrNilIdentity = errors.New("identity is nil") ErrNilConfig = errors.New("config is nil") ErrIdentityMismatch = errors.New("identity mismatch") ErrVaultNotInitialized = errors.New("vault not initialized") ErrVaultDirEscapes = errors.New("vault directory path escapes intended directory") )
Common vault errors
Functions ¶
func CollectFieldMatches ¶ added in v1.1.0
func DeleteEntry ¶
DeleteEntry removes an entry from the vault
func Init ¶
func Init(vaultDir string, identity *age.X25519Identity, cfg *vaultconfig.Config) error
Init initializes a new vault at the given directory with the provided identity and config. It creates the vault directory, config file, and encrypted identity file.
func InitWithPassphrase ¶
func InitWithPassphrase(vaultDir string, passphrase string, cfg *vaultconfig.Config) (*age.X25519Identity, error)
InitWithPassphrase initializes a new vault with a passphrase-protected identity.
func IsInitialized ¶
IsInitialized checks if a vault is initialized at the given directory
func IsStructuredEntry ¶
IsStructuredEntry checks if the given data represents a structured EntryV2 by looking for the presence of version field and created_at timestamp
func SafeRemove ¶ added in v1.1.0
func SafeWriteFile ¶ added in v1.1.0
func WriteEntry ¶
func WriteEntry(vaultDir, path string, entry *Entry, identity *age.X25519Identity) error
WriteEntry encrypts and writes an entry to the vault
func WriteEntryV2 ¶
func WriteEntryV2(vaultDir, path string, entry *EntryV2, identity *age.X25519Identity) error
WriteEntryV2 writes an EntryV2 to the vault
func WriteEntryWithRecipients ¶
func WriteEntryWithRecipients(vaultDir, path string, entry *Entry, identity *age.X25519Identity) error
WriteEntryWithRecipients encrypts and writes an entry to the vault, encrypting for all recipients including those in recipients.txt
Types ¶
type CustomField ¶
type CustomField struct {
// Name is the field identifier
Name string `json:"name"`
// Value is the field content
Value string `json:"value"`
// Type indicates how the field should be displayed/handled
Type CustomFieldType `json:"type,omitempty"`
}
CustomField represents a user-defined field with a type
type CustomFieldType ¶
type CustomFieldType string
CustomFieldType represents the type of a custom field
const ( // FieldTypeString is a plain text field FieldTypeString CustomFieldType = "string" // FieldTypeHidden is a concealed field (like a second password) FieldTypeHidden CustomFieldType = "hidden" // FieldTypeURL is a URL field FieldTypeURL CustomFieldType = "url" // FieldTypeEmail is an email address field FieldTypeEmail CustomFieldType = "email" // FieldTypeDate is a date field FieldTypeDate CustomFieldType = "date" // FieldTypeNumber is a numeric field FieldTypeNumber CustomFieldType = "number" )
type Entry ¶
type Entry struct {
Data map[string]any `json:"data"`
Metadata EntryMetadata `json:"meta"`
}
Entry represents a vault entry with flexible data storage. This is the legacy format that uses map[string]any for data storage. For a more structured approach, use EntryV2.
func MergeEntry ¶
func MergeEntry(vaultDir, path string, partialData map[string]any, identity *age.X25519Identity) (*Entry, error)
MergeEntry merges partial data into an existing entry
func MergeEntryWithRecipients ¶
func MergeEntryWithRecipients(vaultDir, path string, partialData map[string]any, identity *age.X25519Identity) (*Entry, error)
MergeEntryWithRecipients merges partial data into an existing entry, encrypting for all recipients
func ReadEntry ¶
func ReadEntry(vaultDir, path string, identity *age.X25519Identity) (*Entry, error)
ReadEntry reads and decrypts an entry from the vault
func (*Entry) GetField ¶
GetField retrieves a field value from an entry, supporting both legacy map-based data and structured EntryV2 fields
func (Entry) MarshalJSON ¶
MarshalJSON implements custom JSON marshaling for Entry
func (*Entry) UnmarshalJSON ¶
UnmarshalJSON implements custom JSON unmarshaling for Entry
type EntryMetadata ¶
type EntryMetadata struct {
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Version int `json:"version"`
}
EntryMetadata contains metadata about an entry
type EntryV2 ¶
type EntryV2 struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TOTP *TOTPConfig `json:"totp,omitempty"`
Name string `json:"name,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
URL string `json:"url,omitempty"`
Notes string `json:"notes,omitempty"`
Tags []string `json:"tags,omitempty"`
CustomFields []CustomField `json:"custom_fields,omitempty"`
Version int `json:"version"`
}
EntryV2 represents a structured password entry with typed fields. This is the new schema that provides better type safety and structure compared to the legacy Entry type which used map[string]any.
func EntryV2FromLegacy ¶
EntryV2FromLegacy creates an EntryV2 from a legacy Entry This allows migration from the old format to the new structured format
func MergeEntryV2 ¶
func MergeEntryV2(vaultDir, path string, mergeFn func(*EntryV2) error, identity *age.X25519Identity) (*EntryV2, error)
MergeEntryV2 merges partial data into an existing EntryV2
func NewEntryV2 ¶
func NewEntryV2() *EntryV2
NewEntryV2 creates a new EntryV2 with initialized timestamps
func ReadEntryV2 ¶
func ReadEntryV2(vaultDir, path string, identity *age.X25519Identity) (*EntryV2, error)
ReadEntryV2 reads an entry and converts it to EntryV2 format If the stored entry is in legacy format, it will be converted
func (*EntryV2) AddCustomField ¶
func (e *EntryV2) AddCustomField(field CustomField)
AddCustomField adds or updates a custom field
func (*EntryV2) GetCustomField ¶
func (e *EntryV2) GetCustomField(name string) (CustomField, bool)
GetCustomField retrieves a custom field by name
func (EntryV2) MarshalJSON ¶
MarshalJSON implements custom JSON marshaling for EntryV2
func (*EntryV2) RemoveCustomField ¶
RemoveCustomField removes a custom field by name
func (*EntryV2) ToLegacyEntry ¶
ToLegacyEntry converts an EntryV2 to the legacy Entry format This maintains backward compatibility with existing vault data
func (*EntryV2) UnmarshalJSON ¶
UnmarshalJSON implements custom JSON unmarshaling for EntryV2
func (*EntryV2) UpdateTimestamps ¶
func (e *EntryV2) UpdateTimestamps()
UpdateTimestamps updates the UpdatedAt timestamp and increments version
type Match ¶
func Find ¶
Find searches vault entries matching a query. Performance: Uses path-only fast path to avoid decrypting entries when possible. If query appears in a path, entry is included without decryption. Only entries where path doesn't match are decrypted to search field content.
func FindConcurrent ¶ added in v1.1.0
FindConcurrent searches vault entries using bounded parallel decryption. It uses a worker pool with maxWorkers concurrent decryption operations. This is optimal for large vaults (10k+ entries) with field searches that don't match paths.
type RecipientInfo ¶
type RecipientInfo struct {
RawString string
Normalized string
Error string
LineNumber int
Valid bool
}
RecipientInfo contains information about a recipient entry
type RecipientsManager ¶
type RecipientsManager struct {
// contains filtered or unexported fields
}
RecipientsManager handles the recipients.txt file operations
func NewRecipientsManager ¶
func NewRecipientsManager(vaultDir string) *RecipientsManager
NewRecipientsManager creates a new recipients manager for the given vault directory
func (*RecipientsManager) AddRecipient ¶
func (rm *RecipientsManager) AddRecipient(recipientStr string) error
AddRecipient adds a new recipient to the recipients.txt file. Validates the recipient format before adding. Returns ErrRecipientAlreadyExists if the recipient is already in the file.
func (*RecipientsManager) ListRecipients ¶
func (rm *RecipientsManager) ListRecipients() ([]RecipientInfo, error)
ListRecipients returns a list of all recipients with their line numbers. Useful for displaying to users.
func (*RecipientsManager) LoadRecipientStrings ¶
func (rm *RecipientsManager) LoadRecipientStrings() ([]string, error)
LoadRecipientStrings loads all recipient strings from the file without validation. Used for listing and management operations.
func (*RecipientsManager) LoadRecipients ¶
func (rm *RecipientsManager) LoadRecipients() ([]*age.X25519Recipient, error)
LoadRecipients loads all valid recipients from the recipients.txt file. Lines starting with # are treated as comments and ignored. Empty lines are skipped. Returns the list of recipients and any validation errors encountered.
func (*RecipientsManager) RecipientsFileExists ¶
func (rm *RecipientsManager) RecipientsFileExists() bool
RecipientsFileExists checks if the recipients.txt file exists
func (*RecipientsManager) RecipientsFilePath ¶
func (rm *RecipientsManager) RecipientsFilePath() string
RecipientsFilePath returns the full path to the recipients.txt file
func (*RecipientsManager) RemoveRecipient ¶
func (rm *RecipientsManager) RemoveRecipient(recipientStr string) error
RemoveRecipient removes a recipient from the recipients.txt file. Returns ErrRecipientNotFound if the recipient is not in the file.
type TOTPConfig ¶
type TOTPConfig struct {
Secret string `json:"secret"`
Algorithm string `json:"algorithm,omitempty"`
Issuer string `json:"issuer,omitempty"`
AccountName string `json:"account_name,omitempty"`
Digits int `json:"digits,omitempty"`
Period int `json:"period,omitempty"`
}
TOTPConfig represents configuration for time-based one-time passwords
type Vault ¶
type Vault struct {
Identity *age.X25519Identity
Config *vaultconfig.Config
Dir string
}
Vault represents an encrypted password vault
func Open ¶
func Open(vaultDir string, identity *age.X25519Identity) (*Vault, error)
Open opens an existing vault at the given directory with the provided identity. It verifies the identity matches the stored encrypted identity.
func OpenWithPassphrase ¶
OpenWithPassphrase opens a vault using a passphrase-protected identity file.
func (*Vault) AutoCommit ¶
AutoCommit performs a git auto-commit with vault configuration
func (*Vault) GetAllRecipientsForEncryption ¶
func (v *Vault) GetAllRecipientsForEncryption() ([]*age.X25519Recipient, error)
GetAllRecipientsForEncryption returns all recipients that should be used for encryption. This includes the vault's own recipient plus all recipients from the recipients.txt file.
func (*Vault) GetRecipient ¶
func (v *Vault) GetRecipient() (*age.X25519Recipient, error)
GetRecipient returns the vault's recipient (public key)
func (*Vault) ValidateIdentity ¶
func (v *Vault) ValidateIdentity(identity *age.X25519Identity) error
ValidateIdentity validates that the provided identity matches the vault