diff --git a/go.mod b/go.mod index d16bdca..c7a684f 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,8 @@ require ( github.com/gotd/td v0.128.0 github.com/redis/go-redis/v9 v9.11.0 github.com/sirupsen/logrus v1.9.3 - github.com/stretchr/testify v1.10.0 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b + github.com/stretchr/testify v1.11.1 + github.com/vmihailenco/msgpack/v5 v5.4.1 golang.org/x/net v0.42.0 golang.org/x/text v0.27.0 gorm.io/driver/sqlite v1.6.0 @@ -19,6 +19,33 @@ require ( modernc.org/sqlite v1.38.2 ) +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) + require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -29,6 +56,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect + github.com/gin-gonic/gin v1.11.0 github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/jx v1.1.0 // indirect github.com/go-faster/xor v1.0.0 // indirect @@ -54,9 +82,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.40.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sys v0.35.0 // indirect golang.org/x/tools v0.35.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 853b352..0c8a39e 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,16 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -21,8 +27,14 @@ 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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-faster/jx v1.1.0 h1:ZsW3wD+snOdmTDy9eIVgQdjUpXRRV4rqW8NS3t+20bg= @@ -36,8 +48,21 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -54,24 +79,40 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogen-go/ogen v1.12.0 h1:JMkn957i9/IPaSehqpblviy6Uao3eqQ+eVKUn4LM9pg= github.com/ogen-go/ogen v1.12.0/go.mod h1:RL25amedfhq5xKTUuPBPn6nhYU59CWaVWYJ8YIjNHs0= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -83,9 +124,23 @@ github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -100,10 +155,14 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= @@ -117,12 +176,14 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/yaencoding/yaencoding.go b/yaencoding/yaencoding.go new file mode 100644 index 0000000..25f9908 --- /dev/null +++ b/yaencoding/yaencoding.go @@ -0,0 +1,161 @@ +// Package yaencoding provides helpers for encoding and decoding data +// using Gob or MessagePack formats with Base64 string representation. +// It simplifies safe transmission of Go structures through text mediums (e.g., JSON, HTTP). +// +// Each encode/decode returns yaerrors.Error to unify structured error handling +// in backend systems that use GoYaCodeDevUtils. +// +// Supported formats: +// - Gob (native Go binary serialization) +// - MessagePack (efficient binary encoding similar to Protobuf) +// +// Example usage: +// +// type User struct { +// ID int +// Name string +// } +// +// // GOB Example +// user := User{ID: 1, Name: "Alice"} +// +// encoded, err := yaencoding.EncodeGob(user) +// if err != nil { +// log.Fatalf("encode failed: %v", err) +// } +// +// decoded, err := yaencoding.DecodeGob[User](encoded) +// if err != nil { +// log.Fatalf("decode failed: %v", err) +// } +// +// fmt.Println(decoded.Name) // Output: Alice +// +// // MessagePack Example +// msgpackStr, err := yaencoding.EncodeMessagePack(user) +// if err != nil { +// log.Fatalf("encode failed: %v", err) +// } +// +// mpDecoded, err := yaencoding.DecodeMessagePack[User](msgpackStr) +// if err != nil { +// log.Fatalf("decode failed: %v", err) +// } +// +// fmt.Println(mpDecoded.ID) // Output: 1 +package yaencoding + +import ( + "bytes" + "encoding/base64" + "encoding/gob" + "fmt" + "net/http" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/vmihailenco/msgpack/v5" +) + +// EncodeGob serializes the given value `v` using the Gob encoder, +// then base64-encodes the binary data into a string. +// +// Returns the base64 string or a wrapped yaerrors.Error on failure. +// +// Example: +// +// s := MyStruct{ID: 5, Name: "Ya Code"} +// str, err := yaencoding.EncodeGob(s) +func EncodeGob(v any) ([]byte, yaerrors.Error) { + var buf bytes.Buffer + + enc := gob.NewEncoder(&buf) + if err := enc.Encode(v); err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[ENCODING] failed to encode `%T` using gob", v), + ) + } + + return buf.Bytes(), nil +} + +// DecodeGob decodes a base64 string that represents Gob-encoded data +// back into a Go structure of type T. +// +// Example: +// +// out, err := yaencoding.DecodeGob[MyStruct](encoded) +func DecodeGob[T any](data []byte) (*T, yaerrors.Error) { + var v T + + dec := gob.NewDecoder(bytes.NewReader(data)) + if err := dec.Decode(&v); err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[ENCODING] failed to decode gob to `%T`", v), + ) + } + + return &v, nil +} + +// EncodeMessagePack serializes `value` using the MessagePack format, +// then base64-encodes it for text-safe transport. +// +// Example: +// +// str, err := yaencoding.EncodeMessagePack(myStruct) +func EncodeMessagePack(value any) ([]byte, yaerrors.Error) { + bytes, err := msgpack.Marshal(value) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[ENCODING] failed to marshal %T using message pack format", value), + ) + } + + return bytes, nil +} + +// DecodeMessagePack decodes a Base64 string containing MessagePack data +// into a Go structure of type T. +// +// Example: +// +// val, err := yaencoding.DecodeMessagePack[User](encoded) +func DecodeMessagePack[T any](bytes []byte) (*T, yaerrors.Error) { + var res T + + if err := msgpack.Unmarshal(bytes, &res); err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("[ENCODING] failed to marshal %T as message pack format", bytes), + ) + } + + return &res, nil +} + +// ToString converts a byte slice into a base64 string. +// Useful for manual conversions. +func ToString(data []byte) string { + return base64.StdEncoding.EncodeToString(data) +} + +// ToBytes decodes a base64 string into bytes. +func ToBytes(data string) ([]byte, yaerrors.Error) { + bytes, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[ENCODING] failed to decode string to bytes", + ) + } + + return bytes, nil +} diff --git a/yaencoding/yaencoding_test.go b/yaencoding/yaencoding_test.go new file mode 100644 index 0000000..de75b98 --- /dev/null +++ b/yaencoding/yaencoding_test.go @@ -0,0 +1,99 @@ +package yaencoding_test + +import ( + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaencoding" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type sample struct { + ID int + Name string + Tags []string + Meta map[string]string + Bytes []byte +} + +func TestGobEncoding_Flow(t *testing.T) { + t.Run("Full Round Trip", func(t *testing.T) { + in := sample{ + ID: 7, + Name: "RZK", + Tags: []string{"a", "b", "c"}, + Meta: map[string]string{"k1": "v1", "k2": "v2"}, + Bytes: []byte{0, 1, 2, 250, 251, 252}, + } + + b64, err := yaencoding.EncodeGob(in) + require.NoError(t, err, "encode failed") + + out, yaerr := yaencoding.DecodeGob[sample](b64) + require.Nil(t, yaerr, "decode failed") + require.NotNil(t, out, "decoded value is nil") + + assert.Equal(t, in, *out, "mismatch after round-trip") + }) + + t.Run("Invalid Base64 Returns Error", func(t *testing.T) { + out, err := yaencoding.DecodeGob[sample]([]byte("!!!INVALID!!!")) + require.Nil(t, out) + require.NotNil(t, err) + }) + + t.Run("Invalid Gob Data Returns Error", func(t *testing.T) { + out, err := yaencoding.DecodeGob[sample]([]byte("not-gob-data")) + require.Nil(t, out) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to decode gob") + }) + + t.Run("Utility ToString-ToBytes Round Trip", func(t *testing.T) { + data := []byte{1, 2, 3, 4, 5} + str := yaencoding.ToString(data) + res, err := yaencoding.ToBytes(str) + require.Nil(t, err) + assert.Equal(t, data, res) + }) + + t.Run("Utility ToBytes Invalid Input", func(t *testing.T) { + res, err := yaencoding.ToBytes("!!bad!!") + require.Nil(t, res) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to decode string to bytes") + }) +} + +func TestMessagePackEncoding_Flow(t *testing.T) { + t.Run("Full Encode/Decode Round Trip", func(t *testing.T) { + in := sample{ + ID: 42, + Name: "YaCode", + Tags: []string{"x", "y"}, + Meta: map[string]string{"foo": "bar"}, + Bytes: []byte{1, 2, 3}, + } + + bytes, err := yaencoding.EncodeMessagePack(in) + require.NoError(t, err, "encode failed") + + out, yaerr := yaencoding.DecodeMessagePack[sample](bytes) + require.Nil(t, yaerr, "decode failed") + require.NotNil(t, out) + assert.Equal(t, in, *out) + }) + + t.Run("Invalid Base64 Returns Error", func(t *testing.T) { + out, err := yaencoding.DecodeMessagePack[sample]([]byte("!invalid-base64")) + require.Nil(t, out) + require.NotNil(t, err) + }) + + t.Run("Invalid MessagePack Data Returns Error", func(t *testing.T) { + out, err := yaencoding.DecodeMessagePack[sample]([]byte("not-msgpack-data")) + require.Nil(t, out) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to marshal") + }) +} diff --git a/yaginmiddleware/rsa_secure.go b/yaginmiddleware/rsa_secure.go new file mode 100644 index 0000000..cb6afa2 --- /dev/null +++ b/yaginmiddleware/rsa_secure.go @@ -0,0 +1,319 @@ +package yaginmiddleware + +import ( + "bytes" + "crypto/rsa" + "net/http" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaencoding" + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yagzip" + "github.com/YaCodeDev/GoYaCodeDevUtils/yarsa" + "github.com/gin-gonic/gin" +) + +// RSASecureHeader is a generic Gin middleware that enables transparent, +// type-safe encryption and decryption of structured data in HTTP headers +// using RSA-OAEP + GZIP + MessagePack. +// +// It provides methods to encode/decode any struct `T` into a secure, +// base64-encoded header value, and a middleware handler (`Handle`) that +// automatically decrypts incoming headers and injects the resulting struct +// into Gin’s request context. +// +// Pipeline: +// +// struct -> MessagePack -> gzip -> RSA encrypt -> base64 +// base64 -> RSA decrypt -> gunzip -> MessagePack -> struct +// +// Example: +// +// type Payload struct { +// ID uint16 +// Text string +// } +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// header := yaginmiddleware.NewEncodeRSA[Payload]("X-Data", "payload", key, true) +// +// in := Payload{ID: 7, Text: "Hello"} +// enc, _ := header.Encode(in) +// +// _, out, _ := header.Decode(enc) +// fmt.Println(out.Text) // "Hello" +// +// In a Gin app: +// +// r := gin.New() +// r.Use(header.Handle) +// r.GET("/ping", func(c *gin.Context) { +// v, _ := c.Get("payload") +// fmt.Println(v.(*Payload)) +// }) +type RSASecureHeader[T any] struct { + RSA *rsa.PrivateKey + HeaderName string + ContextKey string + ContextAbort bool + compressionLevel int +} + +// NewEncodeRSA constructs a new RSA-secure header middleware for a specific type `T`. +// +// Parameters: +// - headerName: name of the HTTP header carrying the encrypted data +// - contextKey: key under which decoded data will be stored in Gin context +// - rsa: RSA private key (its public key used for encryption) +// - contextAbort: whether to abort the request on decode error +// - compressionLevel: `yagzip` commpression level +// +// Example: +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// header := yaginmiddleware.NewEncodeRSA[MyType]("X-Enc", "payload", key, true) +func NewEncodeRSAWithCompressionLevel[T any]( + headerName string, + contextKey string, + rsa *rsa.PrivateKey, + contextAbort bool, + compressionLevel int, +) *RSASecureHeader[T] { + return &RSASecureHeader[T]{ + RSA: rsa, + ContextKey: contextKey, + HeaderName: headerName, + ContextAbort: contextAbort, + compressionLevel: compressionLevel, + } +} + +// NewEncodeRSA constructs a new RSA-secure header middleware for a specific type `T`. +// +// Parameters: +// - headerName: name of the HTTP header carrying the encrypted data +// - contextKey: key under which decoded data will be stored in Gin context +// - rsa: RSA private key (its public key used for encryption) +// - contextAbort: whether to abort the request on decode error +// +// Example: +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// header := yaginmiddleware.NewEncodeRSA[MyType]("X-Enc", "payload", key, true) +func NewEncodeRSA[T any]( + headerName string, + contextKey string, + rsa *rsa.PrivateKey, + contextAbort bool, +) *RSASecureHeader[T] { + return &RSASecureHeader[T]{ + RSA: rsa, + ContextKey: contextKey, + HeaderName: headerName, + ContextAbort: contextAbort, + compressionLevel: yagzip.DefaultCompression, + } +} + +// Encode serializes and encrypts the provided data into a base64-encoded string. +// +// The process includes: +// 1. MessagePack encoding +// 2. GZIP compression +// 3. RSA encryption (public key) +// 4. Base64 encoding +// +// Returns the encoded header string or a `yaerrors.Error`. +// +// Example: +// +// type Payload struct { +// Name string +// } +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// header := yaginmiddleware.NewEncodeRSA[Payload]("X-Enc", "payload", key, true) +// +// in := Payload{Name: "RZK"} +// enc, _ := header.Encode(in) +// fmt.Println(enc) // eyJ... (long base64) +func (h *RSASecureHeader[T]) Encode(data T) (string, yaerrors.Error) { + bytes, err := yaencoding.EncodeMessagePack(data) + if err != nil { + return "", err.Wrap("[RSA HEADER] failed to encode data to bytes") + } + + zip, err := yagzip.NewGzipWithLevel(h.compressionLevel).Zip(bytes) + if err != nil { + return "", err.Wrap("[RSA HEADER] failed to zip bytes") + } + + rsa, err := yarsa.Encrypt(zip, &h.RSA.PublicKey) + if err != nil { + return "", err.Wrap("[RSA HEADER] failed to encrypt zipped") + } + + return yaencoding.ToString(rsa), nil +} + +// emptySymbol is an invisible Unicode character used internally as a separator +// between the optional plaintext “source” prefix and the binary MessagePack data. +// +// It helps `EncodeWithSrc` and `Decode` distinguish readable prefix text +// from encoded payload bytes. +const emptySymbol = "ᅠ" + +// EncodeWithSrc behaves like Encode but also prepends a plaintext “source” string +// before the encrypted MessagePack bytes, separated by an invisible rune (ᅠ). +// +// This allows embedding a readable prefix (e.g., client ID, version, signature) +// that survives decryption and can be retrieved alongside the struct. +// +// Example: +// +// type Payload struct { +// ID uint16 +// } +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// header := yaginmiddleware.NewEncodeRSA[Payload]("X-Enc", "payload", key, true) +// +// in := Payload{ID: 10} +// enc, _ := header.EncodeWithSrc("ClientA", in) +// fmt.Println(enc) // base64 ciphertext +func (h *RSASecureHeader[T]) EncodeWithSrc(src string, data T) (string, yaerrors.Error) { + bytes, err := yaencoding.EncodeMessagePack(data) + if err != nil { + return "", err.Wrap("[RSA HEADER] failed to encode data to bytes") + } + + bytes = append([]byte(src), append([]byte(emptySymbol), bytes...)...) + + zip, err := yagzip.NewGzipWithLevel(h.compressionLevel).Zip(bytes) + if err != nil { + return "", err.Wrap("[RSA HEADER] failed to zip bytes") + } + + rsa, err := yarsa.Encrypt(zip, &h.RSA.PublicKey) + if err != nil { + return "", err.Wrap("[RSA HEADER] failed to encrypt zipped") + } + + return yaencoding.ToString(rsa), nil +} + +// Decode reverses the Encode / EncodeWithSrc process. +// +// It expects a base64-encoded ciphertext, decrypts it using the private key, +// decompresses, and decodes the underlying struct. +// +// Returns: +// - optional prefix string (if EncodeWithSrc was used, else empty) +// - pointer to decoded struct +// - yaerrors.Error if failure occurred +// +// Example: +// +// type Payload struct { Name string } +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// +// header := yaginmiddleware.NewEncodeRSA[Payload]("X-Enc", "payload", key, true) +// +// in := Payload{Name: "Test"} +// enc, _ := header.Encode(in) +// +// src, out, _ := header.Decode(enc) +// fmt.Println(src) // "" +// fmt.Println(out.Name) // "Test" +func (h *RSASecureHeader[T]) Decode(data string) (string, *T, yaerrors.Error) { + rawData, err := yaencoding.ToBytes(data) + if err != nil { + return "", nil, err.Wrap("[RSA HEADER] failed to decode string to bytes") + } + + if len(rawData)%h.RSA.Size() != 0 { + return "", nil, yaerrors.FromString( + http.StatusInternalServerError, + "[RSA HEADER] bad block string size", + ) + } + + zipped, err := yarsa.Decrypt(rawData, h.RSA) + if err != nil { + return "", nil, err.Wrap("[RSA HEADER] failed to decrypt to zipped data") + } + + plaintext, err := yagzip.NewGzipWithLevel(h.compressionLevel).Unzip(zipped) + if err != nil { + return "", nil, err.Wrap("[RSA HEADER] failed to get plain text from zip") + } + + index := bytes.IndexRune(plaintext, []rune(emptySymbol)[0]) + offset := len([]byte(emptySymbol)) + + switch index { + case 0: + offset = 0 + case -1: + index = 0 + offset = 0 + } + + res, err := yaencoding.DecodeMessagePack[T](plaintext[index+offset:]) + if err != nil { + return "", nil, err.Wrap("[RSA HEADER] failed to decode plaintext") + } + + return string(plaintext[:index+offset]), res, nil +} + +// Handle implements Gin middleware interface to automatically decrypt, +// decode, and inject data into Gin context. +// +// The middleware performs the following: +// 1. Reads the header specified in `HeaderName`. +// 2. Strips CR/LF characters (for safety). +// 3. Calls Decode(). +// 4. On success: +// - Rewrites request header to the plaintext prefix (if present). +// - Stores decoded struct in context under `ContextKey`. +// - Calls `ctx.Next()`. +// 5. On failure: +// - Logs error via ctx.Error(err). +// - Optionally aborts request if `ContextAbort == true`. +// +// Example: +// +// type Payload struct { Msg string } +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// header := yaginmiddleware.NewEncodeRSA[Payload]("X-Enc", "payload", key, true) +// +// r := gin.New() +// r.Use(header.Handle) +// +// r.GET("/ping", func(c *gin.Context) { +// val, _ := c.Get("payload") +// fmt.Println(val.(*Payload).Msg) +// }) +func (h *RSASecureHeader[T]) Handle(ctx *gin.Context) { + text := ctx.GetHeader(h.HeaderName) + + text = yarsa.StripCRLF(text) + + src, data, err := h.Decode(text) + if err != nil { + _ = ctx.Error(err) + + if h.ContextAbort { + ctx.Abort() + } + + return + } + + ctx.Request.Header.Set(h.HeaderName, src) + + ctx.Set(h.ContextKey, data) + + ctx.Next() +} diff --git a/yaginmiddleware/rsa_secure_test.go b/yaginmiddleware/rsa_secure_test.go new file mode 100644 index 0000000..b904a72 --- /dev/null +++ b/yaginmiddleware/rsa_secure_test.go @@ -0,0 +1,110 @@ +package yaginmiddleware_test + +import ( + "crypto/rand" + "crypto/rsa" + "net/http" + "net/http/httptest" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaginmiddleware" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +type testData struct { + ID uint16 `json:"id"` + Text *string `json:"text"` + Data []byte `json:"data"` +} + +func TestEncodeRSAHeader_Flow(t *testing.T) { + t.Parallel() + + t.Run("[EncodeDecode] RoundTrip", func(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err, "failed to generate rsa key") + + lol := "RZK&SKALSE<3" + + in := testData{ + ID: 100, + Text: &lol, + Data: []byte{1, 2, 3, 4, 5, 6, 7, 8}, + } + + header := yaginmiddleware.NewEncodeRSA[testData]("X-Data", "payload", key, true) + + enc, _ := header.Encode(in) + + _, out, _ := header.Decode(enc) + + assert.Equal(t, &in, out, "Data mismatch") + }) + + t.Run("[Middleware] Success", func(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + lol := "OK" + in := testData{ID: 7, Text: &lol, Data: []byte{9, 8, 7}} + + header := yaginmiddleware.NewEncodeRSA[testData]("X-Enc", "payload", key, true) + + enc, yaerr := header.Encode(in) + assert.Nil(t, yaerr, "encode failed: %v", yaerr) + + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(header.Handle) + + engine.GET("/ping", func(c *gin.Context) { + v, _ := c.Get("payload") + + assert.Equal(t, &in, v, "failed to decode response") + + c.JSON(http.StatusOK, v) + }) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("X-Enc", enc) + + rec := httptest.NewRecorder() + + engine.ServeHTTP(rec, req) + }) + + t.Run("[Middleware] AbortOnInvalidHeader", func(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + header := yaginmiddleware.NewEncodeRSA[testData]("X-Enc", "payload", key, true) + + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(header.Handle) + + handlerCalled := false + + engine.GET("/ping", func(c *gin.Context) { + handlerCalled = true + + c.JSON(http.StatusOK, gin.H{"message": "pong"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("X-Enc", "!!!not-base64!!!") + + rec := httptest.NewRecorder() + + engine.ServeHTTP(rec, req) + + assert.False(t, handlerCalled, "handler should NOT be called on abort") + }) +} diff --git a/yaginmiddleware/yaginmiddleware.go b/yaginmiddleware/yaginmiddleware.go new file mode 100644 index 0000000..d6e4d17 --- /dev/null +++ b/yaginmiddleware/yaginmiddleware.go @@ -0,0 +1,10 @@ +// Package yaginmiddleware provides secure middleware utilities for Gin. +package yaginmiddleware + +import "github.com/gin-gonic/gin" + +// Middleware represents a generic Gin middleware component +// capable of processing requests via a `Handle` method. +type Middleware interface { + Handle(ctx *gin.Context) +} diff --git a/yagzip/yagzip.go b/yagzip/yagzip.go new file mode 100644 index 0000000..cdfb355 --- /dev/null +++ b/yagzip/yagzip.go @@ -0,0 +1,128 @@ +// Package yagzip provides tiny helpers to gzip-compress and decompress []byte +// payloads using the standard library's gzip implementation. +// +// Notes: +// - Zip writes gzip data into an internal buffer and returns its bytes. +// - Unzip reads a full gzip stream from memory and returns the decompressed bytes. +// - Errors are wrapped with yaerrors (HTTP 500 semantics) for consistency with +// the rest of your codebase, while keeping the exported signatures as (.., error). +// +// Example (basic round-trip): +// +// data := []byte("Hello, RZK!") +// z, err := yagzip.Zip(data) +// if err != nil { +// log.Fatalf("zip failed: %v", err) +// } +// uz, err := yagzip.Unzip(z) +// if err != nil { +// log.Fatalf("unzip failed: %v", err) +// } +// fmt.Println(string(uz)) // "Hello, RZK!" +package yagzip + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "io" + "net/http" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" +) + +const DefaultCompression = flate.DefaultCompression + +type Gzip struct { + Level int +} + +func NewGzipWithLevel(level int) *Gzip { + return &Gzip{ + Level: level, + } +} + +func NewGzip() *Gzip { + return &Gzip{ + Level: flate.DefaultCompression, + } +} + +// Zip compresses object using gzip and returns the compressed bytes. +// +// Returns: +// - []byte: gzip-compressed data +// - yaerror: wrapped with err on failure +// +// Example: +// +// in := []byte("payload") +// out, err := yagzip.Zip(in) +// if err != nil { /* handle */ } +func (g *Gzip) Zip(object []byte) ([]byte, yaerrors.Error) { + var buf bytes.Buffer + + w, err := gzip.NewWriterLevel(&buf, g.Level) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[GZIP] failed to create write", + ) + } + + _, err = w.Write(object) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[GZIP] failed to write payload to gzip writer", + ) + } + + if err := w.Close(); err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[GZIP] failed to close gzip writer", + ) + } + + return buf.Bytes(), nil +} + +// Unzip decompresses gzip-compressed data back to its original bytes. +// +// Returns: +// - []byte: decompressed payload +// - yaerror: wrapped with err on failure +// +// Example: +// +// payload, err := yagzip.Unzip(zipped) +// if err != nil { /* handle */ } +func (g *Gzip) Unzip(compressed []byte) ([]byte, yaerrors.Error) { + r, err := gzip.NewReader(bytes.NewReader(compressed)) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[GZIP] failed to create gzip reader", + ) + } + defer r.Close() + + var out bytes.Buffer + + _, err = io.Copy(&out, r) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[GZIP] failed to read from gzip stream", + ) + } + + return out.Bytes(), nil +} diff --git a/yagzip/yagzip_test.go b/yagzip/yagzip_test.go new file mode 100644 index 0000000..9562e09 --- /dev/null +++ b/yagzip/yagzip_test.go @@ -0,0 +1,52 @@ +package yagzip_test + +import ( + "bytes" + "math/rand" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yagzip" + "github.com/stretchr/testify/require" +) + +func TestFlow_BasicCases(t *testing.T) { + vectors := [][]byte{ + {}, + []byte("a"), + []byte("Hello, RZK!"), + bytes.Repeat([]byte("x"), 128), + bytes.Repeat([]byte{0x00}, 1024), + bytes.Repeat([]byte{0xEE, 0xFF, 0x00, 0x01}, 257), + } + + gzip := yagzip.NewGzip() + for i, in := range vectors { + z, err := gzip.Zip(in) + require.NoErrorf(t, err, "case %d: Zip failed", i) + + out, err := gzip.Unzip(z) + require.NoErrorf(t, err, "case %d: Unzip failed", i) + + require.Equalf(t, in, out, "case %d: mismatch", i) + } +} + +func TestFlow_LargeCase(t *testing.T) { + sizes := []int{1 << 10, 64 << 10, 256 << 10} + rng := rand.New(rand.NewSource(42)) + + for _, n := range sizes { + in := make([]byte, n) + _, err := rng.Read(in) + require.NoErrorf(t, err, "n=%d: rng read failed", n) + + gzip := yagzip.NewGzip() + z, err := gzip.Zip(in) + require.NoErrorf(t, err, "n=%d: Zip failed", n) + + out, err := gzip.Unzip(z) + require.NoErrorf(t, err, "n=%d: Unzip failed", n) + + require.Equalf(t, in, out, "n=%d: mismatch after round-trip", n) + } +} diff --git a/yalocales/utils.go b/yalocales/utils.go index 428fbaf..a8850d0 100644 --- a/yalocales/utils.go +++ b/yalocales/utils.go @@ -57,7 +57,7 @@ func setDiff(a, b map[string]struct{}) (missingInB []string, extraInB []string) } } - return + return missingInB, extraInB } func subtractSets(a, b map[string]struct{}) []string { diff --git a/yarsa/key.go b/yarsa/key.go new file mode 100644 index 0000000..0710d04 --- /dev/null +++ b/yarsa/key.go @@ -0,0 +1,472 @@ +// Package yarsa — RSA key utilities (deterministic keygen + private-key parsing). +// +// This file provides two main capabilities: +// +// 1. Deterministic RSA key generation: +// GenerateDeterministicRSA(KeyOpts) -> *rsa.PrivateKey +// - Reproducible for the same (Seed, Bits, E). +// - Uses an internal DRBG (HMAC-SHA256(counter)) and a deterministic prime +// search with the top TWO bits forced for each prime; that strongly biases +// p and q to the top quarter of their ranges so the final modulus has the +// requested bit-length. +// - The stdlib rsa.GenerateKey is NOT guaranteed deterministic even with a +// deterministic io.Reader (due to internal jitter), so we implement our +// own prime generation. +// +// 2. Private key parsing convenience: +// ParsePrivateKey(string) -> *rsa.PrivateKey +// - Accepts: +// * PEM (PKCS#1 “RSA PRIVATE KEY” or PKCS#8 “PRIVATE KEY”) +// * Base64 of PEM (std or URL-safe, with/without padding) +// * Raw DER bytes (PKCS#1 or PKCS#8) encoded as base64 +// - Returns yaerrors.Error on failure with HTTP-500 semantics to fit the +// existing error handling style of this codebase. +// +// Notes: +// - For deterministic keygen, supply a high-entropy Seed. A weak or guessable +// seed trivially compromises the private key. +// - StripCRLF(s) helps when keys are transported with line-wraps (pasted base64). +package yarsa + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "io" + "math/big" + "net/http" + "strings" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" +) + +const ( + bigValueOne = 1 + bigValueTwo = 2 +) + +var ( + bigOne = big.NewInt(bigValueOne) + bigTwo = big.NewInt(bigValueTwo) +) + +// KeyOpts holds parameters for deterministic RSA key generation. +// - Bits: modulus size (e.g., 2048, 3072, 4096). Must be even and >= 512. +// - Exponent: public exponent (use 65537 if 0). +// - Seed: high-entropy secret seed; same inputs -> same keypair. +type KeyOpts struct { + Bits int + Exponent int + Seed []byte +} + +// GenerateDeterministicRSAPrivateKey returns a reproducible *rsa.PrivateKey from KeyOpts. +// Implementation details: +// - Uses a deterministic byte stream (NewDeterministicReader) to draw prime candidates. +// - Forces each prime’s top two bits and oddness to ensure target bit length. +// - Ensures gcd(e, p−1) == gcd(e, q−1) == 1 and p != q. +// - Validates the key and precomputes CRT values. +// +// Errors if Bits invalid, Seed empty, or validation fails. +// +// Example: +// +// opts := yarsa.KeyOpts{ +// Bits: 2048, +// E: 65537, +// Seed: []byte("deterministic-seed"), +// } +// +// key, err := yarsa.GenerateDeterministicRSAPrivateKey(opts) +// if err != nil { +// log.Fatalf("failed to generate key: %v", err) +// } +// +// // Calling again with the same seed -> identical key +// key2, _ := yarsa.GenerateDeterministicRSAPrivateKey(opts) +// fmt.Println(key.N.Cmp(key2.N) == 0) // true +func GenerateDeterministicRSAPrivateKey(opts KeyOpts) (*rsa.PrivateKey, yaerrors.Error) { + if opts.Bits < 512 || opts.Bits%2 != 0 { + return nil, yaerrors.FromString( + http.StatusInternalServerError, + "bits must be even and >= 512", + ) + } + + if opts.Exponent == 0 { + opts.Exponent = 65537 + } + + if len(opts.Seed) == 0 { + return nil, yaerrors.FromString(http.StatusInternalServerError, "seed required") + } + + reader := NewDeterministicReader(opts.Seed) + + const bits = 2 + + pBits := opts.Bits / bits + qBits := opts.Bits - pBits + + e := big.NewInt(int64(opts.Exponent)) + + var ( + p, q *big.Int + err yaerrors.Error + ) + + for { + p, err = nextPrime(reader, pBits) + if err != nil { + return nil, err.Wrap("failed to get next prime") + } + + pm1 := new(big.Int).Sub(p, bigOne) + if new(big.Int).GCD(nil, nil, e, pm1).Cmp(bigOne) == 0 { + break + } + } + + for { + q, err = nextPrime(reader, qBits) + if err != nil { + return nil, err.Wrap("failed to get next prime") + } + + if p.Cmp(q) == 0 { + continue + } + + qm1 := new(big.Int).Sub(q, bigOne) + if new(big.Int).GCD(nil, nil, e, qm1).Cmp(bigOne) != 0 { + continue + } + + n := new(big.Int).Mul(p, q) + if n.BitLen() == opts.Bits { + break + } + } + + if p.Cmp(q) < 0 { + p, q = q, p + } + + n := new(big.Int).Mul(p, q) + phi := new(big.Int).Mul(new(big.Int).Sub(p, bigOne), new(big.Int).Sub(q, bigOne)) + + d := new(big.Int).ModInverse(e, phi) + if d == nil { + return nil, yaerrors.FromString(http.StatusInternalServerError, "no modular inverse for d") + } + + private := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{ + N: n, + E: int(e.Int64()), + }, + D: d, + Primes: []*big.Int{new(big.Int).Set(p), new(big.Int).Set(q)}, + } + + if err := private.Validate(); err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "failed to validate private key", + ) + } + + private.Precompute() + + return private, nil +} + +// nextPrime returns a prime of exact bit length `bits` from reader r. +// It sets the top two bits and the low bit (odd), then checks ProbablyPrime(64). +// If the candidate isn’t prime, it does a bounded deterministic +2 search +// (staying within the bit length) before drawing fresh bytes again. +func nextPrime(r io.Reader, bits int) (*big.Int, yaerrors.Error) { + const minBits = 2 + if bits < minBits { + return nil, yaerrors.FromString(http.StatusInternalServerError, "bits too small") + } + + const ( + bit7 = 7 + bit8 = 8 + ) + + byteLen := (bits + bit7) / bit8 + buf := make([]byte, byteLen) + + for { + if _, err := io.ReadFull(r, buf); err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "failed to read full buffer", + ) + } + + const mask = 0xFF + + topMask := byte(mask) + if m := bits % bit8; m != 0 { + topMask = mask >> (bit8 - m) + } + + buf[0] &= topMask + + const bit2 = 2 + + if bits%bit8 == 0 { + buf[0] |= 0xC0 + } else { + msb := uint((bits - 1) % bit8) + nmsb := uint((bits - bit2) % bit8) + buf[0] |= (1 << msb) + buf[0] |= (1 << nmsb) + } + + buf[len(buf)-1] |= 1 + + cand := new(big.Int).SetBytes(buf) + if cand.BitLen() != bits { + continue + } + + const prime = 64 + if cand.ProbablyPrime(prime) { + return cand, nil + } + + const bit12 = 12 + + limit := 1 << bit12 + for range limit { + cand.Add(cand, bigTwo) + + if cand.BitLen() != bits { + break + } + + if cand.ProbablyPrime(prime) { + return cand, nil + } + } + } +} + +// ParsePrivateKey tries to parse an RSA private key provided as: +// 1. PEM: "-----BEGIN RSA PRIVATE KEY-----" (PKCS#1) or "-----BEGIN PRIVATE KEY-----" (PKCS#8) +// 2. Base64 of PEM (standard or URL-safe, with/without padding) +// 3. Raw DER bytes encoded as base64 (PKCS#1 or PKCS#8) +// +// It returns a *rsa.PrivateKey or a yaerrors.Error describing what failed. +// +// Example: +// +// // Parse PEM-formatted private key +// const pemKey = `-----BEGIN RSA PRIVATE KEY----- +// MIIEowIBAAKCAQEA3vRcvK... +// -----END RSA PRIVATE KEY-----` +// +// key, err := yarsa.ParsePrivateKey(pemKey) +// if err != nil { +// log.Fatalf("parse failed: %v", err) +// } +// +// fmt.Println("Modulus bits:", key.N.BitLen()) +func ParsePrivateKey(s string) (*rsa.PrivateKey, yaerrors.Error) { + input := strings.TrimSpace(s) + + if looksLikePEMPrivateKey(input) { + key, err := parsePrivateKey([]byte(input)) + if err != nil { + return nil, err.Wrap("[RSA] failed to parse private PEM key") + } + + return key, nil + } + + noCRLF := StripCRLF(input) + + decoded, err := base64.StdEncoding.DecodeString(noCRLF) + if err != nil { + if alt, altErr := tryBase64URLAll(noCRLF); altErr == nil { + decoded = alt + } else { + return nil, yaerrors.FromString( + http.StatusInternalServerError, + "[RSA] invalid key: expected PEM (PKCS#1/PKCS#8) or base64 of PEM/DER", + ) + } + } + + if looksLikePEMPrivateKey(string(decoded)) { + key, err := parsePrivateKey(decoded) + if err != nil { + return nil, err.Wrap("[RSA] failed to parse private PEM key") + } + + return key, nil + } + + if key, yaerr := parsePKCS1DER(decoded); yaerr == nil { + return key, nil + } + + if key, yaerr := parsePKCS8DER(decoded); yaerr == nil { + return key, nil + } + + return nil, yaerrors.FromString( + http.StatusInternalServerError, + "[RSA] invalid key: expected PEM (PKCS#1/PKCS#8) or base64 of PEM/DER", + ) +} + +// looksLikePEMPrivateKey performs cheap string checks to detect any PEM +// private-key header/footer without fully decoding the PEM. It’s used as +// a fast-path before attempting base64. +func looksLikePEMPrivateKey(s string) bool { + upper := strings.ToUpper(s) + + return strings.Contains(upper, "-----BEGIN ") && + strings.Contains(upper, " PRIVATE KEY-----") +} + +// StripCRLF removes CR and LF characters and then trims surrounding spaces. +// This allows base64 payloads to be pasted with line wraps. +func StripCRLF(s string) string { + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + + return strings.TrimSpace(s) +} + +// tryBase64URLAll attempts to decode s as URL-safe base64 in both variants: +// - RawURLEncoding (no '=' padding expected) +// - URLEncoding (padding expected; we add best-effort padding if missing) +// +// It returns decoded bytes or an error if neither variant works. +func tryBase64URLAll(s string) ([]byte, yaerrors.Error) { + if b, err := base64.RawURLEncoding.DecodeString(s); err == nil { + return b, nil + } + + res, err := base64.URLEncoding.DecodeString(padBase64(s)) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[RSA] failed to decode string as bytes", + ) + } + + return res, nil +} + +// parsePrivateKey parses a PEM-encoded RSA private key in PKCS#1 or PKCS#8 form. +// Returns *rsa.PrivateKey or yaerrors.Error on failure. +func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, yaerrors.Error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, yaerrors.FromString( + http.StatusInternalServerError, + "[RSA] failed to decode PEM block", + ) + } + + switch block.Type { + case "RSA PRIVATE KEY": + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[RSA] failed to parse PKCS#1", + ) + } + + return key, nil + + case "PRIVATE KEY": + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[RSA] failed to parse PKCS#8", + ) + } + + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, yaerrors.FromString( + http.StatusInternalServerError, + "[RSA] PKCS#8 is not an RSA key", + ) + } + + return key, nil + default: + return nil, yaerrors.FromString( + http.StatusInternalServerError, + "[RSA] unsupported PEM type: "+block.Type, + ) + } +} + +// parsePKCS1DER parses a PKCS#1 DER-encoded RSA private key. +func parsePKCS1DER(der []byte) (*rsa.PrivateKey, yaerrors.Error) { + key, err := x509.ParsePKCS1PrivateKey(der) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[RSA] DER PKCS#1 parse failed", + ) + } + + return key, nil +} + +// parsePKCS8DER parses a PKCS#8 DER-encoded private key, ensuring the type is RSA. +func parsePKCS8DER(der []byte) (*rsa.PrivateKey, yaerrors.Error) { + parsed, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[RSA] DER PKCS#8 parse failed", + ) + } + + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, yaerrors.FromString( + http.StatusInternalServerError, + "[RSA] DER PKCS#8 is not an RSA key", + ) + } + + return key, nil +} + +// padBase64 appends '=' characters until len(s) is a multiple of 4. +// This is a best-effort fix for inputs that dropped base64 padding. +func padBase64(s string) string { + const ( + padding = 4 + change = "=" + ) + + if m := len(s) % padding; m != 0 { + s += strings.Repeat(change, padding-m) + } + + return s +} diff --git a/yarsa/key_test.go b/yarsa/key_test.go new file mode 100644 index 0000000..0b76b19 --- /dev/null +++ b/yarsa/key_test.go @@ -0,0 +1,257 @@ +package yarsa_test + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yarsa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func pubFingerprint(t *testing.T, pub *rsa.PublicKey) [32]byte { + t.Helper() + + der, err := x509.MarshalPKIXPublicKey(pub) + require.NoError(t, err) + + return sha256.Sum256(der) +} + +func Test_GenerateDeterministicRSA_Determinism(t *testing.T) { + t.Parallel() + + const bits = 2048 + + seed := []byte("correct-horse-battery-staple") + + key1, err := yarsa.GenerateDeterministicRSAPrivateKey( + yarsa.KeyOpts{Bits: bits, Exponent: 65537, Seed: seed}, + ) + require.NoError(t, err) + + key2, err := yarsa.GenerateDeterministicRSAPrivateKey( + yarsa.KeyOpts{Bits: bits, Exponent: 65537, Seed: seed}, + ) + require.NoError(t, err) + + assert.Equal( + t, + pubFingerprint(t, &key1.PublicKey), + pubFingerprint(t, &key2.PublicKey), + "public keys differ for the same seed", + ) + assert.Equal(t, key1.D, key2.D, "private exponent differs for the same seed") + assert.Equal(t, 2, len(key1.Primes)) + assert.Equal(t, bits, key1.N.BitLen(), "modulus bit length mismatch") +} + +func Test_GenerateDeterministicRSA_DifferentSeedsDiffer(t *testing.T) { + t.Parallel() + + const bits = 2048 + + seedA := []byte("seed-A") + seedB := []byte("seed-B") + + keyA, err := yarsa.GenerateDeterministicRSAPrivateKey( + yarsa.KeyOpts{Bits: bits, Exponent: 65537, Seed: seedA}, + ) + require.NoError(t, err) + + keyB, err := yarsa.GenerateDeterministicRSAPrivateKey( + yarsa.KeyOpts{Bits: bits, Exponent: 65537, Seed: seedB}, + ) + require.NoError(t, err) + + assert.NotEqual( + t, + pubFingerprint(t, &keyA.PublicKey), + pubFingerprint(t, &keyB.PublicKey), + "different seeds yielded identical public keys", + ) + + assert.NotEqual(t, keyA.D, keyB.D, "different seeds yielded identical private exponents") +} + +func Test_GenerateDeterministicRSA_DefaultExponent_And_PrimeOrder(t *testing.T) { + t.Parallel() + + key, err := yarsa.GenerateDeterministicRSAPrivateKey( + yarsa.KeyOpts{Bits: 2048, Exponent: 0, Seed: []byte("exp-default")}, + ) + + require.NoError(t, err) + + assert.Equal(t, 65537, key.E, "default exponent should be 65537") + + require.Equal(t, 2, len(key.Primes)) + + assert.True(t, key.Primes[0].Cmp(key.Primes[1]) > 0, "expected p > q ordering") +} + +func Test_GenerateDeterministicRSA_MultiBitLengths(t *testing.T) { + t.Parallel() + + for _, bits := range []int{2048, 4096} { + t.Run(fmt.Sprintf("bits=%d", bits), func(t *testing.T) { + t.Parallel() + + key, err := yarsa.GenerateDeterministicRSAPrivateKey( + yarsa.KeyOpts{Bits: bits, Exponent: 65537, Seed: []byte("multi")}, + ) + + require.NoError(t, err) + + assert.Equal(t, bits, key.N.BitLen(), "modulus bit length mismatch") + + assert.NoError(t, key.Validate(), "stdlib rsa key validation failed") + }) + } +} + +func Test_GenerateDeterministicRSA_InvalidOpts(t *testing.T) { + t.Parallel() + + _, err := yarsa.GenerateDeterministicRSAPrivateKey( + yarsa.KeyOpts{Bits: 511, Exponent: 65537, Seed: []byte("x")}, + ) + assert.Error(t, err, "odd bit length should fail") + + _, err = yarsa.GenerateDeterministicRSAPrivateKey( + yarsa.KeyOpts{Bits: 2048, Exponent: 65537, Seed: nil}, + ) + assert.Error(t, err, "missing seed should fail") +} + +func Test_ParsePrivateKey_AllFormats(t *testing.T) { + t.Parallel() + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pkcs1DER := x509.MarshalPKCS1PrivateKey(priv) + pkcs1PEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkcs1DER}) + + pkcs8DER, err := x509.MarshalPKCS8PrivateKey(priv) + require.NoError(t, err) + + pkcs8PEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8DER}) + + t.Run("[PEM] PKCS1", func(t *testing.T) { + t.Parallel() + + got, err := yarsa.ParsePrivateKey(string(pkcs1PEM)) + + assert.Nil(t, err, "parse pkcs1 pem error: %v", err) + + require.NotNil(t, got) + + assert.Equal(t, priv.N, got.N) + }) + + t.Run("[PEM] PKCS8", func(t *testing.T) { + t.Parallel() + + got, err := yarsa.ParsePrivateKey(string(pkcs8PEM)) + + assert.Nil(t, err, "parse pkcs8 pem error: %v", err) + + require.NotNil(t, got) + + assert.Equal(t, priv.N, got.N) + }) + + t.Run("[Base64 std] PKCS1 DER", func(t *testing.T) { + t.Parallel() + + b64 := base64.StdEncoding.EncodeToString(pkcs1DER) + + got, err := yarsa.ParsePrivateKey(b64) + + assert.Nil(t, err, "parse base64(pkcs1 der) error: %v", err) + + require.NotNil(t, got) + + assert.Equal(t, priv.N, got.N) + }) + + t.Run("[Base64 std] PKCS8 DER", func(t *testing.T) { + t.Parallel() + + b64 := base64.StdEncoding.EncodeToString(pkcs8DER) + + got, err := yarsa.ParsePrivateKey(b64) + + assert.Nil(t, err, "parse base64(pkcs8 der) error: %v", err) + + require.NotNil(t, got) + + assert.Equal(t, priv.N, got.N) + }) + + t.Run("[Base64 URL raw] PKCS1 DER (no padding)", func(t *testing.T) { + t.Parallel() + + b64url := base64.RawURLEncoding.EncodeToString(pkcs1DER) + + got, err := yarsa.ParsePrivateKey(b64url) + + assert.Nil(t, err, "parse rawURL b64(pkcs1 der) error: %v", err) + + require.NotNil(t, got) + + assert.Equal(t, priv.N, got.N) + }) + + t.Run("[Base64 URL padded] PKCS8 DER", func(t *testing.T) { + t.Parallel() + + b64url := base64.URLEncoding.EncodeToString(pkcs8DER) + + got, err := yarsa.ParsePrivateKey(b64url) + + assert.Nil(t, err, "parse URL b64(pkcs8 der) error: %v", err) + + require.NotNil(t, got) + + assert.Equal(t, priv.N, got.N) + }) + + t.Run("[CRLF-wrapped base64] PKCS1 DER", func(t *testing.T) { + t.Parallel() + + b64 := base64.StdEncoding.EncodeToString(pkcs1DER) + + wrapped := bytes.Join([][]byte{ + []byte(b64[:48]), + []byte(b64[48:96]), + []byte(b64[96:]), + }, []byte("\r\n")) + + got, err := yarsa.ParsePrivateKey(string(wrapped)) + + assert.Nil(t, err, "parse wrapped base64(pkcs1 der) error: %v", err) + + require.NotNil(t, got) + + assert.Equal(t, priv.N, got.N) + }) + + t.Run("[Invalid] garbage string", func(t *testing.T) { + t.Parallel() + + got, err := yarsa.ParsePrivateKey("!!!not-a-key!!!") + + assert.NotNil(t, err, "expected error for invalid input") + + assert.Nil(t, got) + }) +} diff --git a/yarsa/reader.go b/yarsa/reader.go new file mode 100644 index 0000000..7531f2b --- /dev/null +++ b/yarsa/reader.go @@ -0,0 +1,104 @@ +package yarsa + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/binary" +) + +// DeterministicReader is a deterministic byte stream (DRBG-like) backed by +// HMAC-SHA256(counter). For a fixed seed, it produces the same sequence of +// bytes on every run. The internal 64-bit counter is encoded in big-endian and +// incremented once per 32-byte block (the size of SHA-256 output). +// +// Security note: +// - This is a simple construction intended for reproducible randomness in tests +// or key derivation flows you *fully* control. Do not treat it as a drop-in +// replacement for a NIST-approved DRBG without careful review. +// - It is **not** concurrency-safe; use one instance per goroutine if needed. +// +// Usage: +// +// seed := []byte("my secret seed") +// r := yarsa.NewDeterministicReader(seed) +// +// // Read 64 bytes deterministically +// buf := make([]byte, 64) +// _, _ = r.Read(buf) +// +// // Re-create with the same seed -> identical 64 bytes in buf2 +// r2 := yarsa.NewDeterministicReader(seed) +// buf2 := make([]byte, 64) +// _, _ = r2.Read(buf2) +// fmt.Println(bytes.Equal(buf, buf2)) // true +type DeterministicReader struct { + seed []byte + counter uint64 + buf [32]byte + pos int +} + +// NewDeterministicReader constructs a new deterministic reader from seed. +// The seed slice is **copied** internally to avoid external mutation effects. +// For the same seed, the produced byte stream is identical across runs. +// +// Example: +// +// r := yarsa.NewDeterministicReader([]byte("seed")) +// b := make([]byte, 16) +// _, _ = r.Read(b) // b now contains first 16 bytes of HMAC-SHA256(seed, ctr=0) +func NewDeterministicReader(seed []byte) *DeterministicReader { + return &DeterministicReader{ + seed: append([]byte{}, seed...), + counter: 0, + } +} + +// Read fills p with deterministic bytes, refilling the internal 32-byte block +// as needed. It returns len(p), nil on success. +// +// Contract: +// - Always returns exactly len(p) unless an unexpected internal error occurs. +// - Not concurrency-safe. Use one instance per goroutine if needed. +func (r *DeterministicReader) Read(p []byte) (int, error) { + written := 0 + for written < len(p) { + if r.pos >= len(r.buf) { + r.refill() + } + + avail := len(r.buf) - r.pos + + toCopy := avail + + need := len(p) - written + if toCopy > need { + toCopy = need + } + + copy(p[written:written+toCopy], r.buf[r.pos:r.pos+toCopy]) + + r.pos += toCopy + + written += toCopy + } + + return written, nil +} + +// refill computes the next 32-byte block = HMAC-SHA256(seed, bigEndian(counter)) +// and resets the buffer position, then increments the counter. +// Not concurrency-safe. +func (r *DeterministicReader) refill() { + mac := hmac.New(sha256.New, r.seed) + + var ctrBytes [8]byte + binary.BigEndian.PutUint64(ctrBytes[:], r.counter) + mac.Write(ctrBytes[:]) + + copy(r.buf[:], mac.Sum(nil)) + + r.pos = 0 + + r.counter++ +} diff --git a/yarsa/reader_test.go b/yarsa/reader_test.go new file mode 100644 index 0000000..718dac5 --- /dev/null +++ b/yarsa/reader_test.go @@ -0,0 +1,146 @@ +package yarsa_test + +import ( + "bytes" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yarsa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeterministicReader_SameSeedSameStream(t *testing.T) { + t.Parallel() + + seed := []byte("correct-horse-battery-staple") + + r1 := yarsa.NewDeterministicReader(seed) + r2 := yarsa.NewDeterministicReader(seed) + + out1 := make([]byte, 4096) + out2 := make([]byte, 4096) + + n1, err1 := r1.Read(out1) + n2, err2 := r2.Read(out2) + + require.NoError(t, err1) + require.NoError(t, err2) + + require.Equal(t, len(out1), n1) + require.Equal(t, len(out2), n2) + + assert.True(t, bytes.Equal(out1, out2), "streams differ for same seed") +} + +func TestDeterministicReader_DifferentSeedsDiffer(t *testing.T) { + t.Parallel() + + r1 := yarsa.NewDeterministicReader([]byte("seed-A")) + r2 := yarsa.NewDeterministicReader([]byte("seed-B")) + + out1 := make([]byte, 256) + out2 := make([]byte, 256) + + _, _ = r1.Read(out1) + _, _ = r2.Read(out2) + + assert.False(t, bytes.Equal(out1, out2), "different seeds produced identical output") +} + +func TestDeterministicReader_MultiReadEqualsSingleRead(t *testing.T) { + t.Parallel() + + seed := []byte("split-read") + rAll := yarsa.NewDeterministicReader(seed) + rParts := yarsa.NewDeterministicReader(seed) + + full := make([]byte, 10*1024+13) + _, err := rAll.Read(full) + require.NoError(t, err) + + part := make([]byte, 0, len(full)) + + chunks := []int{ + 1, + 3, + 7, + 31, + 32, + 33, + 1000, + 4096, + len(full) - (1 + 3 + 7 + 31 + 32 + 33 + 1000 + 4096), + } + for _, n := range chunks { + buf := make([]byte, n) + _, err := rParts.Read(buf) + require.NoError(t, err) + + part = append(part, buf...) + } + + assert.Equal(t, full, part, "split reads do not match single read") +} + +func TestDeterministicReader_ZeroLengthRead(t *testing.T) { + t.Parallel() + + r := yarsa.NewDeterministicReader([]byte("zlr")) + buf := make([]byte, 0) + + n, err := r.Read(buf) + require.NoError(t, err) + assert.Equal(t, 0, n) +} + +func TestDeterministicReader_LongRead_ManyRefills(t *testing.T) { + t.Parallel() + + r := yarsa.NewDeterministicReader([]byte("long-long-seed")) + + N := 1 << 20 + buf := make([]byte, N) + + n, err := r.Read(buf) + require.NoError(t, err) + assert.Equal(t, N, n) + + r2 := yarsa.NewDeterministicReader([]byte("long-long-seed")) + buf2 := make([]byte, N) + _, _ = r2.Read(buf2) + + assert.Equal(t, buf2, buf) +} + +func TestDeterministicReader_SeedCopyIsolation(t *testing.T) { + t.Parallel() + + seed := []byte("mutable") + r1 := yarsa.NewDeterministicReader(seed) + + seed[0] ^= 0xFF + + out1 := make([]byte, 256) + _, _ = r1.Read(out1) + + r2 := yarsa.NewDeterministicReader(seed) + out2 := make([]byte, 256) + _, _ = r2.Read(out2) + + original := []byte("mutable") + r1Expected := yarsa.NewDeterministicReader(original) + + exp := make([]byte, 256) + _, _ = r1Expected.Read(exp) + + assert.True( + t, + bytes.Equal(out1, exp), + "reader created before seed mutation changed unexpectedly", + ) + assert.False( + t, + bytes.Equal(out1, out2), + "reader created after mutation should differ from original", + ) +} diff --git a/yarsa/yarsa.go b/yarsa/yarsa.go new file mode 100644 index 0000000..d4d44a8 --- /dev/null +++ b/yarsa/yarsa.go @@ -0,0 +1,164 @@ +// Package yarsa provides practical helpers to encrypt and decrypt arbitrary-length +// data with RSA-OAEP (SHA-256), handling chunking under the hood. +// +// The API is intentionally minimal: +// +// - Encrypt(plaintext []byte, public *rsa.PublicKey) -> []byte (concatenated ciphertext blocks) +// - Decrypt(cipher []byte, private *rsa.PrivateKey) -> []byte (reconstructed plaintext) +// +// Notes: +// +// - RSA-OAEP(SHA-256) with a 2048-bit key allows at most 190 bytes of plaintext +// per block (k − 2*hLen − 2 = 256 − 2*32 − 2). Larger inputs are split into +// 190-byte chunks automatically. +// - Ciphertext block size is always exactly the modulus size (256 bytes for +// RSA-2048). Therefore, the total ciphertext length is a multiple of 256. +// - Transport encodings (e.g., base64) are intentionally not handled here. +// Keep base64 “at the edges” of your app. +// - Errors are returned as yaerrors.Error with HTTP 500 semantics to match +// the rest of your codebase. +// +// Example (basic round-trip with RSA-2048): +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// +// msg := []byte("Hello, RZK! This may be longer than 190 bytes; it will be chunked automatically.") +// +// // Encrypt - concatenated 256-byte blocks +// ct, err := yarsa.Encrypt(msg, &key.PublicKey) +// if err != nil { +// log.Fatalf("encrypt failed: %v", err) +// } +// +// // Decrypt - validate multiple of 256, then OAEP-decrypt each block +// pt, err := yarsa.Decrypt(ct, key) +// if err != nil { +// log.Fatalf("decrypt failed: %v", err) +// } +// +// fmt.Println(string(pt)) // "Hello, RZK! …" +package yarsa + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "fmt" + "net/http" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" +) + +// Encrypt applies RSA-OAEP(SHA-256) to plaintext, chunking as needed. +// Each plaintext chunk (≤190 bytes for RSA-2048) is encrypted into a fixed-size +// 256-byte ciphertext block. All blocks are concatenated and returned. +// +// Example: +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// plaintext := []byte("Hello, this message will be chunked at 190 bytes if longer.") +// +// ciphertext, err := yarsa.Encrypt(plaintext, &key.PublicKey) +// if err != nil { +// log.Fatalf("encrypt failed: %v", err) +// } +// +// fmt.Printf("ciphertext length: %d\n", len(ciphertext)) +// +// Returns: +// - []byte: concatenated ciphertext blocks +// - yaerrors.Error: wrapped error with HTTP 500 semantics +func Encrypt(plaintext []byte, public *rsa.PublicKey) ([]byte, yaerrors.Error) { + hash := sha256.New() + + label := []byte{} + + const padding = 2 + + chunksCount := public.Size() - padding*sha256.Size - padding + if chunksCount <= 0 { + return nil, yaerrors.FromString( + http.StatusInternalServerError, + "[RSA] invalid OAEP max chunk size", + ) + } + + var out []byte + + for i := 0; i < len(plaintext); i += chunksCount { + end := i + chunksCount + + end = min(end, len(plaintext)) + + block, err := rsa.EncryptOAEP(hash, rand.Reader, public, plaintext[i:end], label) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[RSA] failed to encrypt chunk with OAEP", + ) + } + + out = append(out, block...) + } + + return out, nil +} + +// Decrypt reverses Encrypt by splitting ciphertext into fixed-size blocks +// (256 bytes for RSA-2048), decrypting each with RSA-OAEP(SHA-256), and +// concatenating results. +// +// Example: +// +// key, _ := rsa.GenerateKey(rand.Reader, 2048) +// plaintext := []byte("Hello, this message will be encrypted and decrypted.") +// +// ciphertext, _ := yarsa.Encrypt(plaintext, &key.PublicKey) +// +// decrypted, err := yarsa.Decrypt(ciphertext, key) +// if err != nil { +// log.Fatalf("decrypt failed: %v", err) +// } +// +// fmt.Println(string(decrypted)) // "Hello, this message will be encrypted and decrypted." +// +// Returns: +// - []byte: reconstructed plaintext +// - yaerrors.Error: wrapped error with HTTP 500 semantics +func Decrypt(ciphertext []byte, private *rsa.PrivateKey) ([]byte, yaerrors.Error) { + hash := sha256.New() + + label := []byte{} + + blockSize := private.Size() + + if len(ciphertext)%blockSize != 0 { + return nil, yaerrors.FromString( + http.StatusInternalServerError, + fmt.Sprintf( + "[RSA] ciphertext length is not a multiple of RSA block size (expected exact {%d}-byte blocks)", + blockSize, + ), + ) + } + + var out []byte + + for i := 0; i < len(ciphertext); i += blockSize { + end := i + blockSize + + plain, err := rsa.DecryptOAEP(hash, rand.Reader, private, ciphertext[i:end], label) + if err != nil { + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "[RSA] failed to decrypt chunk with OAEP", + ) + } + + out = append(out, plain...) + } + + return out, nil +} diff --git a/yarsa/yarsa_test.go b/yarsa/yarsa_test.go new file mode 100644 index 0000000..03825e0 --- /dev/null +++ b/yarsa/yarsa_test.go @@ -0,0 +1,163 @@ +package yarsa_test + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "fmt" + "testing" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yarsa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func genKey2048(t *testing.T) *rsa.PrivateKey { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + + require.NoError(t, err, "failed to generate RSA key") + + return key +} + +func TestEncryptAndDecrypt_Flow(t *testing.T) { + t.Parallel() + + t.Run("[Math] MaxChunkFormula2048", func(t *testing.T) { + t.Parallel() + + const expected = 190 + + key := genKey2048(t) + + result := key.Size() - 2*sha256.Size - 2 + assert.Equal(t, expected, result) + }) + + t.Run("[RoundTrip] SmallMessages", func(t *testing.T) { + t.Parallel() + + key := genKey2048(t) + + vectors := [][]byte{ + []byte("a"), + []byte("Hello, RZK!"), + bytes.Repeat([]byte("x"), 15), + bytes.Repeat([]byte("y"), 189), + bytes.Repeat([]byte("z"), 190), + } + + for i, msg := range vectors { + t.Run(fmt.Sprintf("case#%d_len=%d", i, len(msg)), func(t *testing.T) { + t.Parallel() + + ct, err := yarsa.Encrypt(msg, &key.PublicKey) + require.NoError(t, err, "encrypt failed") + + assert.Equal( + t, + 0, + len(ct)%key.Size(), + "ciphertext length must be multiple of block size", + ) + + pt, err := yarsa.Decrypt(ct, key) + require.NoError(t, err, "decrypt failed") + + assert.Equal(t, msg, pt, "plaintext mismatch") + }) + } + }) + + t.Run("[RoundTrip] LargeMessages", func(t *testing.T) { + t.Parallel() + + key := genKey2048(t) + + const maxChunk = 190 + + sizes := []int{ + maxChunk + 1, + maxChunk*2 - 1, + maxChunk * 2, + maxChunk*2 + 17, + maxChunk*3 + 123, + maxChunk*10 + 3, + maxChunk*20 + 77, + } + + for _, n := range sizes { + t.Run(fmt.Sprintf("n=%d", n), func(t *testing.T) { + t.Parallel() + + msg := make([]byte, n) + _, err := rand.Read(msg) + require.NoError(t, err, "rand.Read failed") + + ct, err := yarsa.Encrypt(msg, &key.PublicKey) + require.NoError(t, err, "encrypt failed") + + assert.Equal( + t, + 0, + len(ct)%key.Size(), + "ciphertext length must be multiple of block size", + ) + + pt, err := yarsa.Decrypt(ct, key) + require.NoError(t, err, "decrypt failed") + + assert.Equal(t, msg, pt, "plaintext mismatch") + }) + } + }) + + t.Run("[Decrypt] WrongKey_ShouldFail", func(t *testing.T) { + t.Parallel() + + key1 := genKey2048(t) + key2 := genKey2048(t) + + msg := []byte("wrong key test") + ct, err := yarsa.Encrypt(msg, &key1.PublicKey) + require.NoError(t, err, "encrypt failed") + + _, err = yarsa.Decrypt(ct, key2) + assert.Error(t, err, "expected decrypt error with wrong key") + }) + + t.Run("[Decrypt] TamperedCiphertext_ShouldFail", func(t *testing.T) { + t.Parallel() + + key := genKey2048(t) + + msg := []byte("tamper test") + ct, err := yarsa.Encrypt(msg, &key.PublicKey) + require.NoError(t, err, "encrypt failed") + require.NotEmpty(t, ct, "unexpected empty ciphertext") + + ct[len(ct)/2] ^= 0xFF + + _, err = yarsa.Decrypt(ct, key) + assert.Error(t, err, "expected decrypt error on tampered ciphertext") + }) + + t.Run("[Decrypt] InvalidLength_ShouldFail", func(t *testing.T) { + t.Parallel() + + key := genKey2048(t) + + msg := []byte("length test") + ct, err := yarsa.Encrypt(msg, &key.PublicKey) + require.NoError(t, err, "encrypt failed") + require.Greater(t, len(ct), 0, "ciphertext should not be empty") + + ct = ct[:len(ct)-1] + + _, err = yarsa.Decrypt(ct, key) + assert.Error(t, err, "expected decrypt error for invalid block multiple") + }) +}