aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/paseto/v4/local_mode.go205
-rw-r--r--lib/paseto/v4/local_mode_example_test.go36
-rw-r--r--lib/paseto/v4/local_mode_test.go119
-rw-r--r--lib/paseto/v4/pasetov4.go9
-rw-r--r--lib/paseto/v4/testdata/local.json135
-rw-r--r--lib/paseto/v4/testdata/local.json.license2
6 files changed, 506 insertions, 0 deletions
diff --git a/lib/paseto/v4/local_mode.go b/lib/paseto/v4/local_mode.go
new file mode 100644
index 00000000..507a1484
--- /dev/null
+++ b/lib/paseto/v4/local_mode.go
@@ -0,0 +1,205 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info>
+
+package pasetov4
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "slices"
+ "strings"
+
+ "git.sr.ht/~shulhan/pakakeh.go/lib/paseto"
+ "golang.org/x/crypto/blake2b"
+ "golang.org/x/crypto/chacha20"
+)
+
+const localHeader = `v4.local.`
+const localKeyPrefixEncryption = `paseto-encryption-key`
+const localKeyPrefixAuth = `paseto-auth-key-for-aead`
+
+// LocalMode contains the secret for Paseto v4 local protocol.
+type LocalMode struct {
+ v [32]byte
+}
+
+// NewLocalMode returns new local key.
+func NewLocalMode(key [32]byte) (lmode *LocalMode) {
+ lmode = &LocalMode{}
+ copy(lmode.v[:], key[:])
+ return lmode
+}
+
+// Encrypt encrypts the plain text with optional footer and implicit using
+// LocalMode as defined in [paseto-v4-encrypt].
+//
+// [paseto-v4-encrypt]: https://github.com/paseto-standard/paseto-spec/blob/master/docs/01-Protocol-Versions/Version4.md#encrypt
+func (lmode LocalMode) Encrypt(plain, footer, implicit []byte) (token string, err error) {
+ logp := `Encrypt`
+
+ var salt [32]byte
+ rand.Read(salt[:])
+
+ token, err = lmode.encrypt(salt, plain, footer, implicit)
+ if err != nil {
+ return ``, fmt.Errorf(`%s: %w`, logp, err)
+ }
+ return token, nil
+}
+
+func (lmode LocalMode) encrypt(salt [32]byte, plain, footer, implicit []byte) (string, error) {
+ // Step 4: generate encryption key, authentication key, and nonce.
+ encKey, authKey, nonce, err := lmode.gen(salt)
+ if err != nil {
+ return ``, err
+ }
+
+ // Step 5: encrpyt the plain text.
+ cipher, err := chacha20.NewUnauthenticatedCipher(encKey, nonce)
+ if err != nil {
+ return ``, err
+ }
+
+ ciphertext := make([]byte, len(plain))
+ cipher.XORKeyStream(ciphertext, plain)
+
+ // Step 6: generate pre-authentication encoding.
+ pae, err := paseto.PreAuthEncode([]byte(localHeader), salt[:], ciphertext, footer, implicit)
+ if err != nil {
+ return ``, err
+ }
+
+ // Step 7: MAC the pae.
+ hash, err := blake2b.New256(authKey)
+ if err != nil {
+ return ``, err
+ }
+ hash.Write(pae)
+ mac := hash.Sum(nil)
+
+ // Step 8: create the paseto token from cipher text and mac.
+ var buf bytes.Buffer
+
+ buf.WriteString(localHeader)
+
+ payload := slices.Concat(salt[:], ciphertext, mac)
+ 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
+}
+
+// Decrypt returns the plain text of encrypted message inside token with
+// optional footer.
+func (lmode LocalMode) Decrypt(token string, implicit []byte) (plain, footer []byte, err error) {
+ logp := `Decrypt`
+
+ // Step 3: verify the header.
+ token, found := strings.CutPrefix(token, localHeader)
+ if !found {
+ return nil, nil, fmt.Errorf(`%s: invalid header, want %s`, logp, localHeader)
+ }
+
+ 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)
+ }
+ }
+
+ payload, err := base64.RawURLEncoding.DecodeString(token)
+ if err != nil {
+ return nil, nil, fmt.Errorf(`%s: %w`, logp, err)
+ }
+
+ // Step 4: decode the
+ if len(payload) <= 64 { // 64 is size of nonce + mac.
+ return nil, nil, fmt.Errorf(`%s: invalid payload length`, logp)
+ }
+
+ var salt [32]byte
+ copy(salt[:], payload[:32])
+ mac := payload[len(payload)-32:]
+ ciphertext := payload[32 : len(payload)-32]
+
+ // Step 5: generate encryption key, authentication key, and nonce.
+ encKey, authKey, nonce, err := lmode.gen(salt)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Step 6: generate pre-authentication encoding.
+ pae, err := paseto.PreAuthEncode([]byte(localHeader), salt[:], ciphertext, footer, implicit)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Step 7: MAC the pae.
+ hash, err := blake2b.New256(authKey)
+ if err != nil {
+ return nil, nil, err
+ }
+ hash.Write(pae)
+ gotmac := hash.Sum(nil)
+
+ // Step 8: Compare MAC.
+ if !bytes.Equal(mac, gotmac) {
+ return nil, nil, fmt.Errorf(`%s: MAC mismatch`, logp)
+ }
+
+ // Step 9: Decrypt the cipher text.
+ cipher, err := chacha20.NewUnauthenticatedCipher(encKey, nonce)
+ if err != nil {
+ return nil, nil, fmt.Errorf(`%s: %w`, logp, err)
+ }
+
+ plain = make([]byte, len(ciphertext))
+ cipher.XORKeyStream(plain, ciphertext)
+
+ return plain, footer, nil
+}
+
+// gen generates new encryption key, authentication key, and nonce.
+func (lmode LocalMode) gen(salt [32]byte) (encKey, authKey, nonce []byte, err error) {
+ logp := `gen`
+
+ hash, err := blake2b.New(56, lmode.v[:])
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf(`%s: %w`, logp, err)
+ }
+ hash.Write([]byte(localKeyPrefixEncryption))
+ hash.Write(salt[:])
+ out := hash.Sum(nil)
+ encKey = out[:32]
+ nonce = out[32:56]
+
+ // Create new hash with 32 bytes size.
+ hash, err = blake2b.New256(lmode.v[:])
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf(`%s: %w`, logp, err)
+ }
+ hash.Write([]byte(localKeyPrefixAuth))
+ hash.Write(salt[:])
+ authKey = hash.Sum(nil)
+
+ return encKey, authKey, nonce, nil
+}
+
+// Hex returns the key as hexadecimal string.
+func (lmode LocalMode) Hex() string {
+ return hex.EncodeToString(lmode.v[:])
+}
diff --git a/lib/paseto/v4/local_mode_example_test.go b/lib/paseto/v4/local_mode_example_test.go
new file mode 100644
index 00000000..a9708935
--- /dev/null
+++ b/lib/paseto/v4/local_mode_example_test.go
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info>
+
+package pasetov4
+
+import (
+ "encoding/hex"
+ "fmt"
+ "log"
+)
+
+func ExampleLocalMode() {
+ secret := `707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f`
+ key, err := hex.DecodeString(secret)
+ if err != nil {
+ log.Fatal(err)
+ }
+ lmode := NewLocalMode([32]byte(key))
+ plain := []byte(`{"data":"Hello, secret!"}`)
+ footer := []byte(`{"kid":1000}`)
+ token, err := lmode.Encrypt(plain, footer, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ gotPlain, gotFooter, err := lmode.Decrypt(token, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println(string(gotPlain))
+ fmt.Println(string(gotFooter))
+
+ // Output:
+ // {"data":"Hello, secret!"}
+ // {"kid":1000}
+}
diff --git a/lib/paseto/v4/local_mode_test.go b/lib/paseto/v4/local_mode_test.go
new file mode 100644
index 00000000..144b8c74
--- /dev/null
+++ b/lib/paseto/v4/local_mode_test.go
@@ -0,0 +1,119 @@
+// 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 testVectorLocal struct {
+ Name string
+ Key string
+ Nonce string
+ Token string
+ Payload string
+ Footer string
+ Implicit string `json:"implicit-assertion"`
+ key [32]byte
+ salt [32]byte
+ ExpectFail bool `json:"expect-fail"`
+}
+
+func (tvl *testVectorLocal) init() {
+ v, err := hex.DecodeString(tvl.Key)
+ if err != nil {
+ log.Fatalf(`key: %s`, err)
+ }
+ copy(tvl.key[:], v)
+
+ v, err = hex.DecodeString(tvl.Nonce)
+ if err != nil {
+ log.Fatalf(`key: %s`, err)
+ }
+ copy(tvl.salt[:], v)
+}
+
+func TestNewLocalMode(t *testing.T) {
+ v, err := hex.DecodeString(`707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lmode := NewLocalMode([32]byte(v))
+ hex := lmode.Hex()
+ test.Assert(t, `LocalMode hex length`, 64, len(hex))
+}
+
+func TestLocalMode_Encrypt(t *testing.T) {
+ logp := `TestLocalMode_Encrypt`
+
+ localb, err := os.ReadFile(`testdata/local.json`)
+ if err != nil {
+ t.Fatalf(`%s: %s`, logp, err)
+ }
+ listCase := []testVectorLocal{}
+ err = json.Unmarshal(localb, &listCase)
+ if err != nil {
+ t.Fatalf(`%s: %s`, logp, err)
+ }
+
+ for _, tc := range listCase {
+ tc.init()
+ lmode := NewLocalMode(tc.key)
+ got, err := lmode.encrypt(tc.salt, []byte(tc.Payload),
+ []byte(tc.Footer), []byte(tc.Implicit))
+ if err != nil {
+ if tc.ExpectFail {
+ continue
+ }
+ t.Fatalf(`%s: %s`, logp, err)
+ }
+ if tc.Token == string(got) {
+ continue
+ }
+ if tc.ExpectFail {
+ continue
+ }
+ t.Fatalf(`%s: token not match`, tc.Name)
+ }
+}
+
+func TestLocalMode_Decrypt(t *testing.T) {
+ logp := `TestLocalMode_Decrypt`
+
+ localb, err := os.ReadFile(`testdata/local.json`)
+ if err != nil {
+ t.Fatalf(`%s: %s`, logp, err)
+ }
+ listCase := []testVectorLocal{}
+ err = json.Unmarshal(localb, &listCase)
+ if err != nil {
+ t.Fatalf(`%s: %s`, logp, err)
+ }
+
+ for _, tc := range listCase {
+ tc.init()
+ lmode := NewLocalMode(tc.key)
+ gotPlain, gotFooter, err := lmode.Decrypt(tc.Token, []byte(tc.Implicit))
+ if err != nil {
+ if tc.ExpectFail {
+ continue
+ }
+ t.Fatalf(`%s: %s`, logp, err)
+ }
+ if tc.Payload != string(gotPlain) && tc.ExpectFail {
+ continue
+ }
+ if tc.Footer != string(gotFooter) && tc.ExpectFail {
+ continue
+ }
+ test.Assert(t, tc.Name+` payload`, tc.Payload, string(gotPlain))
+ test.Assert(t, tc.Name+` footer`, tc.Footer, string(gotFooter))
+ }
+}
diff --git a/lib/paseto/v4/pasetov4.go b/lib/paseto/v4/pasetov4.go
new file mode 100644
index 00000000..3897186c
--- /dev/null
+++ b/lib/paseto/v4/pasetov4.go
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info>
+
+// Package pasetov4 provides a simple, ready to use, opinionated
+// implementation of Platform-Agnostic SEcurity TOkens (PASETO) version 4 as
+// defined in [paseto-v4].
+//
+// [paseto-v4]: https://github.com/paseto-standard/paseto-spec/blob/master/docs/01-Protocol-Versions/Version4.md
+package pasetov4
diff --git a/lib/paseto/v4/testdata/local.json b/lib/paseto/v4/testdata/local.json
new file mode 100644
index 00000000..ae73f38a
--- /dev/null
+++ b/lib/paseto/v4/testdata/local.json
@@ -0,0 +1,135 @@
+[
+ {
+ "name": "4-E-1",
+ "expect-fail": false,
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+ "token": "v4.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAr68PS4AXe7If_ZgesdkUMvSwscFlAl1pk5HC0e8kApeaqMfGo_7OpBnwJOAbY9V7WU6abu74MmcUE8YWAiaArVI8XJ5hOb_4v9RmDkneN0S92dx0OW4pgy7omxgf3S8c3LlQg",
+ "payload": "{\"data\":\"this is a secret message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "",
+ "implicit-assertion": ""
+ },
+ {
+ "name": "4-E-2",
+ "expect-fail": false,
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+ "token": "v4.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAr68PS4AXe7If_ZgesdkUMvS2csCgglvpk5HC0e8kApeaqMfGo_7OpBnwJOAbY9V7WU6abu74MmcUE8YWAiaArVI8XIemu9chy3WVKvRBfg6t8wwYHK0ArLxxfZP73W_vfwt5A",
+ "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "",
+ "implicit-assertion": ""
+ },
+ {
+ "name": "4-E-3",
+ "expect-fail": false,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WkwMsYXw6FSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t6-tyebyWG6Ov7kKvBdkrrAJ837lKP3iDag2hzUPHuMKA",
+ "payload": "{\"data\":\"this is a secret message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "",
+ "implicit-assertion": ""
+ },
+ {
+ "name": "4-E-4",
+ "expect-fail": false,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WiA8rd3wgFSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t4gt6TiLm55vIH8c_lGxxZpE3AWlH4WTR0v45nsWoU3gQ",
+ "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "",
+ "implicit-assertion": ""
+ },
+ {
+ "name": "4-E-5",
+ "expect-fail": false,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WkwMsYXw6FSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t4x-RMNXtQNbz7FvFZ_G-lFpk5RG3EOrwDL6CgDqcerSQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+ "payload": "{\"data\":\"this is a secret message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+ "implicit-assertion": ""
+ },
+ {
+ "name": "4-E-6",
+ "expect-fail": false,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WiA8rd3wgFSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t6pWSA5HX2wjb3P-xLQg5K5feUCX4P2fpVK3ZLWFbMSxQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+ "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+ "implicit-assertion": ""
+ },
+ {
+ "name": "4-E-7",
+ "expect-fail": false,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WkwMsYXw6FSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t40KCCWLA7GYL9KFHzKlwY9_RnIfRrMQpueydLEAZGGcA.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+ "payload": "{\"data\":\"this is a secret message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+ "implicit-assertion": "{\"test-vector\":\"4-E-7\"}"
+ },
+ {
+ "name": "4-E-8",
+ "expect-fail": false,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WiA8rd3wgFSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t5uvqQbMGlLLNYBc7A6_x7oqnpUK5WLvj24eE4DVPDZjw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+ "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+ "implicit-assertion": "{\"test-vector\":\"4-E-8\"}"
+ },
+ {
+ "name": "4-E-9",
+ "expect-fail": false,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WiA8rd3wgFSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t6tybdlmnMwcDMw0YxA_gFSE_IUWl78aMtOepFYSWYfQA.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+ "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+ "footer": "arbitrary-string-that-isn't-json",
+ "implicit-assertion": "{\"test-vector\":\"4-E-9\"}"
+ },
+ {
+ "name": "4-F-1",
+ "expect-fail": true,
+ "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.local.vngXfCISbnKgiP6VWGuOSlYrFYU300fy9ijW33rznDYgxHNPwWluAY2Bgb0z54CUs6aYYkIJ-bOOOmJHPuX_34Agt_IPlNdGDpRdGNnBz2MpWJvB3cttheEc1uyCEYltj7wBQQYX.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+ "payload": null,
+ "footer": "arbitrary-string-that-isn't-json",
+ "implicit-assertion": "{\"test-vector\":\"4-F-1\"}"
+ },
+ {
+ "name": "4-F-3",
+ "expect-fail": true,
+ "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v3.local.23e_2PiqpQBPvRFKzB0zHhjmxK3sKo2grFZRRLM-U7L0a8uHxuF9RlVz3Ic6WmdUUWTxCaYycwWV1yM8gKbZB2JhygDMKvHQ7eBf8GtF0r3K0Q_gF1PXOxcOgztak1eD1dPe9rLVMSgR0nHJXeIGYVuVrVoLWQ.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+ "payload": null,
+ "footer": "arbitrary-string-that-isn't-json",
+ "implicit-assertion": "{\"test-vector\":\"4-F-3\"}"
+ },
+ {
+ "name": "4-F-4",
+ "expect-fail": true,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAr68PS4AXe7If_ZgesdkUMvSwscFlAl1pk5HC0e8kApeaqMfGo_7OpBnwJOAbY9V7WU6abu74MmcUE8YWAiaArVI8XJ5hOb_4v9RmDkneN0S92dx0OW4pgy7omxgf3S8c3LlQh",
+ "payload": null,
+ "footer": "",
+ "implicit-assertion": ""
+ },
+ {
+ "name": "4-F-5",
+ "expect-fail": true,
+ "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+ "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+ "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WkwMsYXw6FSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t4x-RMNXtQNbz7FvFZ_G-lFpk5RG3EOrwDL6CgDqcerSQ==.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+ "payload": null,
+ "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+ "implicit-assertion": ""
+ }
+]
diff --git a/lib/paseto/v4/testdata/local.json.license b/lib/paseto/v4/testdata/local.json.license
new file mode 100644
index 00000000..870a8126
--- /dev/null
+++ b/lib/paseto/v4/testdata/local.json.license
@@ -0,0 +1,2 @@
+SPDX-License-Identifier: BSD-3-Clause
+SPDX-FileCopyrightText: 2021 Paragon Initiative Enterprises <security at paragonie dot com>