From b9b9785834285aefab8591f5ea0209aa537e6ee8 Mon Sep 17 00:00:00 2001 From: pimg Date: Thu, 11 Jul 2024 20:24:02 +0200 Subject: [PATCH] changed storage to SQLite and introduces the browse stored CRL view --- Makefile | 4 + README.md | 16 +- cmd/root.go | 48 +++++- db_schema.plantuml | 36 ++++ db_schema.svg | 1 + go.mod | 8 +- go.sum | 37 +++- .../adapter/db/certificate_revocation_list.go | 149 ++++++++++++++++ internal/adapter/db/libsql.go | 64 +++++++ .../queries/certificate_revocation_list.sql | 29 ++++ .../certificate_revocation_list.sql.go | 162 ++++++++++++++++++ internal/adapter/db/queries/db.go | 31 ++++ internal/adapter/db/queries/models.go | 28 +++ .../db/queries/revoked_certificate.sql | 22 +++ .../db/queries/revoked_certificate.sql.go | 110 ++++++++++++ internal/adapter/db/revoked_certificate.go | 36 ++++ internal/adapter/db/schema/001_init.sql | 31 ++++ internal/adapter/db/sqlc.yaml | 9 + internal/ports/models/base.go | 74 +++++--- internal/ports/models/base_test.go | 10 +- internal/ports/models/browse.go | 142 ++++++++------- internal/ports/models/commands/crl_request.go | 43 +++-- .../models/commands/revoked_certificate.go | 111 ++++++++++++ internal/ports/models/import.go | 133 ++++++++++++++ internal/ports/models/input.go | 4 +- internal/ports/models/list.go | 1 + internal/ports/models/messages/messages.go | 14 +- internal/ports/models/revoked_certificate.go | 2 +- internal/ports/models/styles/styles.go | 2 +- pkg/domain/crl/certificate_revocation_list.go | 67 ++++++++ pkg/domain/crl/revoked_certificate.go | 25 +++ pkg/domain/crl/storage.go | 39 +++++ pkg/domain/crl/storage_mock.go | 32 ++++ pkg/uri/validator.go | 8 +- pkg/uri/validator_test.go | 2 +- states.puml | 6 + 36 files changed, 1398 insertions(+), 138 deletions(-) create mode 100644 db_schema.plantuml create mode 100644 db_schema.svg create mode 100644 internal/adapter/db/certificate_revocation_list.go create mode 100644 internal/adapter/db/libsql.go create mode 100644 internal/adapter/db/queries/certificate_revocation_list.sql create mode 100644 internal/adapter/db/queries/certificate_revocation_list.sql.go create mode 100644 internal/adapter/db/queries/db.go create mode 100644 internal/adapter/db/queries/models.go create mode 100644 internal/adapter/db/queries/revoked_certificate.sql create mode 100644 internal/adapter/db/queries/revoked_certificate.sql.go create mode 100644 internal/adapter/db/revoked_certificate.go create mode 100644 internal/adapter/db/schema/001_init.sql create mode 100644 internal/adapter/db/sqlc.yaml create mode 100644 internal/ports/models/commands/revoked_certificate.go create mode 100644 internal/ports/models/import.go create mode 100644 pkg/domain/crl/certificate_revocation_list.go create mode 100644 pkg/domain/crl/revoked_certificate.go create mode 100644 pkg/domain/crl/storage.go create mode 100644 pkg/domain/crl/storage_mock.go diff --git a/Makefile b/Makefile index d0c9d97..5714372 100644 --- a/Makefile +++ b/Makefile @@ -17,3 +17,7 @@ build: @PHONY: gif gif: build vhs cassette.tape + +@PHONY: sqlc +sqlc: + sqlc generate -f internal/adapter/db/sqlc.yaml \ No newline at end of file diff --git a/README.md b/README.md index ffb0523..69814d3 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,18 @@ A Terminal User Interface (TUI) for inspecting Certificate Revocation Lists (CRL's) With CertGuard it is currently possible to: -- download new CRL files to the local cache directory -- browse locally downloaded CRL files +- download & save new CRL files to the local storage +- import locally downloaded CRL files to the local storage +- browse stored CRL's +- list entries in a CRL file - inspect entries in a CRL file ![demo](demo.gif) ## File locations CertGuard uses two file locations: -- `~/.cache/certguard` for the file cache where CRL files are stored +- `~/.cache/certguard` location of the database/storage file +- `~/.cache/certguard/import` import directory for importing CRLs from file - `~/.local/share/certguard` for the `debug.log` file ## States @@ -22,11 +25,18 @@ Different screens are built using different states. Below is a statemachine depi ![states](states.svg) +## Storage +All information on CRL's and revoked certificates are stored on a local SQLite database. +The Database schema used for Certguard only stores public information: +![database schema](db_schema.svg) + ## Development A MAKE file has been included for convenience: - `make run` builds and run the `certguard` application in `debug` mode - `make test` runs all unit tests - `make lint` runs the linter - `make build` builds the binary file `cg` +- `make sqlc` generates the Go source files from SQL files using sqlc +- `make gif` generates the gif based on the cassette.tape using vhs Since a TUI application cannot log to `stdout` a `debug.log` file is used for debug logging. It is located at: `~/.local/share/certguard/debug.log` diff --git a/cmd/root.go b/cmd/root.go index 0c810be..defb76e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,14 +1,17 @@ package cmd import ( + "context" + "errors" "fmt" "log" "os" "path/filepath" tea "github.com/charmbracelet/bubbletea" - "github.com/pimg/certguard/internal/adapter" + "github.com/pimg/certguard/internal/adapter/db" "github.com/pimg/certguard/internal/ports/models" + "github.com/pimg/certguard/pkg/domain/crl" "github.com/spf13/cobra" ) @@ -18,9 +21,9 @@ func init() { var rootCmd = &cobra.Command{ Version: "v0.0.1", - Use: "crl", - Long: "Crl Inspector (crl) can download and inspect x.509 Certificate Revocation Lists", - Example: "crl", + Use: "certguard", + Long: "Certguard can download, store and inspect x.509 Certificate Revocation Lists", + Example: "certguard", RunE: runInteractiveCertGuard, } @@ -48,11 +51,35 @@ func runInteractiveCertGuard(cmd *cobra.Command, args []string) error { defer f.Close() } - cacheDir, err := adapter.NewFileCache() + cacheDir, err := determineCacheDir() if err != nil { return err } - log.Printf("file cache initialized at: %s", cacheDir) + + dbConnection, err := db.NewDBConnection(cacheDir) + if err != nil { + return err + } + + libsqlStorage := db.NewLibSqlStorage(dbConnection) + defer func() { + err := libsqlStorage.CloseDB() + if err != nil { + log.Printf("could not close database: %v", err) + } + }() + + err = libsqlStorage.InitDB(context.Background()) + if err != nil { + return err + } + + _, err = crl.NewStorage(libsqlStorage, cacheDir) // TODO consider better setup for this + if err != nil { + return err + } + + log.Printf("cache initialized at: %s", cacheDir) if _, err := tea.NewProgram(models.NewBaseModel(), tea.WithAltScreen()).Run(); err != nil { return err @@ -63,3 +90,12 @@ func runInteractiveCertGuard(cmd *cobra.Command, args []string) error { func Execute() error { return rootCmd.Execute() } + +func determineCacheDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", errors.New("could not create file path to User home dir, Cache will not be enabled") + } + + return filepath.Join(homeDir, ".cache", "certguard"), nil +} diff --git a/db_schema.plantuml b/db_schema.plantuml new file mode 100644 index 0000000..b937b26 --- /dev/null +++ b/db_schema.plantuml @@ -0,0 +1,36 @@ +@startuml + +!theme plain +top to bottom direction +skinparam linetype ortho + +class certificate_revocation_list { + name: text + signature: blob + this_update: date + next_update: date + url: text + raw: blob + id: integer +} +class gorp_migrations { + applied_at: datetime + id: varchar(255) +} +class revoked_certificate { + serialnumber: text + revocation_date: date + reason: text + revocation_list: integer + id: integer +} +class sqlite_master { + type: text + name: text + tbl_name: text + rootpage: int + sql: text +} + +revoked_certificate -[#595959,plain]-^ certificate_revocation_list : "revocation_list:id" +@enduml diff --git a/db_schema.svg b/db_schema.svg new file mode 100644 index 0000000..4708eb6 --- /dev/null +++ b/db_schema.svg @@ -0,0 +1 @@ +certificate_revocation_listname: textsignature: blobthis_update: datenext_update: dateurl: textraw: blobid: integergorp_migrationsapplied_at: datetimeid: varchar(255)revoked_certificateserialnumber: textrevocation_date: datereason: textrevocation_list: integerid: integersqlite_mastertype: textname: texttbl_name: textrootpage: intsql: textrevocation_list:id \ No newline at end of file diff --git a/go.mod b/go.mod index f860cd5..1b34e21 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,19 @@ module github.com/pimg/certguard -go 1.21 +go 1.22 require ( github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.6 github.com/charmbracelet/lipgloss v0.11.1 + github.com/rubenv/sql-migrate v1.6.1 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 + github.com/tursodatabase/go-libsql v0.0.0-20240429120401-651096bbee0b ) require ( + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.1.3 // indirect @@ -20,7 +23,9 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -34,6 +39,7 @@ require ( github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index dd5e79a..bddd347 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -23,10 +25,24 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -36,6 +52,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -44,12 +62,20 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rubenv/sql-migrate v1.6.1 h1:bo6/sjsan9HaXAsNxYP/jCEDUGibHp8JmOBw7NTGRos= +github.com/rubenv/sql-migrate v1.6.1/go.mod h1:tPzespupJS0jacLfhbwto/UjSX+8h2FdWB7ar+QlHa0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= @@ -59,10 +85,12 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tursodatabase/go-libsql v0.0.0-20240429120401-651096bbee0b h1:R7hev4b96zgXjKbS2ZNbHBnDvyFZhH+LlMqtKH6hIkU= +github.com/tursodatabase/go-libsql v0.0.0-20240429120401-651096bbee0b/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -71,7 +99,10 @@ golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/internal/adapter/db/certificate_revocation_list.go b/internal/adapter/db/certificate_revocation_list.go new file mode 100644 index 0000000..208f3d6 --- /dev/null +++ b/internal/adapter/db/certificate_revocation_list.go @@ -0,0 +1,149 @@ +package db + +import ( + "context" + "crypto/x509" + "database/sql" + "errors" + "fmt" + "log" + "net/url" + "time" + + "github.com/pimg/certguard/internal/adapter/db/queries" + "github.com/pimg/certguard/pkg/domain/crl" +) + +// insert record +func (s *LibSqlStorage) Save(ctx context.Context, crl *crl.CertificateRevocationList) (int64, error) { + params := queries.CreateCertificateRevocationListParams{ + Name: crl.Name, + Signature: crl.Signature, + ThisUpdate: crl.ThisUpdate, + NextUpdate: sql.NullTime{ + Time: crl.NextUpdate, + Valid: true, + }, + Raw: crl.Raw, + } + + if crl.URL != nil { + params.Url = sql.NullString{ + String: crl.URL.String(), + Valid: true, + } + } + + id, err := s.Queries.CreateCertificateRevocationList(ctx, params) + if err != nil { + log.Println("could not create certificate revocation list") + return 0, err + } + + fmt.Printf("crl with id: %d stored\n", id) + return id, nil +} + +// Find a Certificate Revocation List +func (s *LibSqlStorage) Find(ctx context.Context, name string) (*crl.CertificateRevocationList, error) { + dbCrl, err := s.Queries.GetCertificateRevocationList(ctx, name) + if err != nil { + return nil, err + } + + revocationList := &crl.CertificateRevocationList{ + ID: dbCrl.ID, + Name: dbCrl.Name, + Signature: dbCrl.Signature, + Raw: dbCrl.Raw, + } + + nextUpdate, ok := dbCrl.NextUpdate.(time.Time) + if !ok { + return nil, errors.New("next_update not valid") + } + revocationList.NextUpdate = nextUpdate + + thisUpdate, ok := dbCrl.ThisUpdate.(time.Time) + if !ok { + return nil, errors.New("this_update not valid") + } + revocationList.ThisUpdate = thisUpdate + + return revocationList, nil +} + +// List all Certificate Revocation Lists +func (s *LibSqlStorage) List(ctx context.Context) ([]*crl.CertificateRevocationList, error) { + dbCrls, err := s.Queries.ListCertificateRevocationLists(ctx) + if err != nil { + return nil, err + } + + cRLs := make([]*crl.CertificateRevocationList, len(dbCrls)) + + for i, dbCrl := range dbCrls { + thisUpdate, ok := dbCrl.ThisUpdate.(time.Time) + if !ok { + return nil, errors.New("invalid ThisUpdate") + } + + nextUpdate, ok := dbCrl.NextUpdate.(time.Time) + if !ok { + return nil, errors.New("invalid NextUpdate") + } + + url, err := url.Parse(dbCrl.Url.String) + if err != nil { + return nil, errors.Join(errors.New("invalid CRL URL"), err) + } + + cRLs[i] = &crl.CertificateRevocationList{ + ID: dbCrl.ID, + Name: dbCrl.Name, + Signature: dbCrl.Signature, // TODO this query should not return Signature?? + ThisUpdate: thisUpdate, + NextUpdate: nextUpdate, + Raw: dbCrl.Raw, // TODO this query should not return raw + URL: url, // TODO convert to URL + } + } + + return cRLs, nil +} + +// save revoked certificates +func (s *LibSqlStorage) SaveRevokedCertificates(ctx context.Context, revocationListId int64, revokedCertificates []x509.RevocationListEntry) (int, error) { + tx, err := s.DB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + qtx := s.Queries.WithTx(tx) + + rowsAffected := 0 + for _, revokedCertificateEntry := range revokedCertificates { + reason, ok := crl.RevocationReasons[revokedCertificateEntry.ReasonCode] + if !ok { + return 0, errors.New("invalid ReasonCode on revoked certificate") + } + + err := qtx.CreateRevokedCertificates(ctx, queries.CreateRevokedCertificatesParams{ + Serialnumber: revokedCertificateEntry.SerialNumber.String(), + RevocationDate: revokedCertificateEntry.RevocationTime, + Reason: reason, + RevocationList: revocationListId, + }) + if err != nil { + return 0, errors.Join(errors.New("could not save certificate revocation list entry"), err) + } + rowsAffected++ + } + + if rowsAffected != len(revokedCertificates) { + return 0, errors.New("not all revoked certificates could be saved") + } + + return rowsAffected, tx.Commit() +} diff --git a/internal/adapter/db/libsql.go b/internal/adapter/db/libsql.go new file mode 100644 index 0000000..174b115 --- /dev/null +++ b/internal/adapter/db/libsql.go @@ -0,0 +1,64 @@ +package db + +import ( + "context" + "database/sql" + "embed" + _ "embed" + "errors" + "log" + + "github.com/pimg/certguard/internal/adapter/db/queries" + "github.com/rubenv/sql-migrate" + _ "github.com/tursodatabase/go-libsql" +) + +//go:embed schema/*.sql +var dbMigrations embed.FS + +type LibSqlStorage struct { + DB *sql.DB + Queries *queries.Queries +} + +func NewLibSqlStorage(db *sql.DB) *LibSqlStorage { + return &LibSqlStorage{ + DB: db, + Queries: queries.New(db), + } +} + +func NewDBConnection(dbLocation string) (*sql.DB, error) { + log.Println("Connecting to DB...") + db, err := sql.Open("libsql", "file:"+dbLocation+"/certguard.db?_journal_mode=WAL&busy_timeout=5000_foreign_keys=on") + if err != nil { + log.Printf("Error connecting to DB: %v", err) + return nil, err + } + + return db, nil +} + +func (s *LibSqlStorage) InitDB(ctx context.Context) error { + migrations := migrate.EmbedFileSystemMigrationSource{ + FileSystem: dbMigrations, + Root: "schema", + } + + n, err := migrate.ExecContext(ctx, s.DB, "sqlite3", migrations, migrate.Up) + if err != nil { + return errors.Join(errors.New("error performing migrations"), err) + } + log.Printf("database initialized, applied %d migrations!", n) + + return nil +} + +func (s *LibSqlStorage) CloseDB() error { + if closeError := s.DB.Close(); closeError != nil { + return errors.Join(errors.New("error closing database"), closeError) + } + log.Println("Database closed") + + return nil +} diff --git a/internal/adapter/db/queries/certificate_revocation_list.sql b/internal/adapter/db/queries/certificate_revocation_list.sql new file mode 100644 index 0000000..6e88e72 --- /dev/null +++ b/internal/adapter/db/queries/certificate_revocation_list.sql @@ -0,0 +1,29 @@ +-- name: CreateCertificateRevocationList :one +INSERT INTO certificate_revocation_list( + name, + signature, + this_update, + next_update, + url, + raw +) VALUES (?,?,?,?,?,?) + ON CONFLICT DO UPDATE SET + this_update = excluded.this_update, + next_update = excluded.next_update +RETURNING id; + +-- name: UpdateCertificateRevocationList :one +UPDATE certificate_revocation_list +set this_update = ?, + next_update = ?, + raw = ? +WHERE name = ? +RETURNING *; + +-- name: GetCertificateRevocationList :one +SELECT id, name, signature, DATETIME(this_update) as this_update, DATETIME(next_update) as next_update, url, raw FROM certificate_revocation_list +WHERE name = ?; + +-- name: ListCertificateRevocationLists :many +SELECT id, name, signature, DATETIME(this_update) as this_update, DATETIME(next_update) as next_update, url, raw FROM certificate_revocation_list +ORDER BY id; \ No newline at end of file diff --git a/internal/adapter/db/queries/certificate_revocation_list.sql.go b/internal/adapter/db/queries/certificate_revocation_list.sql.go new file mode 100644 index 0000000..a81fc7c --- /dev/null +++ b/internal/adapter/db/queries/certificate_revocation_list.sql.go @@ -0,0 +1,162 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: certificate_revocation_list.sql + +package queries + +import ( + "context" + "database/sql" + "time" +) + +const createCertificateRevocationList = `-- name: CreateCertificateRevocationList :one +INSERT INTO certificate_revocation_list( + name, + signature, + this_update, + next_update, + url, + raw +) VALUES (?,?,?,?,?,?) + ON CONFLICT DO UPDATE SET + this_update = excluded.this_update, + next_update = excluded.next_update +RETURNING id +` + +type CreateCertificateRevocationListParams struct { + Name string + Signature []byte + ThisUpdate time.Time + NextUpdate sql.NullTime + Url sql.NullString + Raw []byte +} + +func (q *Queries) CreateCertificateRevocationList(ctx context.Context, arg CreateCertificateRevocationListParams) (int64, error) { + row := q.db.QueryRowContext(ctx, createCertificateRevocationList, + arg.Name, + arg.Signature, + arg.ThisUpdate, + arg.NextUpdate, + arg.Url, + arg.Raw, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const getCertificateRevocationList = `-- name: GetCertificateRevocationList :one +SELECT id, name, signature, DATETIME(this_update) as this_update, DATETIME(next_update) as next_update, url, raw FROM certificate_revocation_list +WHERE name = ? +` + +type GetCertificateRevocationListRow struct { + ID int64 + Name string + Signature []byte + ThisUpdate interface{} + NextUpdate interface{} + Url sql.NullString + Raw []byte +} + +func (q *Queries) GetCertificateRevocationList(ctx context.Context, name string) (GetCertificateRevocationListRow, error) { + row := q.db.QueryRowContext(ctx, getCertificateRevocationList, name) + var i GetCertificateRevocationListRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Signature, + &i.ThisUpdate, + &i.NextUpdate, + &i.Url, + &i.Raw, + ) + return i, err +} + +const listCertificateRevocationLists = `-- name: ListCertificateRevocationLists :many +SELECT id, name, signature, DATETIME(this_update) as this_update, DATETIME(next_update) as next_update, url, raw FROM certificate_revocation_list +ORDER BY id +` + +type ListCertificateRevocationListsRow struct { + ID int64 + Name string + Signature []byte + ThisUpdate interface{} + NextUpdate interface{} + Url sql.NullString + Raw []byte +} + +func (q *Queries) ListCertificateRevocationLists(ctx context.Context) ([]ListCertificateRevocationListsRow, error) { + rows, err := q.db.QueryContext(ctx, listCertificateRevocationLists) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCertificateRevocationListsRow + for rows.Next() { + var i ListCertificateRevocationListsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Signature, + &i.ThisUpdate, + &i.NextUpdate, + &i.Url, + &i.Raw, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateCertificateRevocationList = `-- name: UpdateCertificateRevocationList :one +UPDATE certificate_revocation_list +set this_update = ?, + next_update = ?, + raw = ? +WHERE name = ? +RETURNING id, name, signature, this_update, next_update, url, raw +` + +type UpdateCertificateRevocationListParams struct { + ThisUpdate time.Time + NextUpdate sql.NullTime + Raw []byte + Name string +} + +func (q *Queries) UpdateCertificateRevocationList(ctx context.Context, arg UpdateCertificateRevocationListParams) (CertificateRevocationList, error) { + row := q.db.QueryRowContext(ctx, updateCertificateRevocationList, + arg.ThisUpdate, + arg.NextUpdate, + arg.Raw, + arg.Name, + ) + var i CertificateRevocationList + err := row.Scan( + &i.ID, + &i.Name, + &i.Signature, + &i.ThisUpdate, + &i.NextUpdate, + &i.Url, + &i.Raw, + ) + return i, err +} diff --git a/internal/adapter/db/queries/db.go b/internal/adapter/db/queries/db.go new file mode 100644 index 0000000..fa78573 --- /dev/null +++ b/internal/adapter/db/queries/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package queries + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/adapter/db/queries/models.go b/internal/adapter/db/queries/models.go new file mode 100644 index 0000000..a5c0d65 --- /dev/null +++ b/internal/adapter/db/queries/models.go @@ -0,0 +1,28 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package queries + +import ( + "database/sql" + "time" +) + +type CertificateRevocationList struct { + ID int64 + Name string + Signature []byte + ThisUpdate time.Time + NextUpdate sql.NullTime + Url sql.NullString + Raw []byte +} + +type RevokedCertificate struct { + ID int64 + Serialnumber string + RevocationDate time.Time + Reason string + RevocationList int64 +} diff --git a/internal/adapter/db/queries/revoked_certificate.sql b/internal/adapter/db/queries/revoked_certificate.sql new file mode 100644 index 0000000..b322363 --- /dev/null +++ b/internal/adapter/db/queries/revoked_certificate.sql @@ -0,0 +1,22 @@ +-- name: CreateRevokedCertificates :exec +INSERT INTO revoked_certificate( + serialnumber, + revocation_date, + reason, + revocation_list +) VALUES ( + ?,?,?,? +) +ON CONFLICT DO NOTHING; + +-- name: GetRevokedCertificatesByRevocationList :many +SELECT id, serialnumber, DATETIME(revocation_date) as revocation_date, reason, revocation_list +FROM revoked_certificate +WHERE revocation_list = ? +ORDER BY revocation_date; + +-- name: GetRevokedCertificate :one +SELECT cert.serialnumber, cert.reason, DATETIME(cert.revocation_date) as revocation_date, crl.name AS revoked_by +FROM revoked_certificate as cert +JOIN certificate_revocation_list AS crl ON crl.id = cert.revocation_list +WHERE serialnumber = ?; diff --git a/internal/adapter/db/queries/revoked_certificate.sql.go b/internal/adapter/db/queries/revoked_certificate.sql.go new file mode 100644 index 0000000..ffd9d3e --- /dev/null +++ b/internal/adapter/db/queries/revoked_certificate.sql.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: revoked_certificate.sql + +package queries + +import ( + "context" + "time" +) + +const createRevokedCertificates = `-- name: CreateRevokedCertificates :exec +INSERT INTO revoked_certificate( + serialnumber, + revocation_date, + reason, + revocation_list +) VALUES ( + ?,?,?,? +) +ON CONFLICT DO NOTHING +` + +type CreateRevokedCertificatesParams struct { + Serialnumber string + RevocationDate time.Time + Reason string + RevocationList int64 +} + +func (q *Queries) CreateRevokedCertificates(ctx context.Context, arg CreateRevokedCertificatesParams) error { + _, err := q.db.ExecContext(ctx, createRevokedCertificates, + arg.Serialnumber, + arg.RevocationDate, + arg.Reason, + arg.RevocationList, + ) + return err +} + +const getRevokedCertificate = `-- name: GetRevokedCertificate :one +SELECT cert.serialnumber, cert.reason, DATETIME(cert.revocation_date) as revocation_date, crl.name AS revoked_by +FROM revoked_certificate as cert +JOIN certificate_revocation_list AS crl ON crl.id = cert.revocation_list +WHERE serialnumber = ? +` + +type GetRevokedCertificateRow struct { + Serialnumber string + Reason string + RevocationDate interface{} + RevokedBy string +} + +func (q *Queries) GetRevokedCertificate(ctx context.Context, serialnumber string) (GetRevokedCertificateRow, error) { + row := q.db.QueryRowContext(ctx, getRevokedCertificate, serialnumber) + var i GetRevokedCertificateRow + err := row.Scan( + &i.Serialnumber, + &i.Reason, + &i.RevocationDate, + &i.RevokedBy, + ) + return i, err +} + +const getRevokedCertificatesByRevocationList = `-- name: GetRevokedCertificatesByRevocationList :many +SELECT id, serialnumber, DATETIME(revocation_date) as revocation_date, reason, revocation_list +FROM revoked_certificate +WHERE revocation_list = ? +ORDER BY revocation_date +` + +type GetRevokedCertificatesByRevocationListRow struct { + ID int64 + Serialnumber string + RevocationDate interface{} + Reason string + RevocationList int64 +} + +func (q *Queries) GetRevokedCertificatesByRevocationList(ctx context.Context, revocationList int64) ([]GetRevokedCertificatesByRevocationListRow, error) { + rows, err := q.db.QueryContext(ctx, getRevokedCertificatesByRevocationList, revocationList) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetRevokedCertificatesByRevocationListRow + for rows.Next() { + var i GetRevokedCertificatesByRevocationListRow + if err := rows.Scan( + &i.ID, + &i.Serialnumber, + &i.RevocationDate, + &i.Reason, + &i.RevocationList, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/adapter/db/revoked_certificate.go b/internal/adapter/db/revoked_certificate.go new file mode 100644 index 0000000..ae0cfeb --- /dev/null +++ b/internal/adapter/db/revoked_certificate.go @@ -0,0 +1,36 @@ +package db + +import ( + "context" + "errors" + "time" + + "github.com/pimg/certguard/pkg/domain/crl" +) + +// Find all revoked certificates in CRL +func (s *LibSqlStorage) FindRevokedCertificates(ctx context.Context, revocationListID int64) ([]*crl.RevokedCertificate, error) { + dbRevCerts, err := s.Queries.GetRevokedCertificatesByRevocationList(ctx, revocationListID) + if err != nil { + return nil, err + } + + revokedCertificates := make([]*crl.RevokedCertificate, len(dbRevCerts)) + + for i, revokedCertificate := range dbRevCerts { + + revocationDate, ok := revokedCertificate.RevocationDate.(time.Time) + if !ok { + return nil, errors.New("invalid revocation date") + } + + revokedCertificates[i] = &crl.RevokedCertificate{ + SerialNumber: revokedCertificate.Serialnumber, + RevocationReason: crl.RevocationReason(revokedCertificate.Reason), + RevocationDate: revocationDate, + RevocationListID: revokedCertificate.RevocationList, + } + } + + return revokedCertificates, nil +} diff --git a/internal/adapter/db/schema/001_init.sql b/internal/adapter/db/schema/001_init.sql new file mode 100644 index 0000000..ebd1eea --- /dev/null +++ b/internal/adapter/db/schema/001_init.sql @@ -0,0 +1,31 @@ +-- +migrate Up +CREATE TABLE IF NOT EXISTS certificate_revocation_list ( + id integer primary key, + name text unique not null, + signature BLOB unique not null, + this_update DATE not null, + next_update DATE, + url text, + raw BLOB +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_certificate_revocation_list_name + ON certificate_revocation_list(name); + +CREATE TABLE IF NOT EXISTS revoked_certificate ( + id integer primary key, + serialnumber text unique not null, + revocation_date DATE not null, + reason text not null, + revocation_list integer not null, + foreign key (revocation_list) references certificate_revocation_list(id) + ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_revoked_certificates_serialnumber + ON revoked_certificate(serialnumber); + +-- +migrate Down +DROP TABLE certificate_revocation_list; + +DROP TABLE revoked_certificate; diff --git a/internal/adapter/db/sqlc.yaml b/internal/adapter/db/sqlc.yaml new file mode 100644 index 0000000..ffaf091 --- /dev/null +++ b/internal/adapter/db/sqlc.yaml @@ -0,0 +1,9 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "queries" + schema: "schema" + gen: + go: + package: "queries" + out: "queries" \ No newline at end of file diff --git a/internal/ports/models/base.go b/internal/ports/models/base.go index ff200af..baf7fdf 100644 --- a/internal/ports/models/base.go +++ b/internal/ports/models/base.go @@ -20,6 +20,7 @@ const ( baseView sessionState = iota inputView listView + importView browseView revokedCertificateView ) @@ -28,7 +29,8 @@ var titles = map[sessionState]string{ baseView: "CRL inspector", inputView: "Download a new CRL by entering it's URL", listView: "Pick an entry from the CRL to inspect", - browseView: "View and select a CRL from the local cache", + importView: "Import an existing CRL, from the file system", + browseView: "Browse all loaded CRL's from storage", revokedCertificateView: "Revoked Certificate", } @@ -39,6 +41,7 @@ type keyMap struct { Download key.Binding Back key.Binding Home key.Binding + Import key.Binding Browse key.Binding Quit key.Binding } @@ -53,7 +56,7 @@ func (k *keyMap) ShortHelp() []key.Binding { // key.Map interface. func (k *keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Download, k.Browse, k.Home}, + {k.Download, k.Import, k.Home}, {k.Back, k.Help, k.Quit}, } } @@ -71,9 +74,13 @@ var keys = keyMap{ key.WithKeys("h"), key.WithHelp("h", "back to the main view"), ), + Import: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "import crl from local import directory"), + ), Browse: key.NewBinding( key.WithKeys("b"), - key.WithHelp("b", "browse local cache"), + key.WithHelp("b", "browse all loaded CRL's from storage"), ), Help: key.NewBinding( key.WithKeys("?"), @@ -86,18 +93,19 @@ var keys = keyMap{ } type BaseModel struct { - title string - state sessionState - prevState sessionState - keys keyMap - help help.Model - styles *styles.Styles - input InputModel - list ListModel - browse BrowseModel - err error - width int - height int + title string + state sessionState + prevState sessionState + keys keyMap + help help.Model + styles *styles.Styles + input InputModel + browse *BrowseModel + list ListModel + importModel ImportModel + err error + width int + height int } func NewBaseModel() BaseModel { @@ -173,9 +181,13 @@ func (m BaseModel) handleStates(msg tea.Msg) (tea.Model, tea.Cmd) { rcm := revokedCertificateModel.(RevokedCertificateModel) m.list.selectedItem = &rcm cmd = append(cmd, revokedCertificateCmd) + case importView: + importModel, importCmd := m.importModel.Update(msg) + m.importModel = importModel.(ImportModel) + cmd = append(cmd, importCmd) case browseView: browseModel, browseCmd := m.browse.Update(msg) - m.browse = browseModel.(BrowseModel) + m.browse = browseModel.(*BrowseModel) cmd = append(cmd, browseCmd) case baseView: switch msg := msg.(type) { @@ -187,11 +199,18 @@ func (m BaseModel) handleStates(msg tea.Msg) (tea.Model, tea.Cmd) { m.input = NewInputModel() return m, m.input.Init() } + if key.Matches(msg, m.keys.Import) { + m.prevState = m.state + m.state = importView + m.title = titles[m.state] + m.importModel = NewImportModel() + return m, m.importModel.Init() + } if key.Matches(msg, m.keys.Browse) { m.prevState = m.state m.state = browseView m.title = titles[m.state] - m.browse = NewBrowseModel() + m.browse = NewBrowseModel(m.height) return m, m.browse.Init() } } @@ -220,22 +239,29 @@ func (m BaseModel) View() string { revokedCertificateDetails := m.list.selectedItem.View() height := strings.Count(revokedCertificateDetails, "\n") + strings.Count(title, "\n") return lipgloss.JoinVertical(lipgloss.Top, title, revokedCertificateDetails) + lipgloss.Place(m.width, m.height-height-1, lipgloss.Left, lipgloss.Bottom, helpMenu) - case browseView: + case importView: title := m.styles.Title.Render(m.title) - listInfo := m.browse.View() - helpMenu := m.help.View(&browseKeys) + listInfo := m.importModel.View() + helpMenu := m.help.View(&listKeys) height := strings.Count(listInfo, "\n") + strings.Count(title, "\n") return lipgloss.JoinVertical(lipgloss.Top, title, listInfo) + lipgloss.Place(m.width, m.height-height-1, lipgloss.Left, lipgloss.Bottom, helpMenu) + case browseView: + title := m.styles.Title.Render(m.title) + table := m.browse.View() + helpMenu := m.help.View(&browseKeys) + height := strings.Count(table, "\n") + strings.Count(title, "\n") + return lipgloss.JoinVertical(lipgloss.Top, title, table) + lipgloss.Place(m.width, m.height-height-1, lipgloss.Left, lipgloss.Bottom, helpMenu) default: title := m.styles.Title.Render(m.title) if m.err != nil { errorMsg = m.err.Error() } - downloadHelp := m.styles.BaseText.Render("Download a CRL file: ") + "Ctrl-d" - browseHelp := m.styles.BaseText.Render("Browse local CRL cache: ") + "Ctrl-b" - mainMenu := fmt.Sprintf("%s\n%s\n", downloadHelp, browseHelp) + downloadHelp := m.styles.BaseText.Render("Download a CRL file: ") + "d" + importHelp := m.styles.BaseText.Render("Import a CRL file from local import directory: ") + "i" + browseHelp := m.styles.BaseText.Render("Browse all loaded CRL's from storage") + "b" + mainMenu := fmt.Sprintf("%s\n%s\n%s", downloadHelp, importHelp, browseHelp) helpMenu := m.help.View(&keys) height := strings.Count(title, "\n") - return lipgloss.JoinVertical(lipgloss.Top, title, errorMsg, mainMenu) + lipgloss.Place(m.width, m.height-height-4, lipgloss.Left, lipgloss.Bottom, helpMenu) + return lipgloss.JoinVertical(lipgloss.Top, title, errorMsg, mainMenu) + lipgloss.Place(m.width, m.height-height-5, lipgloss.Left, lipgloss.Bottom, helpMenu) } } diff --git a/internal/ports/models/base_test.go b/internal/ports/models/base_test.go index 9f461b8..1cdbf0d 100644 --- a/internal/ports/models/base_test.go +++ b/internal/ports/models/base_test.go @@ -6,8 +6,8 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" - "github.com/pimg/certguard/internal/adapter" "github.com/pimg/certguard/internal/ports/models/messages" + "github.com/pimg/certguard/pkg/domain/crl" "github.com/stretchr/testify/assert" ) @@ -43,14 +43,14 @@ func TestSwitchBackToBaseModel(t *testing.T) { } func TestSwitchToBrowseModel(t *testing.T) { - _, err := adapter.NewFileCache() + _, err := crl.NewMockStorage() assert.NoError(t, err) baseModel := NewBaseModel() - updatedModel, _ := baseModel.Update(keyBindingToKeyMsg(keys.Browse)) + updatedModel, _ := baseModel.Update(keyBindingToKeyMsg(keys.Import)) - assert.Equal(t, browseView, updatedModel.(BaseModel).state) - assert.Equal(t, titles[browseView], updatedModel.(BaseModel).title) + assert.Equal(t, importView, updatedModel.(BaseModel).state) + assert.Equal(t, titles[importView], updatedModel.(BaseModel).title) } func TestSwitchToListModel(t *testing.T) { diff --git a/internal/ports/models/browse.go b/internal/ports/models/browse.go index 51b1c96..68721fd 100644 --- a/internal/ports/models/browse.go +++ b/internal/ports/models/browse.go @@ -1,30 +1,32 @@ package models import ( - "errors" + "strconv" "strings" "time" - "github.com/charmbracelet/bubbles/filepicker" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" - "github.com/pimg/certguard/internal/adapter" + "github.com/charmbracelet/lipgloss" "github.com/pimg/certguard/internal/ports/models/commands" + "github.com/pimg/certguard/internal/ports/models/messages" "github.com/pimg/certguard/internal/ports/models/styles" ) // keyMap defines a set of keybindings. To work for help it must satisfy // key.Map. It could also very easily be a map[string]key.Binding. type browseKeyMap struct { - filepicker.KeyMap - Back key.Binding - Quit key.Binding + table.KeyMap + Back key.Binding + Quit key.Binding + Enter key.Binding } // ShortHelp returns keybindings to be shown in the mini help view. It's part // of the key.Map interface. func (k *browseKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Back, k.Quit} + return []key.Binding{k.Back, k.Quit, k.LineUp, k.LineDown, k.Enter} } // FullHelp returns keybindings for the expanded help view. It's part of the @@ -32,13 +34,12 @@ func (k *browseKeyMap) ShortHelp() []key.Binding { func (k *browseKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Back, k.Quit}, - {k.Up, k.Down}, - {k.Open, k.Select}, + {k.LineUp, k.LineDown}, + {k.GotoTop, k.GotoBottom}, + {k.Enter}, } } -type clearErrorMsg struct{} - var browseKeys = browseKeyMap{ Back: key.NewBinding( key.WithKeys("esc"), @@ -48,86 +49,79 @@ var browseKeys = browseKeyMap{ key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit"), ), - KeyMap: filepicker.DefaultKeyMap(), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select row"), + ), + KeyMap: table.DefaultKeyMap(), } type BrowseModel struct { - keys browseKeyMap - styles *styles.Styles - filepicker filepicker.Model - selectedFile string - err error + keys browseKeyMap + table table.Model } -func NewBrowseModel() BrowseModel { - browseStyle := styles.DefaultStyles() - fp := filepicker.New() - fp.AllowedTypes = []string{".crl", ".pem", ".crt"} - fp.ShowPermissions = false - fp.Styles.File = browseStyle.FilePickerFile - fp.Styles.Selected = browseStyle.FilePickerCurrent - fp.Styles.Cursor = browseStyle.FilePickerFile - fp.KeyMap.Back = key.NewBinding(key.WithKeys("h", "backspace", "left"), key.WithHelp("h", "back")) - - fp.CurrentDirectory = adapter.GlobalCache.(*adapter.FileCache).Dir() +func NewBrowseModel(height int) *BrowseModel { + columns := []table.Column{ + {Title: "ID", Width: 2}, + {Title: "Name", Width: 30}, + {Title: "This Update", Width: 11}, + {Title: "Next Update", Width: 11}, + {Title: "Url", Width: 15}, + } - return BrowseModel{ - keys: browseKeys, - styles: styles.DefaultStyles(), - filepicker: fp, + tbl := table.New(table.WithColumns(columns), table.WithFocused(true), table.WithHeight(height-10), table.WithWidth(80)) + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.DefaultStyles().ListComponentTitle). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(styles.DefaultStyles().FilePickerCurrent.GetForeground()). + Background(styles.DefaultStyles().BaseText.GetBackground()). + Bold(false) + tbl.SetStyles(s) + + return &BrowseModel{ + table: tbl, } } -func (m BrowseModel) Init() tea.Cmd { - return m.filepicker.Init() +func (m *BrowseModel) Init() tea.Cmd { + return commands.GetCRLsFromStore } -func (m BrowseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg.(type) { - case clearErrorMsg: - m.err = nil - } - +func (m *BrowseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - m.filepicker, cmd = m.filepicker.Update(msg) - - // Did the user select a file? - if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { - // Get the path of the selected file. - m.selectedFile = path - - cmd := commands.LoadCRL(m.selectedFile) - m.selectedFile = "" - m.filepicker.Path = "" - return m, cmd - } - - // Did the user select a disabled file? - // This is only necessary to display an error to the user. - if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { - // Let's clear the selectedFile and display an error. - m.err = errors.New(path + " is not valid.") - m.selectedFile = "" - return m, tea.Batch(cmd, clearErrorAfter(2*time.Second)) + switch msg := msg.(type) { + case messages.ListCRLsResponseMsg: + rows := make([]table.Row, len(msg.CRLs)) + for i, CRL := range msg.CRLs { + rows[i] = table.Row{ + strconv.Itoa(int(CRL.ID)), + CRL.Name, + CRL.ThisUpdate.Format(time.DateOnly), + CRL.NextUpdate.Format(time.DateOnly), + CRL.URL.String(), + } + } + m.table.SetRows(rows) + case tea.KeyMsg: + switch msg.String() { + case "enter": + //return m, tea.Batch(tea.Printf("You have selected: %s", m.table.SelectedRow()[1])) + cmd := commands.GetRevokedCertificates(m.table.SelectedRow()[0], m.table.SelectedRow()[1], m.table.SelectedRow()[2], m.table.SelectedRow()[3]) + return m, cmd + } } + m.table, cmd = m.table.Update(msg) return m, cmd } -func (m BrowseModel) View() string { +func (m *BrowseModel) View() string { var s strings.Builder - s.WriteString("\n ") - if m.err != nil { - s.WriteString(m.filepicker.Styles.DisabledFile.Render(m.err.Error())) - } else { - s.WriteString("Pick a file:") - } - s.WriteString("\n\n" + m.filepicker.View() + "\n") + s.WriteString("\n\n" + m.table.View() + "\n") return s.String() } - -func clearErrorAfter(t time.Duration) tea.Cmd { - return tea.Tick(t, func(_ time.Time) tea.Msg { - return clearErrorMsg{} - }) -} diff --git a/internal/ports/models/commands/crl_request.go b/internal/ports/models/commands/crl_request.go index 9499a30..700dfcc 100644 --- a/internal/ports/models/commands/crl_request.go +++ b/internal/ports/models/commands/crl_request.go @@ -1,38 +1,38 @@ package commands import ( + "context" "errors" "fmt" "log" "log/slog" + "net/url" + "os" "strings" tea "github.com/charmbracelet/bubbletea" - "github.com/pimg/certguard/internal/adapter" "github.com/pimg/certguard/internal/ports/models/messages" "github.com/pimg/certguard/pkg/crl" + domain_crl "github.com/pimg/certguard/pkg/domain/crl" ) -func GetCRL(requestURL string) tea.Cmd { - slog.Debug("requesting CRL from: " + requestURL) +func GetCRL(url *url.URL) tea.Cmd { + slog.Debug("requesting CRL from: " + url.String()) + ctx := context.Background() return func() tea.Msg { - revocationListURL := strings.TrimSpace(requestURL) + revocationListURL := strings.TrimSpace(url.String()) revocationList, err := crl.FetchRevocationList(revocationListURL) if err != nil { - errorMsg := fmt.Errorf("could not download CRL with provided URL: %s", requestURL) + errorMsg := fmt.Errorf("could not download CRL with provided URL: %s", url.String()) log.Print(errorMsg.Error()) return messages.ErrorMsg{ Err: errors.Join(errorMsg, err), } } - filename := revocationListURL[strings.LastIndex(revocationListURL, "/"):] - - err = adapter.GlobalCache.Write(filename, revocationList.Raw) + err = domain_crl.Process(ctx, url, revocationList, domain_crl.GlobalStorage) if err != nil { - errorMsg := fmt.Errorf("cannot write the CRL to the cache: %s", filename) - log.Print(errorMsg.Error()) - return messages.ErrorMsg{Err: errors.Join(err, errorMsg)} + return nil } return messages.CRLResponseMsg{ @@ -42,8 +42,9 @@ func GetCRL(requestURL string) tea.Cmd { } func LoadCRL(path string) tea.Cmd { + ctx := context.Background() return func() tea.Msg { - rawCRL, err := adapter.GlobalCache.Read(path) + rawCRL, err := os.ReadFile(path) if err != nil { errorMsg := fmt.Errorf("could not load CRL from cache location: %s", path) log.Print(errorMsg.Error()) @@ -59,8 +60,26 @@ func LoadCRL(path string) tea.Cmd { Err: errors.Join(errors.New("could not parse CRL"), err), } } + + err = domain_crl.Process(ctx, nil, revocationList, domain_crl.GlobalStorage) + if err != nil { + return nil + } + return messages.CRLResponseMsg{ RevocationList: revocationList, } } } + +func GetCRLsFromStore() tea.Msg { + ctx := context.Background() + cRLs, err := domain_crl.GlobalStorage.Repository.List(ctx) + if err != nil { + return nil + } + + return messages.ListCRLsResponseMsg{ + CRLs: cRLs, + } +} diff --git a/internal/ports/models/commands/revoked_certificate.go b/internal/ports/models/commands/revoked_certificate.go new file mode 100644 index 0000000..6669348 --- /dev/null +++ b/internal/ports/models/commands/revoked_certificate.go @@ -0,0 +1,111 @@ +package commands + +import ( + "context" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "fmt" + "log/slog" + "math/big" + "strconv" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/pimg/certguard/internal/ports/models/messages" + "github.com/pimg/certguard/pkg/domain/crl" +) + +func GetRevokedCertificates(aID, aCN, aThisUpdate, aNextUpdate string) tea.Cmd { + slog.Debug(fmt.Sprintf("getting revoked certificate from storage with ID: %s", aID)) + ctx := context.Background() + return func() tea.Msg { + ID, err := strconv.ParseInt(aID, 10, 64) + if err != nil { + slog.Debug(fmt.Sprintf("%s is not a valid ID, %v", aID, err)) + return messages.ErrorMsg{ + Err: errors.Join(errors.New("could not parse ID"), err), + } + } + + thisUpdate, err := time.Parse(time.DateOnly, aThisUpdate) + if err != nil { + slog.Debug(fmt.Sprintf("%s is not a valid time, %v", aThisUpdate, err)) + return messages.ErrorMsg{ + Err: errors.Join(errors.New("could not parse time of ThisUpdate"), err), + } + } + + nextUpdate, err := time.Parse(time.DateOnly, aNextUpdate) + if err != nil { + slog.Debug(fmt.Sprintf("%s is not a valid time, %v", aNextUpdate, err)) + return messages.ErrorMsg{ + Err: errors.Join(errors.New("could not parse time of NextUpdate"), err), + } + } + + certificates, err := crl.GlobalStorage.Repository.FindRevokedCertificates(ctx, ID) + if err != nil { + slog.Debug(fmt.Sprintf("could not retrieve revoked certificates: %v", err)) + return messages.ErrorMsg{ + Err: errors.Join(errors.New("could not parse CRL"), err), + } + } + + revokedCertificates := make([]x509.RevocationListEntry, len(certificates)) + for i, cert := range certificates { + serialNumber := new(big.Int) + serialNumber, ok := serialNumber.SetString(cert.SerialNumber, 10) + if !ok { + slog.Debug(fmt.Sprintf("could not parse serialNumber: %v", cert)) + return messages.ErrorMsg{ + Err: errors.Join(errors.New("could not parse serialNumber"), err), + } + } + + reasonCode := convertReasonCode(cert.RevocationReason) + + revokedCertificates[i] = x509.RevocationListEntry{ + SerialNumber: serialNumber, + RevocationTime: cert.RevocationDate, + ReasonCode: reasonCode, + } + } + + return messages.CRLResponseMsg{ + RevocationList: &x509.RevocationList{ + Issuer: pkix.Name{CommonName: aCN}, + ThisUpdate: thisUpdate, + NextUpdate: nextUpdate, + RevokedCertificateEntries: revokedCertificates, + }, + } + } +} + +func convertReasonCode(reason crl.RevocationReason) int { + switch reason { + case crl.RevocationReasonUnspecified: + return 0 + case crl.RevocationReasonKeyCompromise: + return 1 + case crl.RevocationReasonCACompromise: + return 2 + case crl.RevocationReasonAffiliationChanged: + return 3 + case crl.RevocationReasonSuperseded: + return 4 + case crl.RevocationReasonCessationOfOperation: + return 5 + case crl.RevocationReasonCertificateHold: + return 6 + case crl.RevocationReasonRemoveFromCRL: + return 8 + case crl.RevocationReasonPriviledgeWithdrawn: + return 9 + case crl.RevocationReasonAACompromise: + return 10 + default: + return 0 + } +} diff --git a/internal/ports/models/import.go b/internal/ports/models/import.go new file mode 100644 index 0000000..84fb475 --- /dev/null +++ b/internal/ports/models/import.go @@ -0,0 +1,133 @@ +package models + +import ( + "errors" + "strings" + "time" + + "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/pimg/certguard/internal/ports/models/commands" + "github.com/pimg/certguard/internal/ports/models/styles" + "github.com/pimg/certguard/pkg/domain/crl" +) + +// keyMap defines a set of keybindings. To work for help it must satisfy +// key.Map. It could also very easily be a map[string]key.Binding. +type importKeyMap struct { + filepicker.KeyMap + Back key.Binding + Quit key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k *importKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Back, k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k *importKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Back, k.Quit}, + {k.Up, k.Down}, + {k.Open, k.Select}, + } +} + +type clearErrorMsg struct{} + +var importKeys = importKeyMap{ + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back to main view"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + KeyMap: filepicker.DefaultKeyMap(), +} + +type ImportModel struct { + keys importKeyMap + styles *styles.Styles + filepicker filepicker.Model + selectedFile string + err error +} + +func NewImportModel() ImportModel { + browseStyle := styles.DefaultStyles() + fp := filepicker.New() + fp.AllowedTypes = []string{".crl", ".pem", ".crt"} + fp.ShowPermissions = false + fp.Styles.File = browseStyle.FilePickerFile + fp.Styles.Selected = browseStyle.FilePickerCurrent + fp.Styles.Cursor = browseStyle.FilePickerFile + fp.KeyMap.Back = key.NewBinding(key.WithKeys("h", "backspace", "left"), key.WithHelp("h", "back")) + + fp.CurrentDirectory = crl.GlobalStorage.ImportDir() + + return ImportModel{ + keys: importKeys, + styles: styles.DefaultStyles(), + filepicker: fp, + } +} + +func (m ImportModel) Init() tea.Cmd { + return m.filepicker.Init() +} + +func (m ImportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case clearErrorMsg: + m.err = nil + } + + var cmd tea.Cmd + m.filepicker, cmd = m.filepicker.Update(msg) + + // Did the user select a file? + if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { + // Get the path of the selected file. + m.selectedFile = path + + cmd := commands.LoadCRL(m.selectedFile) + m.selectedFile = "" + m.filepicker.Path = "" + return m, cmd + } + + // Did the user select a disabled file? + // This is only necessary to display an error to the user. + if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { + // Let's clear the selectedFile and display an error. + m.err = errors.New(path + " is not valid.") + m.selectedFile = "" + return m, tea.Batch(cmd, clearErrorAfter(2*time.Second)) + } + + return m, cmd +} + +func (m ImportModel) View() string { + var s strings.Builder + s.WriteString("\n ") + if m.err != nil { + s.WriteString(m.filepicker.Styles.DisabledFile.Render(m.err.Error())) + } else { + s.WriteString("Pick a file:") + } + s.WriteString("\n\n" + m.filepicker.View() + "\n") + return s.String() +} + +func clearErrorAfter(t time.Duration) tea.Cmd { + return tea.Tick(t, func(_ time.Time) tea.Msg { + return clearErrorMsg{} + }) +} diff --git a/internal/ports/models/input.go b/internal/ports/models/input.go index 13ae31c..4263bb9 100644 --- a/internal/ports/models/input.go +++ b/internal/ports/models/input.go @@ -82,12 +82,12 @@ func (i InputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, i.keys.Enter): confirmedInput := i.textinput.Value() i.textinput.Reset() - err := uri.ValidateURI(confirmedInput) + url, err := uri.ValidateURI(confirmedInput) if err != nil { i.textinput.Err = err return i, nil } - cmd = commands.GetCRL(confirmedInput) + cmd = commands.GetCRL(url) return i, cmd } diff --git a/internal/ports/models/list.go b/internal/ports/models/list.go index 986de90..fd233de 100644 --- a/internal/ports/models/list.go +++ b/internal/ports/models/list.go @@ -60,6 +60,7 @@ func (i item) FilterValue() string { return i.serialnumber } const TOP_INFO_HEIGHT = 12 +// TODO refactor model to not use x509.RevocationList but a domain model or refactor commands/revoked_certificate to return a x.509 revoked certificate type ListModel struct { keys listKeyMap styles *styles.Styles diff --git a/internal/ports/models/messages/messages.go b/internal/ports/models/messages/messages.go index f66b5cd..be36801 100644 --- a/internal/ports/models/messages/messages.go +++ b/internal/ports/models/messages/messages.go @@ -1,6 +1,10 @@ package messages -import "crypto/x509" +import ( + "crypto/x509" + + "github.com/pimg/certguard/pkg/domain/crl" +) type CRLResponseMsg struct { RevocationList *x509.RevocationList @@ -9,3 +13,11 @@ type CRLResponseMsg struct { type ErrorMsg struct { Err error } + +type ListCRLsResponseMsg struct { + CRLs []*crl.CertificateRevocationList +} + +type RevokedCertificatesMsg struct { + RevokedCertificates []x509.RevocationListEntry +} diff --git a/internal/ports/models/revoked_certificate.go b/internal/ports/models/revoked_certificate.go index 9e9b0dc..5f641d8 100644 --- a/internal/ports/models/revoked_certificate.go +++ b/internal/ports/models/revoked_certificate.go @@ -7,7 +7,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/pimg/certguard/internal/ports/models/styles" - "github.com/pimg/certguard/pkg/crl" + "github.com/pimg/certguard/pkg/domain/crl" ) type revokedCertKeyMap struct { diff --git a/internal/ports/models/styles/styles.go b/internal/ports/models/styles/styles.go index 7f27fca..a7f6443 100644 --- a/internal/ports/models/styles/styles.go +++ b/internal/ports/models/styles/styles.go @@ -33,7 +33,7 @@ func DefaultStyles() *Styles { Text: lipgloss.NewStyle().Foreground(lipgloss.Color("#B8BB26")).Padding(1).Width(80), RevokedCertificateText: lipgloss.NewStyle().Foreground(lipgloss.Color("#B8BB26")).PaddingTop(1).PaddingLeft(1).Width(20), CRLText: lipgloss.NewStyle().Foreground(lipgloss.Color("#B8BB26")).PaddingTop(1).PaddingLeft(1).Width(25), - BaseText: lipgloss.NewStyle().Foreground(lipgloss.Color("#B8BB26")).PaddingLeft(1).Width(25), + BaseText: lipgloss.NewStyle().Foreground(lipgloss.Color("#B8BB26")).PaddingLeft(1).Width(48), FilePickerFile: lipgloss.NewStyle().Foreground(lipgloss.Color("#83A598")), FilePickerCurrent: lipgloss.NewStyle().Foreground(lipgloss.Color("#B8BB26")), ListComponentTitle: "#83A598", diff --git a/pkg/domain/crl/certificate_revocation_list.go b/pkg/domain/crl/certificate_revocation_list.go new file mode 100644 index 0000000..b77f3a7 --- /dev/null +++ b/pkg/domain/crl/certificate_revocation_list.go @@ -0,0 +1,67 @@ +package crl + +import ( + "context" + "crypto/x509" + "errors" + "net/url" + "time" +) + +type CertificateRevocationList struct { + ID int64 + Name string + Signature []byte + ThisUpdate time.Time + NextUpdate time.Time + Raw []byte + URL *url.URL +} + +// TODO changeto map[int]RevocationReason +var RevocationReasons = map[int]string{ + 0: "unspecified", + 1: "keyCompromise", + 2: "cACompromise", + 3: "affiliationChanged", + 4: "superseded", + 5: "cessationOfOperation", + 6: "certificateHold", + 8: "removeFromCRL", + 9: "priviledgeWithdrawn", + 10: "aACompromise", +} + +func FromCRL(crl *x509.RevocationList, URL *url.URL) (*CertificateRevocationList, error) { + return &CertificateRevocationList{ + Name: crl.Issuer.CommonName, + Signature: crl.Signature, + ThisUpdate: crl.ThisUpdate, + NextUpdate: crl.NextUpdate, + URL: URL, + Raw: crl.Raw, + }, nil +} + +func Process(ctx context.Context, URL *url.URL, crl *x509.RevocationList, store *Storage) error { + parsed, err := FromCRL(crl, URL) + if err != nil { + return err + } + + id, err := store.Repository.Save(ctx, parsed) + if err != nil { + return err + } + + storedRevokedCertificates, err := store.Repository.SaveRevokedCertificates(ctx, id, crl.RevokedCertificateEntries) + if err != nil { + return err + } + + if storedRevokedCertificates < len(crl.RevokedCertificateEntries) { + return errors.New("no revoked certificates saved") + } + + return nil +} diff --git a/pkg/domain/crl/revoked_certificate.go b/pkg/domain/crl/revoked_certificate.go new file mode 100644 index 0000000..5b955c9 --- /dev/null +++ b/pkg/domain/crl/revoked_certificate.go @@ -0,0 +1,25 @@ +package crl + +import "time" + +type RevokedCertificate struct { + SerialNumber string + RevocationReason RevocationReason + RevocationDate time.Time + RevocationListID int64 +} + +type RevocationReason string + +const ( + RevocationReasonUnspecified RevocationReason = "unspecified" + RevocationReasonKeyCompromise RevocationReason = "keyCompromise" + RevocationReasonCACompromise RevocationReason = "cACompromise" + RevocationReasonAffiliationChanged RevocationReason = "affiliationChanged" + RevocationReasonSuperseded RevocationReason = "superseded" + RevocationReasonCessationOfOperation RevocationReason = "cessationOfOperation" + RevocationReasonCertificateHold RevocationReason = "certificateHold" + RevocationReasonRemoveFromCRL RevocationReason = "removeFromCRL" + RevocationReasonPriviledgeWithdrawn RevocationReason = "priviledgeWithdrawn" + RevocationReasonAACompromise RevocationReason = "aACompromise" +) diff --git a/pkg/domain/crl/storage.go b/pkg/domain/crl/storage.go new file mode 100644 index 0000000..2517dff --- /dev/null +++ b/pkg/domain/crl/storage.go @@ -0,0 +1,39 @@ +package crl + +import ( + "context" + "crypto/x509" + "path/filepath" +) + +type Repository interface { + Save(ctx context.Context, crl *CertificateRevocationList) (int64, error) + Find(ctx context.Context, name string) (*CertificateRevocationList, error) + List(ctx context.Context) ([]*CertificateRevocationList, error) + SaveRevokedCertificates(ctx context.Context, revocationListId int64, revokedCertificates []x509.RevocationListEntry) (int, error) + FindRevokedCertificates(ctx context.Context, revocationListId int64) ([]*RevokedCertificate, error) +} + +type Storage struct { + Repository Repository + baseDir string +} + +func NewStorage(repository Repository, baseDir string) (*Storage, error) { + storage := &Storage{ + Repository: repository, + baseDir: baseDir, + } + GlobalStorage = storage + return storage, nil +} + +var GlobalStorage *Storage + +func (s *Storage) CacheDir() string { + return s.baseDir +} + +func (s *Storage) ImportDir() string { + return filepath.Join(s.baseDir, "/import") +} diff --git a/pkg/domain/crl/storage_mock.go b/pkg/domain/crl/storage_mock.go new file mode 100644 index 0000000..213d703 --- /dev/null +++ b/pkg/domain/crl/storage_mock.go @@ -0,0 +1,32 @@ +package crl + +import ( + "context" + "crypto/x509" +) + +type MockRepository struct{} + +func (r *MockRepository) List(_ context.Context) ([]*CertificateRevocationList, error) { + return []*CertificateRevocationList{}, nil +} + +func (r *MockRepository) Save(_ context.Context, _ *CertificateRevocationList) (int64, error) { + return 0, nil +} + +func (r *MockRepository) Find(_ context.Context, _ string) (*CertificateRevocationList, error) { + return &CertificateRevocationList{}, nil +} + +func (r *MockRepository) SaveRevokedCertificates(_ context.Context, _ int64, _ []x509.RevocationListEntry) (int, error) { + return 0, nil +} + +func (r *MockRepository) FindRevokedCertificates(_ context.Context, _ int64) ([]*RevokedCertificate, error) { + return []*RevokedCertificate{}, nil +} + +func NewMockStorage() (*Storage, error) { + return NewStorage(&MockRepository{}, "test") +} diff --git a/pkg/uri/validator.go b/pkg/uri/validator.go index f5db3b1..f63b2f7 100644 --- a/pkg/uri/validator.go +++ b/pkg/uri/validator.go @@ -5,11 +5,11 @@ import ( "net/url" ) -func ValidateURI(rawURI string) error { - _, err := url.ParseRequestURI(rawURI) +func ValidateURI(rawURI string) (*url.URL, error) { + url, err := url.ParseRequestURI(rawURI) if err != nil { - return fmt.Errorf("URI must start with either: 'http://', 'https://' or 'file://' the provided string: %s is not a valid URI: %s", rawURI, err) + return nil, fmt.Errorf("URI must start with either: 'http://', 'https://' or 'file://' the provided string: %s is not a valid URI: %s", rawURI, err) } - return nil + return url, nil } diff --git a/pkg/uri/validator_test.go b/pkg/uri/validator_test.go index 4622799..5685fc0 100644 --- a/pkg/uri/validator_test.go +++ b/pkg/uri/validator_test.go @@ -35,7 +35,7 @@ func TestURIValidator(t *testing.T) { t.Run(tc.input, func(t *testing.T) { t.Parallel() - err := ValidateURI(tc.input) + _, err := ValidateURI(tc.input) if tc.wantErr { assert.NotNil(t, err) diff --git a/states.puml b/states.puml index 54bf0aa..c50ba78 100644 --- a/states.puml +++ b/states.puml @@ -2,6 +2,10 @@ [*] --> Base Base --> Download: d Base --> BrowseCache: b +Base --> Import: i +Import --> Base: back, home +Import --> List: