Skip to content

Commit c5ac576

Browse files
committed
feat(crypto): add per-page HPKE encryption for LTX v4
Implement per-page encryption using HPKE (RFC 9180) with DHKEM(X25519) + HKDF-SHA256 + ChaCha20-Poly1305. Each LTX file gets a random CEK wrapped per-recipient via HPKE, with separate derived keys for page encryption and index authentication. Key changes: - Bump format to v4 with backward-compatible v3 decoding - Add HeaderFlagEncryptedHPKE flag and encryption header fields - Per-page ChaCha20-Poly1305 encryption with random nonces - Page index authentication via AEAD with empty plaintext - Duplicate recipient block at start and end for VFS access - Multi-recipient support from day one - CLI: keygen, rekey commands; -key/-encrypt-to flags on apply, dump, encode-db, list, verify Refs #77
1 parent d017048 commit c5ac576

File tree

19 files changed

+1492
-76
lines changed

19 files changed

+1492
-76
lines changed

cmd/ltx/apply.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/hex"
56
"flag"
67
"fmt"
78
"io"
@@ -22,6 +23,7 @@ func NewApplyCommand() *ApplyCommand {
2223
func (c *ApplyCommand) Run(ctx context.Context, args []string) (ret error) {
2324
fs := flag.NewFlagSet("ltx-apply", flag.ContinueOnError)
2425
dbPath := fs.String("db", "", "database path")
26+
keyHex := fs.String("key", "", "hex-encoded private key for decryption")
2527
fs.Usage = func() {
2628
fmt.Println(`
2729
The apply command applies one or more LTX files to a database file.
@@ -45,6 +47,15 @@ Arguments:
4547
return fmt.Errorf("required: -db PATH")
4648
}
4749

50+
var decryptionKey []byte
51+
if *keyHex != "" {
52+
var err error
53+
decryptionKey, err = hex.DecodeString(*keyHex)
54+
if err != nil {
55+
return fmt.Errorf("invalid -key: %w", err)
56+
}
57+
}
58+
4859
// Open database file. Create if it doesn't exist.
4960
dbFile, err := os.OpenFile(*dbPath, os.O_RDWR|os.O_CREATE, 0o666)
5061
if err != nil {
@@ -54,7 +65,7 @@ Arguments:
5465

5566
// Apply LTX files in order.
5667
for _, filename := range fs.Args() {
57-
if err := c.applyLTXFile(ctx, dbFile, filename); err != nil {
68+
if err := c.applyLTXFile(ctx, dbFile, filename, decryptionKey); err != nil {
5869
return fmt.Errorf("%s: %s", filename, err)
5970
}
6071
}
@@ -66,7 +77,7 @@ Arguments:
6677
return dbFile.Close()
6778
}
6879

69-
func (c *ApplyCommand) applyLTXFile(_ context.Context, dbFile *os.File, filename string) error {
80+
func (c *ApplyCommand) applyLTXFile(_ context.Context, dbFile *os.File, filename string, decryptionKey []byte) error {
7081
ltxFile, err := os.Open(filename)
7182
if err != nil {
7283
return err
@@ -75,6 +86,9 @@ func (c *ApplyCommand) applyLTXFile(_ context.Context, dbFile *os.File, filename
7586

7687
// Read LTX header and verify initial checksum matches.
7788
dec := ltx.NewDecoder(ltxFile)
89+
if decryptionKey != nil {
90+
dec.SetDecryptionKey(decryptionKey)
91+
}
7892
if err := dec.DecodeHeader(); err != nil {
7993
return fmt.Errorf("decode ltx header: %w", err)
8094
}

cmd/ltx/dump.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/hex"
56
"flag"
67
"fmt"
78
"io"
@@ -22,6 +23,7 @@ func NewDumpCommand() *DumpCommand {
2223
// Run executes the command.
2324
func (c *DumpCommand) Run(ctx context.Context, args []string) (ret error) {
2425
fs := flag.NewFlagSet("ltx-dump", flag.ContinueOnError)
26+
keyHex := fs.String("key", "", "hex-encoded private key for decryption")
2527
fs.Usage = func() {
2628
fmt.Println(`
2729
The dump command writes out all data for a single LTX file.
@@ -43,13 +45,25 @@ Arguments:
4345
return fmt.Errorf("too many arguments")
4446
}
4547

48+
var decryptionKey []byte
49+
if *keyHex != "" {
50+
var err error
51+
decryptionKey, err = hex.DecodeString(*keyHex)
52+
if err != nil {
53+
return fmt.Errorf("invalid -key: %w", err)
54+
}
55+
}
56+
4657
f, err := os.Open(fs.Arg(0))
4758
if err != nil {
4859
return err
4960
}
5061
defer func() { _ = f.Close() }()
5162

5263
dec := ltx.NewDecoder(f)
64+
if decryptionKey != nil {
65+
dec.SetDecryptionKey(decryptionKey)
66+
}
5367

5468
// Read & print header information.
5569
err = dec.DecodeHeader()
@@ -66,6 +80,12 @@ Arguments:
6680
fmt.Printf("WAL offset: %d\n", hdr.WALOffset)
6781
fmt.Printf("WAL size: %d\n", hdr.WALSize)
6882
fmt.Printf("WAL salt: %08x %08x\n", hdr.WALSalt1, hdr.WALSalt2)
83+
if hdr.Encrypted() {
84+
fmt.Printf("Encrypted: yes (recipients=%d, KEM=0x%04x, KDF=0x%04x, AEAD=0x%04x)\n",
85+
hdr.RecipientCount, hdr.KEMID, hdr.KDFID, hdr.AEADID)
86+
} else {
87+
fmt.Printf("Encrypted: no\n")
88+
}
6989
fmt.Printf("\n")
7090
if err != nil {
7191
return err

cmd/ltx/encode_db.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"bytes"
55
"context"
66
"encoding/binary"
7+
"encoding/hex"
78
"flag"
89
"fmt"
910
"io"
1011
"os"
12+
"strings"
1113
"time"
1214

1315
"github.com/superfly/ltx"
@@ -30,6 +32,7 @@ func NewEncodeDBCommand() *EncodeDBCommand {
3032
func (c *EncodeDBCommand) Run(ctx context.Context, args []string) (ret error) {
3133
fs := flag.NewFlagSet("ltx-encode-db", flag.ContinueOnError)
3234
outPath := fs.String("o", "", "output path")
35+
encryptTo := fs.String("encrypt-to", "", "comma-separated hex-encoded public keys for encryption")
3336
fs.Usage = func() {
3437
fmt.Println(`
3538
The encode-db command encodes an SQLite database into an LTX file.
@@ -59,6 +62,17 @@ Arguments:
5962
}
6063
defer func() { _ = db.Close() }()
6164

65+
var recipientKeys [][]byte
66+
if *encryptTo != "" {
67+
for _, s := range strings.Split(*encryptTo, ",") {
68+
key, err := hex.DecodeString(strings.TrimSpace(s))
69+
if err != nil {
70+
return fmt.Errorf("invalid -encrypt-to key: %w", err)
71+
}
72+
recipientKeys = append(recipientKeys, key)
73+
}
74+
}
75+
6276
out, err := os.OpenFile(*outPath, os.O_CREATE|os.O_WRONLY, 0o644)
6377
if err != nil {
6478
return fmt.Errorf("open output file: %w", err)
@@ -76,8 +90,13 @@ Arguments:
7690
if err != nil {
7791
return fmt.Errorf("create ltx encoder: %w", err)
7892
}
93+
if len(recipientKeys) > 0 {
94+
if err := enc.SetEncryption(recipientKeys); err != nil {
95+
return fmt.Errorf("set encryption: %w", err)
96+
}
97+
}
7998
if err := enc.EncodeHeader(ltx.Header{
80-
Version: 1,
99+
Version: ltx.Version,
81100
Flags: flags,
82101
PageSize: hdr.pageSize,
83102
Commit: hdr.pageN,

cmd/ltx/keygen.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/hex"
6+
"flag"
7+
"fmt"
8+
9+
"github.com/superfly/ltx"
10+
)
11+
12+
type KeygenCommand struct{}
13+
14+
func NewKeygenCommand() *KeygenCommand {
15+
return &KeygenCommand{}
16+
}
17+
18+
func (c *KeygenCommand) Run(_ context.Context, args []string) error {
19+
fs := flag.NewFlagSet("ltx-keygen", flag.ContinueOnError)
20+
fs.Usage = func() {
21+
fmt.Println(`
22+
The keygen command generates an X25519 keypair for HPKE encryption.
23+
24+
Usage:
25+
26+
ltx keygen
27+
28+
Output is two lines of hex-encoded keys: public key, then private key.
29+
`[1:])
30+
}
31+
if err := fs.Parse(args); err != nil {
32+
return err
33+
}
34+
35+
pub, priv, err := ltx.GenerateKeyPair()
36+
if err != nil {
37+
return fmt.Errorf("generate keypair: %w", err)
38+
}
39+
40+
fmt.Printf("public: %s\n", hex.EncodeToString(pub))
41+
fmt.Printf("private: %s\n", hex.EncodeToString(priv))
42+
43+
return nil
44+
}

cmd/ltx/list.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/hex"
56
"flag"
67
"fmt"
78
"io"
@@ -25,6 +26,7 @@ func NewListCommand() *ListCommand {
2526
func (c *ListCommand) Run(ctx context.Context, args []string) (ret error) {
2627
fs := flag.NewFlagSet("ltx-list", flag.ContinueOnError)
2728
tsv := fs.Bool("tsv", false, "output as tab-separated values")
29+
keyHex := fs.String("key", "", "hex-encoded private key for decryption")
2830
fs.Usage = func() {
2931
fmt.Println(`
3032
The list command lists header & trailer information for a set of LTX files.
@@ -44,31 +46,43 @@ Arguments:
4446
return fmt.Errorf("at least one LTX file is required")
4547
}
4648

49+
var decryptionKey []byte
50+
if *keyHex != "" {
51+
var err error
52+
decryptionKey, err = hex.DecodeString(*keyHex)
53+
if err != nil {
54+
return fmt.Errorf("invalid -key: %w", err)
55+
}
56+
}
57+
4758
var w io.Writer = os.Stdout
4859
if !*tsv {
4960
tw := tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0)
5061
defer func() { _ = tw.Flush() }()
5162
w = tw
5263
}
5364

54-
_, _ = fmt.Fprintln(w, "min_txid\tmax_txid\tcommit\tpages\tpreapply\tpostapply\ttimestamp\twal_offset\twal_size\twal_salt")
65+
_, _ = fmt.Fprintln(w, "min_txid\tmax_txid\tcommit\tpages\tpreapply\tpostapply\ttimestamp\twal_offset\twal_size\twal_salt\tencrypted")
5566
for _, arg := range fs.Args() {
56-
if err := c.printFile(w, arg); err != nil {
67+
if err := c.printFile(w, arg, decryptionKey); err != nil {
5768
_, _ = fmt.Fprintf(os.Stderr, "%s: %s\n", arg, err)
5869
}
5970
}
6071

6172
return nil
6273
}
6374

64-
func (c *ListCommand) printFile(w io.Writer, filename string) error {
75+
func (c *ListCommand) printFile(w io.Writer, filename string, decryptionKey []byte) error {
6576
f, err := os.Open(filename)
6677
if err != nil {
6778
return err
6879
}
6980
defer func() { _ = f.Close() }()
7081

7182
dec := ltx.NewDecoder(f)
83+
if decryptionKey != nil {
84+
dec.SetDecryptionKey(decryptionKey)
85+
}
7286
if err := dec.Verify(); err != nil {
7387
return err
7488
}
@@ -79,7 +93,12 @@ func (c *ListCommand) printFile(w io.Writer, filename string) error {
7993
timestamp = ""
8094
}
8195

82-
_, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\t%s\t%s\t%d\t%d\t%08x %08x\n",
96+
encrypted := "no"
97+
if dec.Header().Encrypted() {
98+
encrypted = "yes"
99+
}
100+
101+
_, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\t%s\t%s\t%d\t%d\t%08x %08x\t%s\n",
83102
dec.Header().MinTXID.String(),
84103
dec.Header().MaxTXID.String(),
85104
dec.Header().Commit,
@@ -90,6 +109,7 @@ func (c *ListCommand) printFile(w io.Writer, filename string) error {
90109
dec.Header().WALOffset,
91110
dec.Header().WALSize,
92111
dec.Header().WALSalt1, dec.Header().WALSalt2,
112+
encrypted,
93113
)
94114

95115
return nil

cmd/ltx/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,12 @@ func (m *Main) Run(ctx context.Context, args []string) (err error) {
4949
return NewDumpCommand().Run(ctx, args)
5050
case "encode-db":
5151
return NewEncodeDBCommand().Run(ctx, args)
52+
case "keygen":
53+
return NewKeygenCommand().Run(ctx, args)
5254
case "list":
5355
return NewListCommand().Run(ctx, args)
56+
case "rekey":
57+
return NewRekeyCommand().Run(ctx, args)
5458
case "verify":
5559
return NewVerifyCommand().Run(ctx, args)
5660
case "version":
@@ -85,7 +89,10 @@ The commands are:
8589
apply applies a set of LTX files to a database
8690
checksum computes the LTX checksum of a database file
8791
dump writes out metadata and page headers for a set of LTX files
92+
encode-db encodes an SQLite database into an LTX file
93+
keygen generates an X25519 keypair for HPKE encryption
8894
list lists header & trailer fields for LTX files in a table
95+
rekey re-encrypts an LTX file with new recipients
8996
verify reads & verifies checksums of a set of LTX files
9097
version prints the version
9198
`[1:])

0 commit comments

Comments
 (0)