diff --git a/aperturedb/onion.go b/aperturedb/onion.go new file mode 100644 index 0000000..4997ead --- /dev/null +++ b/aperturedb/onion.go @@ -0,0 +1,170 @@ +package aperturedb + +import ( + "bytes" + "context" + "database/sql" + "fmt" + + "github.com/lightninglabs/aperture/aperturedb/sqlc" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/tor" +) + +type ( + NewOnionPrivateKey = sqlc.UpsertOnionParams +) + +// OnionDB is an interface that defines the set of operations that can be +// executed against the onion database. +type OnionDB interface { + // UpsertOnion inserts a new onion private key into the database. If + // the onion private key already exists in the db this is a NOOP + // operation. + UpsertOnion(ctx context.Context, arg NewOnionPrivateKey) error + + // SelectOnionPrivateKey selects the onion private key from the + // database. + SelectOnionPrivateKey(ctx context.Context) ([]byte, error) + + // DeleteOnionPrivateKey deletes the onion private key from the + // database. + DeleteOnionPrivateKey(ctx context.Context) error +} + +// OnionTxOptions defines the set of db txn options the OnionStore +// understands. +type OnionDBTxOptions struct { + // readOnly governs if a read only transaction is needed or not. + readOnly bool +} + +// ReadOnly returns true if the transaction should be read only. +// +// NOTE: This implements the TxOptions +func (a *OnionDBTxOptions) ReadOnly() bool { + return a.readOnly +} + +// NewOnionDBReadTx creates a new read transaction option set. +func NewOnionDBReadTx() OnionDBTxOptions { + return OnionDBTxOptions{ + readOnly: true, + } +} + +// BatchedOnionDB is a version of the OnionDB that's capable of batched +// database operations. +type BatchedOnionDB interface { + OnionDB + + BatchedTx[OnionDB] +} + +// OnionStore represents a storage backend. +type OnionStore struct { + db BatchedOnionDB + clock clock.Clock +} + +// NewOnionStore creates a new OnionStore instance given a open BatchedOnionDB +// storage backend. +func NewOnionStore(db BatchedOnionDB) *OnionStore { + return &OnionStore{ + db: db, + clock: clock.NewDefaultClock(), + } +} + +// StorePrivateKey stores the private key according to the implementation of +// the OnionStore interface. +func (o *OnionStore) StorePrivateKey(privateKey []byte) error { + ctxt, cancel := context.WithTimeout( + context.Background(), DefaultStoreTimeout, + ) + defer cancel() + + var writeTxOpts OnionDBTxOptions + err := o.db.ExecTx(ctxt, &writeTxOpts, func(tx OnionDB) error { + // Only store the private key if it doesn't already exist. + dbPK, err := tx.SelectOnionPrivateKey(ctxt) + switch { + // If there is already a different private key stored in the + /// database, return an error. + case dbPK != nil && !bytes.Equal(dbPK, privateKey): + return fmt.Errorf("private key already exists") + + case err != nil && err != sql.ErrNoRows: + return err + } + + params := NewOnionPrivateKey{ + PrivateKey: privateKey, + CreatedAt: o.clock.Now().UTC(), + } + + return tx.UpsertOnion(ctxt, params) + }) + + if err != nil { + return fmt.Errorf("failed to store private key: %v", err) + } + + return nil +} + +// PrivateKey retrieves a stored private key. If it is not found, then +// ErrNoPrivateKey should be returned. +func (o *OnionStore) PrivateKey() ([]byte, error) { + ctxt, cancel := context.WithTimeout( + context.Background(), DefaultStoreTimeout, + ) + defer cancel() + + var ( + privateKey []byte + ) + + var readTxOpts OnionDBTxOptions + err := o.db.ExecTx(ctxt, &readTxOpts, func(tx OnionDB) error { + row, err := o.db.SelectOnionPrivateKey(ctxt) + switch { + case err == sql.ErrNoRows: + return tor.ErrNoPrivateKey + + case err != nil: + return err + } + + privateKey = make([]byte, len(row)) + copy(privateKey, row) + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve private key: %w", + err) + } + + return privateKey, nil +} + +// DeletePrivateKey securely removes the private key from the store. +func (o *OnionStore) DeletePrivateKey() error { + ctxt, cancel := context.WithTimeout( + context.Background(), DefaultStoreTimeout, + ) + defer cancel() + + var writeTxOpts OnionDBTxOptions + err := o.db.ExecTx(ctxt, &writeTxOpts, func(tx OnionDB) error { + return tx.DeleteOnionPrivateKey(ctxt) + }) + + if err != nil { + return fmt.Errorf("failed to delete private key: %v", err) + } + + return nil +} diff --git a/aperturedb/onion_test.go b/aperturedb/onion_test.go new file mode 100644 index 0000000..3445301 --- /dev/null +++ b/aperturedb/onion_test.go @@ -0,0 +1,56 @@ +package aperturedb + +import ( + "database/sql" + "testing" + + "github.com/lightningnetwork/lnd/tor" + "github.com/stretchr/testify/require" +) + +func newOnionStoreWithDB(db *BaseDB) *OnionStore { + dbTxer := NewTransactionExecutor(db, + func(tx *sql.Tx) OnionDB { + return db.WithTx(tx) + }, + ) + + return NewOnionStore(dbTxer) +} + +func TestOnionDB(t *testing.T) { + // First, create a new test database. + db := NewTestDB(t) + store := newOnionStoreWithDB(db.BaseDB) + + // Attempting to retrieve a private key when none is stored returns the + // expected error. + _, err := store.PrivateKey() + require.ErrorIs(t, err, tor.ErrNoPrivateKey) + + // Store a private key. + privateKey := []byte("private key") + err = store.StorePrivateKey(privateKey) + require.NoError(t, err) + + // Retrieving the private key should return the stored value. + privateKeyDB, err := store.PrivateKey() + require.NoError(t, err) + require.Equal(t, privateKey, privateKeyDB) + + // Storing the same private key should not return an error. + err = store.StorePrivateKey(privateKey) + require.NoError(t, err) + + // We can only store one private key. + newPrivateKey := []byte("second private key") + err = store.StorePrivateKey(newPrivateKey) + require.Error(t, err) + + // Remove the stored private key. + err = store.DeletePrivateKey() + require.NoError(t, err) + + err = store.StorePrivateKey(newPrivateKey) + require.NoError(t, err) +} diff --git a/aperturedb/secrets_test.go b/aperturedb/secrets_test.go index a3d94d5..ec5763e 100644 --- a/aperturedb/secrets_test.go +++ b/aperturedb/secrets_test.go @@ -16,7 +16,7 @@ var ( defaultTestTimeout = 5 * time.Second ) -func newSecretsStoreWithDB(t *testing.T, db *BaseDB) *SecretsStore { +func newSecretsStoreWithDB(db *BaseDB) *SecretsStore { dbTxer := NewTransactionExecutor(db, func(tx *sql.Tx) SecretsDB { return db.WithTx(tx) @@ -34,7 +34,7 @@ func TestSecretDB(t *testing.T) { // First, create a new test database. db := NewTestDB(t) - store := newSecretsStoreWithDB(t, db.BaseDB) + store := newSecretsStoreWithDB(db.BaseDB) // Create a random hash. hash := [sha256.Size]byte{}