From b0795552ad0b5f4e57553cc91c887ef32bfa5f70 Mon Sep 17 00:00:00 2001 From: Shulhan Date: Mon, 30 Mar 2026 06:35:34 +0700 Subject: paseto/v4: implements API to Pack and Unpack Message for PublicMode The Pack method returns the signed [paseto.Message] as public token. The token then verified and decoded into Message using the Unpack method. --- lib/paseto/message.go | 77 ++++++++++++- lib/paseto/v4/public_mode.go | 173 ++++++++++++++++++++++-------- lib/paseto/v4/public_mode_example_test.go | 115 ++++++++++++++++++++ 3 files changed, 321 insertions(+), 44 deletions(-) diff --git a/lib/paseto/message.go b/lib/paseto/message.go index 47718f9e..e9dca297 100644 --- a/lib/paseto/message.go +++ b/lib/paseto/message.go @@ -3,8 +3,83 @@ package paseto +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "strings" + "time" +) + // Message defines the payload be signed and verified by sender/receiver. type Message struct { - Payload Payload + // The following fields are filled after Unpack and MUST not be used + // directly. + RawPayload []byte + RawFooter []byte + Sig []byte + PAE []byte + + // The Payload and optional Footer to be packed and signed into token. Footer Footer + Payload Payload +} + +// NewMessage returns [Message] where all fields except [Payload.Data] and +// [Footer.Data] are set using send, receiver, subject, and current time. +func NewMessage(sender, receiver Peer, subject string) (msg *Message) { + now := time.Now().UTC().Unix() + msg = &Message{ + Payload: Payload{ + Issuer: sender.ID, + Audience: receiver.ID, + Subject: subject, + IssuedAt: now, + NotBefore: now, + ExpiredAt: now + DefaultTTL, + }, + Footer: Footer{ + PeerID: sender.ID, + }, + } + return msg +} + +// Unpack returns the decoded token into a [Message]. +func (msg *Message) Unpack(header, token string, implicit []byte) (err error) { + logp := `Unpack` + + // Step 3: verify the header and unpack the footer if it exists. + token, found := strings.CutPrefix(token, header) + if !found { + return fmt.Errorf(`%s: invalid header, want %s`, logp, header) + } + token, footerb64, found := strings.Cut(token, `.`) + if found { + msg.RawFooter, err = base64.RawURLEncoding.DecodeString(footerb64) + if err != nil { + return fmt.Errorf(`%s: invalid footer: %w`, logp, err) + } + } + + // Step 4: Decodes the payload. + paysig, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return fmt.Errorf(`%s: %w`, logp, err) + } + lenpaysig := len(paysig) + if lenpaysig <= ed25519.SignatureSize { + return fmt.Errorf(`%s: invalid payload size %d`, logp, lenpaysig) + } + msg.RawPayload = paysig[:lenpaysig-64] + msg.Sig = paysig[lenpaysig-64:] + + // Step 5: Generate PAE. + msg.PAE, err = PreAuthEncode([]byte(header), msg.RawPayload, + msg.RawFooter, implicit) + if err != nil { + return fmt.Errorf(`%s: %w`, logp, err) + } + + return nil } diff --git a/lib/paseto/v4/public_mode.go b/lib/paseto/v4/public_mode.go index 3ea901eb..605be124 100644 --- a/lib/paseto/v4/public_mode.go +++ b/lib/paseto/v4/public_mode.go @@ -7,53 +7,126 @@ import ( "bytes" "crypto/ed25519" "encoding/base64" + "encoding/json" + "errors" "fmt" "slices" - "strings" + "sync" "git.sr.ht/~shulhan/pakakeh.go/lib/paseto" ) const publicHeader = `v4.public.` +// List of errors for [PublicMode.Unpack] and [PublicMode.Verify]. +var ( + ErrSignature = errors.New(`invalid signature`) +) + // PublicMode contains ed25519 private and public key for signing and // verifying message. type PublicMode struct { - priv ed25519.PrivateKey - pub ed25519.PublicKey + peers map[string]paseto.Peer + + PrivateKey ed25519.PrivateKey + PublicKey ed25519.PublicKey + + sync.Mutex } // 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) + pmode = &PublicMode{ + peers: make(map[string]paseto.Peer), + } + pmode.PrivateKey = ed25519.NewKeyFromSeed(seed) + pmode.PublicKey = pmode.PrivateKey.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) { +// AddPeer adds the peer p to list of known peers for verifying incoming +// token. +// The only required fields in [paseto.Peer] is ID and Public. +func (pmode *PublicMode) AddPeer(peer paseto.Peer) (err error) { + if len(peer.ID) == 0 { + return errors.New(`empty peer ID`) + } + if len(peer.Public) == 0 { + return errors.New(`empty public key`) + } + pmode.Lock() + pmode.peers[peer.ID] = peer + pmode.Unlock() + return nil +} + +// GetPeer returns the Peer and true if ID found in list of peers. +func (pmode *PublicMode) GetPeer(id string) (peer paseto.Peer, ok bool) { + pmode.Lock() + peer, ok = pmode.peers[id] + pmode.Unlock() + return peer, ok +} + +// Pack returns the signed [paseto.Message] as public token. +// The token can be verified and decoded into Message again using +// [PublicMode.Unpack]. +func (pmode *PublicMode) Pack(msg paseto.Message, implicit []byte) ( + token string, err error, +) { + logp := `Pack` + + msg.RawPayload, err = json.Marshal(msg.Payload) + if err != nil { + return ``, fmt.Errorf(`%s: %w`, logp, err) + } + msg.RawFooter, err = json.Marshal(msg.Footer) + if err != nil { + return ``, fmt.Errorf(`%s: %w`, logp, err) + } + token, err = pmode.Sign(msg.RawPayload, msg.RawFooter, implicit) + if err != nil { + return ``, fmt.Errorf(`%s: %w`, logp, err) + } + return token, nil +} + +// RemovePeer removes the peer from list of peers based on its ID. +func (pmode *PublicMode) RemovePeer(id string) { + pmode.Lock() + delete(pmode.peers, id) + pmode.Unlock() +} + +// Sign returns the public token that has been signed with private key. +// The token contains header, payload, signature, and optional footer. +// +// The token can be verified to get the payload and footer again using +// [PublicMode.Verify]. +func (pmode *PublicMode) Sign(payload, 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) + pae, err := paseto.PreAuthEncode([]byte(publicHeader), + payload, footer, implicit) if err != nil { return ``, fmt.Errorf(`%s: %w`, logp, err) } // Step 4: Sign pae. - sig := ed25519.Sign(pmode.priv, pae) + sig := ed25519.Sign(pmode.PrivateKey, 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)) + paysig := slices.Concat(payload, sig) + n := base64.RawURLEncoding.EncodedLen(len(paysig)) b64 := make([]byte, n) - base64.RawURLEncoding.Encode(b64, payload) + base64.RawURLEncoding.Encode(b64, paysig) buf.Write(b64) if len(footer) != 0 { @@ -66,47 +139,61 @@ func (pmode *PublicMode) Sign(msg, footer, implicit []byte) (token string, err e 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` +// Unpack verifies the token and returns it as [paseto.Message]. +// The token is verified using public key from peer ID in the footer, that +// should has been added previously using [PublicMode.AddPeer]. +// +// Note that, the [paseto.Payload] is not validated yet. +// It is up to the user to validate and handle the error. +func (pmode *PublicMode) Unpack(token string, implicit []byte, msg *paseto.Message) ( + err error, +) { + logp := `Unpack` + + err = msg.Unpack(publicHeader, token, implicit) + if err != nil { + return fmt.Errorf(`%s: %w`, logp, err) + } - // 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) + err = json.Unmarshal(msg.RawFooter, &msg.Footer) + if err != nil { + return fmt.Errorf(`%s: %w`, logp, err) } - 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) - } + sender, ok := pmode.GetPeer(msg.Footer.PeerID) + if !ok { + return fmt.Errorf(`%s: unknown peer ID %s`, logp, msg.Footer.PeerID) } - // Step 4: Decodes the payload. - payload, err := base64.RawURLEncoding.DecodeString(token) - if err != nil { - return nil, nil, fmt.Errorf(`%s: %w`, logp, err) + if !ed25519.Verify(sender.Public, msg.PAE, msg.Sig) { + return fmt.Errorf(`%s: %w`, logp, ErrSignature) } - 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)) + + err = json.Unmarshal(msg.RawPayload, &msg.Payload) + if err != nil { + return fmt.Errorf(`%s: %w`, logp, err) } - msg = payload[:lenpayload-64] - sig := payload[lenpayload-64:] - // Step 5: Generate PAE. - pae, err := paseto.PreAuthEncode([]byte(publicHeader), msg, footer, implicit) + return nil +} + +// Verify returns the payload and optional footer inside the token only if its +// signature is valid. +// The token is verified using the public key belong to PublicMode. +func (pmode *PublicMode) Verify(token string, implicit []byte) ( + payload, footer []byte, err error, +) { + logp := `Verify` + msg := &paseto.Message{} + + err = msg.Unpack(publicHeader, token, 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) + if !ed25519.Verify(pmode.PublicKey, msg.PAE, msg.Sig) { + return nil, nil, fmt.Errorf(`%s: %w`, logp, ErrSignature) } - return msg, footer, nil + return msg.RawPayload, msg.RawFooter, nil } diff --git a/lib/paseto/v4/public_mode_example_test.go b/lib/paseto/v4/public_mode_example_test.go index 38c3df0f..7d2ee53a 100644 --- a/lib/paseto/v4/public_mode_example_test.go +++ b/lib/paseto/v4/public_mode_example_test.go @@ -4,9 +4,14 @@ package pasetov4 import ( + "encoding/base64" "encoding/hex" + "encoding/json" "fmt" "log" + "strings" + + "git.sr.ht/~shulhan/pakakeh.go/lib/paseto" ) func ExamplePublicMode() { @@ -35,3 +40,113 @@ func ExamplePublicMode() { // {"data":"signed message!"} // {"kid":1000} } + +type DummyData struct { + Email string `json:"email"` +} +type DummyFooterData struct { + Quote string `json:"quote"` +} + +func ExamplePublicMode_Pack() { + createPeer := func(id, secret string) (peer paseto.Peer, pmode *PublicMode) { + seed, err := hex.DecodeString(secret) + if err != nil { + log.Fatal(err) + } + pmode = NewPublicMode(seed) + peer = paseto.Peer{ + ID: id, + Private: pmode.PrivateKey, + Public: pmode.PublicKey, + } + return peer, pmode + } + sender, senderpm := createPeer( + `john@example.com`, + `b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774`, + ) + receiver, _ := createPeer( + `jane@example.org`, + `2222222222222222222222222222222222222222222222222222222222222222`, + ) + + sendMsg := paseto.NewMessage(sender, receiver, `hello`) + sendMsg.Payload.Data = DummyData{ + Email: `hello@example.com`, + } + sendMsg.Footer.Data = DummyFooterData{ + Quote: `Live to eat`, + } + + token, err := senderpm.Pack(*sendMsg, nil) + if err != nil { + log.Fatal(err) + } + + pieces := strings.Split(token, `.`) + fmt.Printf("Header: %s.%s\n", pieces[0], pieces[1]) + + paysig, _ := base64.RawURLEncoding.DecodeString(pieces[2]) + payload := paysig[:len(paysig)-64] + pdata := paseto.Payload{ + Data: &DummyData{}, + } + _ = json.Unmarshal(payload, &pdata) + fmt.Printf("Payload.Data: %+v\n", pdata.Data) + + footer, _ := base64.RawURLEncoding.DecodeString(pieces[3]) + fmt.Printf("Footer: %s", footer) + + // Output: + // Header: v4.public + // Payload.Data: &{Email:hello@example.com} + // Footer: {"data":{"quote":"Live to eat"},"peer_id":"john@example.com"} +} + +func ExamplePublicMode_Unpack() { + createPeer := func(id, secret string) (peer paseto.Peer, pmode *PublicMode) { + seed, err := hex.DecodeString(secret) + if err != nil { + log.Fatal(err) + } + pmode = NewPublicMode(seed) + peer = paseto.Peer{ + ID: id, + Private: pmode.PrivateKey, + Public: pmode.PublicKey, + } + return peer, pmode + } + sender, _ := createPeer( + `john@example.com`, + `b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774`, + ) + _, recvpm := createPeer( + `jane@example.org`, + `2222222222222222222222222222222222222222222222222222222222222222`, + ) + recvpm.AddPeer(sender) + + token := `v4.public.eyJpc3MiOiJqb2huQGV4YW1wbGUuY29tIiwic3ViIjoiaGVsbG8iLCJhdWQiOiJqYW5lQGV4YW1wbGUub3JnIiwiZXhwIjoxNzc0ODE5NzYzLCJuYmYiOjE3NzQ4MTk3MDMsImlhdCI6MTc3NDgxOTcwMywiZGF0YSI6eyJlbWFpbCI6ImhlbGxvQGV4YW1wbGUuY29tIn19Ui741xeDUhQE2w31smKyAJ_d-fhMH20SRLkIY3lDbIBfj_tq7es9-H2wQR3q6EZZRPrlAPxEug712IEgomTnBA.eyJkYXRhIjp7InF1b3RlIjoiTGlmZSBpcyBmb3IgZWF0aW5nIn0sInBlZXJfaWQiOiJqb2huQGV4YW1wbGUuY29tIn0` + pdata := DummyData{} + fdata := DummyFooterData{} + recvMsg := paseto.Message{ + Payload: paseto.Payload{ + Data: &pdata, + }, + Footer: paseto.Footer{ + Data: &fdata, + }, + } + err := recvpm.Unpack(token, nil, &recvMsg) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%+v\n", recvMsg.Payload.Data) + fmt.Printf("%+v\n", recvMsg.Footer.Data) + + // Output: + // &{Email:hello@example.com} + // &{Quote:Life is for eating} +} -- cgit v1.3