diff options
| author | Shulhan <ms@kilabit.info> | 2026-03-29 15:38:59 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2026-03-29 16:36:32 +0700 |
| commit | a2245d5be247ef93944c16cbeed7fbb1dc9bab1d (patch) | |
| tree | 29474d1a4e29527b2a7e1fd915a5c0d2c4b6eee6 /lib | |
| parent | 3a3bb2c3b9be26336791bd0dbc450f9e6b2865c7 (diff) | |
| download | pakakeh.go-a2245d5be247ef93944c16cbeed7fbb1dc9bab1d.tar.xz | |
paseto/v4: implements public protocol for paseto version 4
paseto/v4 provides a simple, ready to use, opinionated
implementation of Platform-Agnostic SEcurity TOkens (PASETO) version 4
as defined in [paseto-v4].
The public protocol contains method to sign and verify the message
into/from paseto token.
[paseto-v4]: https://github.com/paseto-standard/paseto-spec/blob/master/docs/01-Protocol-Versions/Version4.md
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/paseto/v4/public_mode.go | 112 | ||||
| -rw-r--r-- | lib/paseto/v4/public_mode_example_test.go | 37 | ||||
| -rw-r--r-- | lib/paseto/v4/public_mode_test.go | 111 | ||||
| -rw-r--r-- | lib/paseto/v4/testdata/public.json | 51 | ||||
| -rw-r--r-- | lib/paseto/v4/testdata/public.json.license | 2 |
5 files changed, 313 insertions, 0 deletions
diff --git a/lib/paseto/v4/public_mode.go b/lib/paseto/v4/public_mode.go new file mode 100644 index 00000000..3ea901eb --- /dev/null +++ b/lib/paseto/v4/public_mode.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +package pasetov4 + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "fmt" + "slices" + "strings" + + "git.sr.ht/~shulhan/pakakeh.go/lib/paseto" +) + +const publicHeader = `v4.public.` + +// PublicMode contains ed25519 private and public key for signing and +// verifying message. +type PublicMode struct { + priv ed25519.PrivateKey + pub ed25519.PublicKey +} + +// NewPublicMode returns new instance of public mode from ed25519 seeds. +func NewPublicMode(seed []byte) (pmode *PublicMode) { + pmode = &PublicMode{} + pmode.priv = ed25519.NewKeyFromSeed(seed) + pmode.pub = pmode.priv.Public().(ed25519.PublicKey) + return pmode +} + +// Sign returns the public token that has been signing with private key. +// The token contains msg and optional footer. +func (pmode *PublicMode) Sign(msg, footer, implicit []byte) (token string, err error) { + logp := `Sign` + + // Step 3: pack header, message, footer, and implicit. + pae, err := paseto.PreAuthEncode([]byte(publicHeader), msg, footer, implicit) + if err != nil { + return ``, fmt.Errorf(`%s: %w`, logp, err) + } + + // Step 4: Sign pae. + sig := ed25519.Sign(pmode.priv, pae) + + // Step 5: Pack all into token, + var buf bytes.Buffer + + buf.WriteString(publicHeader) + + payload := slices.Concat(msg, sig) + n := base64.RawURLEncoding.EncodedLen(len(payload)) + b64 := make([]byte, n) + base64.RawURLEncoding.Encode(b64, payload) + buf.Write(b64) + + if len(footer) != 0 { + buf.WriteByte('.') + n = base64.RawURLEncoding.EncodedLen(len(footer)) + b64 = make([]byte, n) + base64.RawURLEncoding.Encode(b64, footer) + buf.Write(b64) + } + return buf.String(), nil +} + +// Verify returns the msg, with optional footer data, inside the token only if +// its signature is valid. +func (pmode *PublicMode) Verify(token string, implicit []byte) (msg, footer []byte, err error) { + logp := `Verify` + + // Step 3: verify the header and unpack the footer if it exists. + token, found := strings.CutPrefix(token, publicHeader) + if !found { + return nil, nil, fmt.Errorf(`%s: invalid header, want %s`, logp, publicHeader) + } + token, footerb64, found := strings.Cut(token, `.`) + if found { + footer, err = base64.RawURLEncoding.DecodeString(footerb64) + if err != nil { + return nil, nil, fmt.Errorf(`%s: invalid footer: %w`, logp, err) + } + } + + // Step 4: Decodes the payload. + payload, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return nil, nil, fmt.Errorf(`%s: %w`, logp, err) + } + lenpayload := len(payload) + if lenpayload <= ed25519.SignatureSize { + return nil, nil, fmt.Errorf(`%s: invalid payload size, want %d got %d`, + logp, ed25519.SignatureSize, len(payload)) + } + msg = payload[:lenpayload-64] + sig := payload[lenpayload-64:] + + // Step 5: Generate PAE. + pae, err := paseto.PreAuthEncode([]byte(publicHeader), msg, footer, implicit) + if err != nil { + return nil, nil, fmt.Errorf(`%s: %w`, logp, err) + } + + // Step 6: Verify the signature. + if !ed25519.Verify(pmode.pub, pae, sig) { + return nil, nil, fmt.Errorf(`%s: invalid message signature`, logp) + } + + return msg, footer, nil +} diff --git a/lib/paseto/v4/public_mode_example_test.go b/lib/paseto/v4/public_mode_example_test.go new file mode 100644 index 00000000..38c3df0f --- /dev/null +++ b/lib/paseto/v4/public_mode_example_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +package pasetov4 + +import ( + "encoding/hex" + "fmt" + "log" +) + +func ExamplePublicMode() { + secret := `b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774` + seed, err := hex.DecodeString(secret) + if err != nil { + log.Fatal(err) + } + pmode := NewPublicMode(seed) + + plain := []byte(`{"data":"signed message!"}`) + footer := []byte(`{"kid":1000}`) + token, err := pmode.Sign(plain, footer, nil) + if err != nil { + log.Fatal(err) + } + + gotPlain, gotFooter, err := pmode.Verify(token, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println(string(gotPlain)) + fmt.Println(string(gotFooter)) + + // Output: + // {"data":"signed message!"} + // {"kid":1000} +} diff --git a/lib/paseto/v4/public_mode_test.go b/lib/paseto/v4/public_mode_test.go new file mode 100644 index 00000000..6c960c59 --- /dev/null +++ b/lib/paseto/v4/public_mode_test.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +package pasetov4 + +import ( + "encoding/hex" + "encoding/json" + "log" + "os" + "testing" + + "git.sr.ht/~shulhan/pakakeh.go/lib/test" +) + +type testVectorPublic struct { + Name string + PublicKey string `json:"public-key"` + PublicKeyPEM string `json:"public-key-pem"` + SecretKey string `json:"secret-key"` + SecretKeyPEM string `json:"secret-key-pem"` + Seed string `json:"secret-key-seed"` + Token string + Payload string + Footer string + Implicit string `json:"implicit-assertion"` + seed []byte + ExpectFail bool `json:"expect-fail"` +} + +func (tvp *testVectorPublic) init() { + var err error + tvp.seed, err = hex.DecodeString(tvp.Seed) + if err != nil { + log.Fatalf(`init: on seed: %s`, err) + } +} + +func TestPublicMode_Sign(t *testing.T) { + logp := `TestPublicMode_Sign` + + jsonb, err := os.ReadFile(`testdata/public.json`) + if err != nil { + t.Fatalf(`%s: %s`, logp, err) + } + listCase := []testVectorPublic{} + err = json.Unmarshal(jsonb, &listCase) + if err != nil { + t.Fatalf(`%s: %s`, logp, err) + } + + for _, tc := range listCase { + t.Run(tc.Name, func(tt *testing.T) { + defer func() { + if tc.ExpectFail { + msg := recover() + tt.Logf(`%s: expect panic: %s`, tc.Name, msg) + } + }() + + tc.init() + pmode := NewPublicMode(tc.seed) + gotToken, err := pmode.Sign([]byte(tc.Payload), []byte(tc.Footer), + []byte(tc.Implicit)) + if err != nil { + if tc.ExpectFail { + return + } + tt.Fatalf(`%s: %s`, logp, err) + } + test.Assert(tt, tc.Name, tc.Token, string(gotToken)) + }) + } +} + +func TestPublicMode_Verify(t *testing.T) { + logp := `TestPublicMode_Verify` + + jsonb, err := os.ReadFile(`testdata/public.json`) + if err != nil { + t.Fatalf(`%s: %s`, logp, err) + } + listCase := []testVectorPublic{} + err = json.Unmarshal(jsonb, &listCase) + if err != nil { + t.Fatalf(`%s: %s`, logp, err) + } + + for _, tc := range listCase { + t.Run(tc.Name, func(tt *testing.T) { + defer func() { + if tc.ExpectFail { + msg := recover() + tt.Logf(`%s: expect panic: %s`, tc.Name, msg) + } + }() + + tc.init() + pmode := NewPublicMode(tc.seed) + gotMsg, gotFooter, err := pmode.Verify(tc.Token, []byte(tc.Implicit)) + if err != nil { + if tc.ExpectFail { + return + } + tt.Fatalf(`%s: %s`, logp, err) + } + test.Assert(tt, tc.Name, tc.Payload, string(gotMsg)) + test.Assert(tt, tc.Name, tc.Footer, string(gotFooter)) + }) + } +} diff --git a/lib/paseto/v4/testdata/public.json b/lib/paseto/v4/testdata/public.json new file mode 100644 index 00000000..5e7cb501 --- /dev/null +++ b/lib/paseto/v4/testdata/public.json @@ -0,0 +1,51 @@ +[ + { + "name": "4-S-1", + "expect-fail": false, + "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2", + "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2", + "secret-key-seed": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774", + "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----", + "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----", + "token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9bg_XBBzds8lTZShVlwwKSgeKpLT3yukTw6JUz3W4h_ExsQV-P0V54zemZDcAxFaSeef1QlXEFtkqxT1ciiQEDA", + "payload": "{\"data\":\"this is a signed message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}", + "footer": "", + "implicit-assertion": "" + }, + { + "name": "4-S-2", + "expect-fail": false, + "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2", + "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2", + "secret-key-seed": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774", + "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----", + "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----", + "token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9v3Jt8mx_TdM2ceTGoqwrh4yDFn0XsHvvV_D0DtwQxVrJEBMl0F2caAdgnpKlt4p7xBnx1HcO-SPo8FPp214HDw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9", + "payload": "{\"data\":\"this is a signed message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}", + "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}", + "implicit-assertion": "" + }, + { + "name": "4-S-3", + "expect-fail": false, + "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2", + "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2", + "secret-key-seed": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774", + "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----", + "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----", + "token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9NPWciuD3d0o5eXJXG5pJy-DiVEoyPYWs1YSTwWHNJq6DZD3je5gf-0M4JR9ipdUSJbIovzmBECeaWmaqcaP0DQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9", + "payload": "{\"data\":\"this is a signed message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}", + "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}", + "implicit-assertion": "{\"test-vector\":\"4-S-3\"}" + }, + { + "name": "4-F-2", + "expect-fail": true, + "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8", + "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "token": "v4.public.eyJpbnZhbGlkIjoidGhpcyBzaG91bGQgbmV2ZXIgZGVjb2RlIn22Sp4gjCaUw0c7EH84ZSm_jN_Qr41MrgLNu5LIBCzUr1pn3Z-Wukg9h3ceplWigpoHaTLcwxj0NsI1vjTh67YB.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9", + "payload": null, + "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}", + "implicit-assertion": "{\"test-vector\":\"4-F-2\"}" + } +] diff --git a/lib/paseto/v4/testdata/public.json.license b/lib/paseto/v4/testdata/public.json.license new file mode 100644 index 00000000..870a8126 --- /dev/null +++ b/lib/paseto/v4/testdata/public.json.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: BSD-3-Clause +SPDX-FileCopyrightText: 2021 Paragon Initiative Enterprises <security at paragonie dot com> |
