aboutsummaryrefslogtreecommitdiff
path: root/lib/paseto
diff options
context:
space:
mode:
Diffstat (limited to 'lib/paseto')
-rw-r--r--lib/paseto/v4/public_mode.go112
-rw-r--r--lib/paseto/v4/public_mode_example_test.go37
-rw-r--r--lib/paseto/v4/public_mode_test.go111
-rw-r--r--lib/paseto/v4/testdata/public.json51
-rw-r--r--lib/paseto/v4/testdata/public.json.license2
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>