]> Cypherpunks repositories - keks.git/commitdiff
enveloped-data
authorSergey Matveev <stargrave@stargrave.org>
Sat, 18 Jan 2025 14:48:43 +0000 (17:48 +0300)
committerSergey Matveev <stargrave@stargrave.org>
Tue, 21 Jan 2025 15:45:36 +0000 (18:45 +0300)
21 files changed:
go/pki/algo.go
go/pki/av.go
go/pki/cer.go
go/pki/cmd/kekscertool/main.go
go/pki/cmd/keksenvtool/chapoly.go [new file with mode: 0644]
go/pki/cmd/keksenvtool/main.go [new file with mode: 0644]
go/pki/cmd/kekssigntool/main.go [moved from go/pki/cmd/kekssdtool/main.go with 87% similarity]
go/pki/go.mod
go/pki/signed-data.go
go/pki/sntrup4591761-x25519/algo.go [new file with mode: 0644]
go/pki/sntrup4591761-x25519/go.mod [new file with mode: 0644]
go/pki/sntrup4591761-x25519/go.sum [new file with mode: 0644]
go/pki/sntrup4591761-x25519/kp.go [new file with mode: 0644]
spec/format/cer.texi
spec/format/enveloped-data.cddl [new file with mode: 0644]
spec/format/enveloped-data.texi [new file with mode: 0644]
spec/format/index.texi
spec/format/private-key.texi
spec/format/registry.texi
spec/format/signed-data.cddl
spec/format/signed-data.texi

index c85029b737eb43104600a4a3dbcf0cdbfde840afec798bbfda003ad4a87da09f..d0abc7d4905a2e5857cd26648f790baaac846e7356f3bc0a584dfa6dd3742ffc 100644 (file)
@@ -1,12 +1,17 @@
 package pki
 
 import (
-       "go.cypherpunks.su/keks/pki/ed25519-blake2b"
+       ed25519blake2b "go.cypherpunks.su/keks/pki/ed25519-blake2b"
        "go.cypherpunks.su/keks/pki/gost"
+       sntrup4591761x25519 "go.cypherpunks.su/keks/pki/sntrup4591761-x25519"
 )
 
 const (
-       Ed25519BLAKE2b = ed25519blake2b.Ed25519BLAKE2b
-       GOST3410256A   = gost.GOST3410256A
-       GOST3410512C   = gost.GOST3410512C
+       Ed25519BLAKE2b                 = ed25519blake2b.Ed25519BLAKE2b
+       GOST3410256A                   = gost.GOST3410256A
+       GOST3410512C                   = gost.GOST3410512C
+       SNTRUP4591761X25519            = sntrup4591761x25519.SNTRUP4591761X25519
+       SNTRUP4591761X25519HKDFBLAKE2b = sntrup4591761x25519.SNTRUP4591761X25519HKDFBLAKE2b
+       BalloonBLAKE2bHKDF             = "balloon-blake2b-hkdf"
+       ChaCha20Poly1305               = "chacha20poly1305"
 )
index 289bac3ba89e70569a52de1ee9c480586b17f1b5154b8f1c03f6bebb1f79858f..e9cb679adf3faf72e09ebfbc2c0428a30868bcff93c46a9f1f6be393a3fd0e5e 100644 (file)
@@ -37,7 +37,7 @@ type AV struct {
 func (av *AV) Id() (id uuid.UUID) {
        var hasher hash.Hash
        switch av.A {
-       case Ed25519BLAKE2b:
+       case Ed25519BLAKE2b, SNTRUP4591761X25519:
                hasher = pkihash.ByName(pkihash.BLAKE2b256)
        case GOST3410256A, GOST3410512C:
                hasher = pkihash.ByName(pkihash.Streebog256)
index 02a9120aef943323d64037027138bdfbadf350796c0151935c75212818338c6a..528aac58e724e90b408adb31a4f75707d907d27fcbfbac16b39e7b35dd90e5a3 100644 (file)
@@ -31,6 +31,7 @@ import (
 const (
        KUCA  = "ca"  // CA-capable key usage
        KUSig = "sig" // Signing-capable key usage
+       KUKEM = "kem" // Key-encapsulation-mechanism key usage
 )
 
 // Public key.
@@ -55,9 +56,6 @@ func (sd *SignedData) CerParse() error {
        if sd.Load.T != "cer" {
                return errors.New("CerParse: wrong load type")
        }
-       if len(sd.Sigs) == 0 {
-               return errors.New("CerParse: missing sigs")
-       }
        for _, sig := range sd.Sigs {
                if sig.TBS.Hashes != nil {
                        return errors.New("CerParse: prehashed SignedData")
index 2658f41ebb27d7dca1cb292ad08ae56ae70281c9d9b5a46aab1d522f39f45785..2909a51792ed7f51cca74984a6d0e797968ffe64b52233aafded508a50f983b9 100644 (file)
@@ -28,6 +28,7 @@ import (
        "go.cypherpunks.su/keks/pki"
        ed25519blake2b "go.cypherpunks.su/keks/pki/ed25519-blake2b"
        "go.cypherpunks.su/keks/pki/gost"
+       sntrup4591761x25519 "go.cypherpunks.su/keks/pki/sntrup4591761-x25519"
        "go.cypherpunks.su/keks/pki/utils"
 )
 
@@ -75,6 +76,7 @@ func main() {
        prvPath := flag.String("prv", "", "Path to private key file")
        cerPath := flag.String("cer", "", "Path to certificate file")
        verify := flag.Bool("verify", false, "Verify provided -cer with -ca-cer")
+       onlyLoad := flag.Bool("only-load", false, "Store only cer-load in -cer")
 
        flag.Parse()
        log.SetFlags(log.Lshortfile)
@@ -150,6 +152,8 @@ func main() {
                        prv, prvRaw, pub, err = ed25519blake2b.NewKeypair()
                case pki.GOST3410256A, pki.GOST3410512C:
                        prv, prvRaw, pub, err = gost.NewKeypair(*algo)
+               case pki.SNTRUP4591761X25519:
+                       prvRaw, pub, err = sntrup4591761x25519.NewKeypair()
                default:
                        err = errors.New("unknown -algo specified")
                }
@@ -176,6 +180,16 @@ func main() {
        if len(ku) > 0 {
                cerLoad.KU = &ku
        }
+       var data []byte
+       if *onlyLoad {
+               if data, err = keks.EncodeBuf(cerLoad, nil); err != nil {
+                       log.Fatal(err)
+               }
+               if err = os.WriteFile(*cerPath, data, 0o666); err != nil {
+                       log.Fatal(err)
+               }
+               return
+       }
        var caCerLoad *pki.CerLoad
        if caPrv == nil {
                caPrv = prv
@@ -184,18 +198,16 @@ func main() {
                caCerLoad = caCers[0].CerLoad()
        }
        sd := pki.SignedData{Load: pki.SignedDataLoad{T: "cer", V: cerLoad}}
-       err = sd.CerIssueWith(caCerLoad, caPrv, since, till)
-       if err != nil {
-               log.Fatal(err)
+       if prv != nil {
+               if err = sd.CerIssueWith(caCerLoad, caPrv, since, till); err != nil {
+                       log.Fatal(err)
+               }
        }
 
-       var data []byte
-       data, err = keks.EncodeBuf(sd, nil)
-       if err != nil {
+       if data, err = keks.EncodeBuf(sd, nil); err != nil {
                log.Fatal(err)
        }
-       err = os.WriteFile(*cerPath, data, 0o666)
-       if err != nil {
+       if err = os.WriteFile(*cerPath, data, 0o666); err != nil {
                log.Fatal(err)
        }
 }
diff --git a/go/pki/cmd/keksenvtool/chapoly.go b/go/pki/cmd/keksenvtool/chapoly.go
new file mode 100644 (file)
index 0000000..8338222
--- /dev/null
@@ -0,0 +1,141 @@
+// kekscertool -- dealing with KEKS-encoded certificates utility
+// Copyright (C) 2024-2025 Sergey Matveev <stargrave@stargrave.org>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as
+// published by the Free Software Foundation, version 3 of the License.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public
+// License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "bufio"
+       "crypto/subtle"
+       "errors"
+       "io"
+       "os"
+
+       "golang.org/x/crypto/chacha20poly1305"
+)
+
+const ChaPolyChunkLen = 64 * 1024
+
+var ChaPolyPad = make([]byte, 16)
+
+func incr(data []byte) {
+       for i := len(data) - 1; i >= 0; i-- {
+               data[i]++
+               if data[i] != 0 {
+                       return
+               }
+       }
+}
+
+func demChaPolySeal(cek []byte) error {
+       ciph, err := chacha20poly1305.New(cek)
+       if err != nil {
+               return err
+       }
+       in := make([]byte, len(ChaPolyPad)+ChaPolyChunkLen)
+       out := make([]byte, len(ChaPolyPad)+ChaPolyChunkLen+ciph.Overhead())
+       br := bufio.NewReaderSize(os.Stdin, ChaPolyChunkLen)
+       bw := bufio.NewWriterSize(os.Stdout, len(out))
+       nonce := make([]byte, ciph.NonceSize())
+       var n int
+       var eof bool
+       for !eof {
+               n, err = io.ReadFull(br, in[len(ChaPolyPad):])
+               if err != nil {
+                       if err != io.ErrUnexpectedEOF {
+                               return err
+                       }
+                       eof = true
+               }
+               incr(nonce[:len(nonce)-1])
+               if n != ChaPolyChunkLen {
+                       nonce[len(nonce)-1] = 0x01
+               }
+               _, err = bw.Write(ciph.Seal(out[:0], nonce, in[:len(ChaPolyPad)+n], nil))
+               if err != nil {
+                       return err
+               }
+       }
+       return bw.Flush()
+}
+
+func demChaPolyOpen(cek []byte) error {
+       ciph, err := chacha20poly1305.New(cek)
+       if err != nil {
+               return err
+       }
+       in := make([]byte, len(ChaPolyPad)+ChaPolyChunkLen+ciph.Overhead())
+       out := make([]byte, len(ChaPolyPad)+ChaPolyChunkLen)
+       var n int
+       var eof bool
+       var chunk []byte
+       br := bufio.NewReaderSize(os.Stdin, len(in))
+       bw := bufio.NewWriterSize(os.Stdout, ChaPolyChunkLen)
+       nonce := make([]byte, ciph.NonceSize())
+       for !eof {
+               n, err = io.ReadFull(br, in)
+               if err != nil {
+                       if err != io.ErrUnexpectedEOF {
+                               return err
+                       }
+                       eof = true
+               }
+               incr(nonce[:len(nonce)-1])
+               if n != len(in) {
+                       nonce[len(nonce)-1] = 0x01
+               }
+               chunk, err = ciph.Open(out[:0], nonce, in[:n], nil)
+               if err != nil {
+                       return err
+               }
+               if subtle.ConstantTimeCompare(chunk[:len(ChaPolyPad)], ChaPolyPad) != 1 {
+                       return errors.New("bad pad")
+               }
+               if _, err = bw.Write(chunk[len(ChaPolyPad):]); err != nil {
+                       return err
+               }
+       }
+       return bw.Flush()
+}
+
+func kemChaPolySeal(kek, cek []byte) ([]byte, error) {
+       ciph, err := chacha20poly1305.New(kek)
+       if err != nil {
+               return nil, err
+       }
+       nonce := make([]byte, ciph.NonceSize())
+       plaintext := append(ChaPolyPad, cek...)
+       return ciph.Seal(nil, nonce, plaintext, nil), nil
+}
+
+func kemChaPolyOpen(kek, ciphertext []byte, cekLenExpected int) ([]byte, error) {
+       ciph, err := chacha20poly1305.New(kek)
+       if err != nil {
+               return nil, err
+       }
+       nonce := make([]byte, ciph.NonceSize())
+       var cek []byte
+       cek, err = ciph.Open(nil, nonce, ciphertext, nil)
+       if err != nil {
+               return nil, err
+       }
+       if subtle.ConstantTimeCompare(cek[:len(ChaPolyPad)], ChaPolyPad) != 1 {
+               return nil, errors.New("bad pad")
+       }
+       cek = cek[len(ChaPolyPad):]
+       if len(cek) != cekLenExpected {
+               return nil, errors.New("invalid CEK len")
+       }
+       return cek, nil
+}
diff --git a/go/pki/cmd/keksenvtool/main.go b/go/pki/cmd/keksenvtool/main.go
new file mode 100644 (file)
index 0000000..1b15f63
--- /dev/null
@@ -0,0 +1,376 @@
+// kekscertool -- dealing with KEKS-encoded certificates utility
+// Copyright (C) 2024-2025 Sergey Matveev <stargrave@stargrave.org>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as
+// published by the Free Software Foundation, version 3 of the License.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public
+// License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "bytes"
+       "crypto/ecdh"
+       "crypto/rand"
+       "errors"
+       "flag"
+       "hash"
+       "io"
+       "log"
+       "os"
+
+       "github.com/companyzero/sntrup4591761"
+       "github.com/google/uuid"
+       "go.cypherpunks.su/balloon/v3"
+       "golang.org/x/crypto/blake2b"
+       "golang.org/x/crypto/chacha20poly1305"
+       "golang.org/x/crypto/hkdf"
+
+       "go.cypherpunks.su/keks"
+       "go.cypherpunks.su/keks/pki"
+       "go.cypherpunks.su/keks/pki/utils"
+)
+
+const (
+       BalloonSaltLen          = 8
+       BalloonHKDFSalt         = "keks/enveloped-data/balloon-blake2b-hkdf"
+       SNTRUP4591761X25519Salt = "keks/enveloped-data/sntrup4591761-x25519-hkdf-blake2b"
+)
+
+type BalloonCost struct {
+       S uint64 `keks:"s"`
+       T uint64 `keks:"t"`
+       P uint64 `keks:"p"`
+}
+
+type KEM struct {
+       A   string     `keks:"a"`
+       CEK []byte     `keks:"cek"`
+       To  *uuid.UUID `keks:"to,omitempty"`
+
+       // balloon-blake2b-hkdf related
+       Cost *BalloonCost `keks:"cost,omitempty"`
+       Salt *[]byte      `keks:"salt,omitempty"`
+
+       // sntrup4591761-x25519-hkdf-blake2b related
+       Encap *[]byte `keks:"encap,omitempty"`
+}
+
+type DEM struct {
+       A string `keks:"a"`
+}
+
+type Envelope struct {
+       DEM  DEM       `keks:"dem"`
+       KEM  []KEM     `keks:"kem"`
+       Bind uuid.UUID `keks:"bind"`
+
+       Ciphertext *keks.BlobChunked `keks:"ciphertext,omitempty"`
+}
+
+func blake2b256() hash.Hash {
+       h, err := blake2b.New256(nil)
+       if err != nil {
+               panic(err)
+       }
+       return h
+}
+
+func main() {
+       log.SetFlags(log.Lshortfile)
+       setBind := flag.String("bind", "", "Set that /bind instead of autogeneration")
+       includeTo := flag.Bool("include-to", false, `Include "to" field in KEMs`)
+       passwd := flag.String("passwd", "", "Passphrase")
+       balloonS := flag.Int("balloon-s", 1<<16, "Balloon's space cost")
+       balloonT := flag.Int("balloon-t", 3, "Balloon's time cost")
+       balloonP := flag.Int("balloon-p", 2, "Balloon's number of threads")
+       doDecrypt := flag.Bool("d", false, "Decrypt")
+       var pubs []*pki.Pub
+       flag.Func("pub", "Encrypt to, path to .cer", func(v string) error {
+               sd, err := pki.CerParse(utils.MustReadFile(v))
+               if err != nil {
+                       return err
+               }
+               load := sd.CerLoad()
+               if load.KU == nil {
+                       log.Println(v, "does not have key usages")
+               } else {
+                       if _, ok := (*load.KU)[pki.KUKEM]; !ok {
+                               log.Println(v, "does not have", pki.KUKEM, "key usage")
+                       }
+               }
+               if len(load.Pub) != 1 {
+                       return errors.New("expected single public key")
+               }
+               pubs = append(pubs, &load.Pub[0])
+               return err
+       })
+       var prvs []*pki.AV
+       flag.Func("prv", "Our private keys for decryption", func(v string) (err error) {
+               var av pki.AV
+               d := keks.NewDecoderFromBytes(
+                       utils.MustReadFile(v),
+                       &keks.DecodeOpts{MaxStrLen: 1 << 16},
+               )
+               if err = d.DecodeStruct(&av); err != nil {
+                       return err
+               }
+               prvs = append(prvs, &av)
+               return nil
+       })
+       flag.Parse()
+
+       var err error
+       var cek []byte
+       if *doDecrypt {
+               var envelope Envelope
+               {
+                       d := keks.NewDecoderFromReader(os.Stdin, nil)
+                       err = d.DecodeStruct(&envelope)
+                       if err != nil {
+                               log.Fatal(err)
+                       }
+               }
+               if envelope.Bind == uuid.Nil {
+                       log.Fatalln("unll bind")
+               }
+               if envelope.DEM.A != pki.ChaCha20Poly1305 {
+                       log.Fatalln("unsupported DEM:", envelope.DEM.A)
+               }
+               if len(envelope.KEM) == 0 {
+                       log.Fatalln("no KEMs")
+               }
+               for kemIdx, kem := range envelope.KEM {
+                       switch kem.A {
+                       case pki.BalloonBLAKE2bHKDF:
+                               if *passwd == "" {
+                                       log.Println(kemIdx, kem.A, "skipping because no -passwd")
+                                       continue
+                               }
+                               if kem.Salt == nil {
+                                       log.Fatalln("missing salt")
+                               }
+                               if kem.Cost == nil {
+                                       log.Fatalln("missing cost")
+                               }
+                               {
+                                       kek := hkdf.Extract(blake2b256, balloon.H(blake2b256,
+                                               []byte(*passwd),
+                                               append(envelope.Bind[:], *kem.Salt...),
+                                               int(kem.Cost.S), int(kem.Cost.T), int(kem.Cost.P),
+                                       ), []byte(BalloonHKDFSalt))
+                                       var cekp []byte
+                                       cekp, err = kemChaPolyOpen(kek, kem.CEK, chacha20poly1305.KeySize)
+                                       if err != nil {
+                                               log.Println(kemIdx, kem.A, err, "skipping")
+                                               continue
+                                       }
+                                       cek = cekp
+                               }
+                       case pki.SNTRUP4591761X25519HKDFBLAKE2b:
+                               if len(prvs) == 0 {
+                                       log.Println(kemIdx, kem.A, "skipping because no -prv")
+                                       continue
+                               }
+                               if kem.Encap == nil {
+                                       log.Fatalln("missing encap")
+                               }
+                               if len(*kem.Encap) != sntrup4591761.CiphertextSize+32 {
+                                       log.Fatalln("invalid encap len")
+                               }
+                               for prvIdx, prv := range prvs {
+                                       if len(prv.V) != sntrup4591761.PrivateKeySize+32 {
+                                               log.Fatalln("invalid private keys len")
+                                       }
+                                       var ourSNTRUP sntrup4591761.PrivateKey
+                                       copy(ourSNTRUP[:], prv.V)
+                                       x25519 := ecdh.X25519()
+                                       var ourX25519 *ecdh.PrivateKey
+                                       ourX25519, err = x25519.NewPrivateKey(
+                                               prv.V[sntrup4591761.PrivateKeySize:],
+                                       )
+                                       if err != nil {
+                                               log.Fatal(err)
+                                       }
+                                       var theirSNTRUP sntrup4591761.Ciphertext
+                                       copy(theirSNTRUP[:], *kem.Encap)
+                                       keySNTRUP, eq := sntrup4591761.Decapsulate(&theirSNTRUP, &ourSNTRUP)
+                                       if eq != 1 {
+                                               log.Println("can not KEM, skipping")
+                                               continue
+                                       }
+                                       var theirX25519 *ecdh.PublicKey
+                                       theirX25519, err = x25519.NewPublicKey(
+                                               (*kem.Encap)[sntrup4591761.CiphertextSize:],
+                                       )
+                                       if err != nil {
+                                               log.Fatal(err)
+                                       }
+                                       var keyX25519 []byte
+                                       keyX25519, err = ourX25519.ECDH(theirX25519)
+                                       if err != nil {
+                                               log.Fatal(err)
+                                       }
+                                       {
+                                               ikm := bytes.Join([][]byte{
+                                                       envelope.Bind[:],
+                                                       *kem.Encap, pubs[prvIdx].V,
+                                                       keySNTRUP[:], keyX25519,
+                                               }, []byte{})
+                                               kek := hkdf.Extract(blake2b256,
+                                                       ikm, []byte(SNTRUP4591761X25519Salt))
+                                               var cekp []byte
+                                               cekp, err = kemChaPolyOpen(kek, kem.CEK, chacha20poly1305.KeySize)
+                                               if err != nil {
+                                                       log.Println(kemIdx, kem.A, err, "skipping")
+                                                       continue
+                                               }
+                                               cek = cekp
+                                       }
+                               }
+                       default:
+                               log.Println("unsupported KEM:", kem.A)
+                               continue
+                       }
+                       if cek != nil {
+                               break
+                       }
+               }
+               if cek == nil {
+                       log.Fatal("no KEMs processed")
+               }
+               err = demChaPolyOpen(cek)
+               if err != nil {
+                       log.Fatal(err)
+               }
+       } else {
+               var binding uuid.UUID
+               if *setBind == "" {
+                       binding, err = uuid.NewV7()
+               } else {
+                       binding, err = uuid.Parse(*setBind)
+               }
+               if err != nil {
+                       log.Fatal(err)
+               }
+               var kems []KEM
+               cek = make([]byte, chacha20poly1305.KeySize)
+               _, err = io.ReadFull(rand.Reader, cek)
+               if err != nil {
+                       log.Fatal(err)
+               }
+               if *passwd != "" {
+                       salt := make([]byte, BalloonSaltLen)
+                       if _, err = io.ReadFull(rand.Reader, salt); err != nil {
+                               log.Fatal(err)
+                       }
+                       kem := KEM{
+                               A:    pki.BalloonBLAKE2bHKDF,
+                               Salt: &salt,
+                               Cost: &BalloonCost{
+                                       S: uint64(*balloonS),
+                                       T: uint64(*balloonT),
+                                       P: uint64(*balloonP),
+                               },
+                       }
+                       {
+                               kek := hkdf.Extract(blake2b256, balloon.H(blake2b256,
+                                       []byte(*passwd),
+                                       append(binding[:], salt...),
+                                       *balloonS, *balloonT, *balloonP,
+                               ), []byte(BalloonHKDFSalt))
+                               kem.CEK, err = kemChaPolySeal(kek, cek)
+                               if err != nil {
+                                       log.Fatal(err)
+                               }
+                       }
+                       kems = append(kems, kem)
+               }
+               for _, pub := range pubs {
+                       switch pub.A {
+                       case pki.SNTRUP4591761X25519:
+                               if len(pub.V) != sntrup4591761.PublicKeySize+32 {
+                                       log.Fatalln("invalid public keys len")
+                               }
+                               var theirSNTRUP sntrup4591761.PublicKey
+                               copy(theirSNTRUP[:], pub.V[:sntrup4591761.PublicKeySize])
+                               x25519 := ecdh.X25519()
+                               var theirX25519 *ecdh.PublicKey
+                               theirX25519, err = x25519.NewPublicKey(
+                                       pub.V[sntrup4591761.PublicKeySize:],
+                               )
+                               if err != nil {
+                                       log.Fatal(err)
+                               }
+                               var ciphertext *sntrup4591761.Ciphertext
+                               var keySNTRUP *sntrup4591761.SharedKey
+                               ciphertext, keySNTRUP, err = sntrup4591761.Encapsulate(
+                                       rand.Reader, &theirSNTRUP,
+                               )
+                               if err != nil {
+                                       log.Fatal(err)
+                               }
+                               var ourPrvX25519 *ecdh.PrivateKey
+                               ourPrvX25519, err = ecdh.X25519().GenerateKey(rand.Reader)
+                               if err != nil {
+                                       log.Fatal(err)
+                               }
+                               ourPubX25519 := ourPrvX25519.PublicKey()
+                               var keyX25519 []byte
+                               keyX25519, err = ourPrvX25519.ECDH(theirX25519)
+                               if err != nil {
+                                       log.Fatal(err)
+                               }
+                               kem := KEM{A: pki.SNTRUP4591761X25519HKDFBLAKE2b}
+                               encap := append(ciphertext[:], ourPubX25519.Bytes()...)
+                               kem.Encap = &encap
+                               {
+                                       ikm := bytes.Join([][]byte{
+                                               binding[:],
+                                               encap, pub.V,
+                                               keySNTRUP[:], keyX25519,
+                                       }, []byte{})
+                                       kek := hkdf.Extract(blake2b256,
+                                               ikm, []byte(SNTRUP4591761X25519Salt))
+                                       kem.CEK, err = kemChaPolySeal(kek, cek)
+                                       if err != nil {
+                                               log.Fatal(err)
+                                       }
+                               }
+                               if *includeTo {
+                                       kem.To = &pub.Id
+                               }
+                               kems = append(kems, kem)
+                       default:
+                               log.Fatalln("unsupported KEM:", pub.A)
+                       }
+               }
+               if len(kems) == 0 {
+                       log.Fatal("no KEMs specified")
+               }
+               {
+                       var hdr []byte
+                       hdr, err = keks.EncodeBuf(&Envelope{
+                               Bind: binding,
+                               KEM:  kems,
+                               DEM:  DEM{A: pki.ChaCha20Poly1305},
+                       }, nil)
+                       if err != nil {
+                               log.Fatal(err)
+                       }
+                       if _, err = io.Copy(os.Stdout, bytes.NewReader(hdr)); err != nil {
+                               log.Fatal(err)
+                       }
+               }
+               if err = demChaPolySeal(cek); err != nil {
+                       log.Fatal(err)
+               }
+       }
+}
similarity index 87%
rename from go/pki/cmd/kekssdtool/main.go
rename to go/pki/cmd/kekssigntool/main.go
index 3e6e40a8175f86f559fb8c291e292fa0db099ae2c359265aa4e47c6719a75796..88d0ca3039c77a6e69803ac98e04e5bbdd947f2b4063686c2ec68f71eb3cd96a 100644 (file)
@@ -25,6 +25,8 @@ import (
        "os"
        "time"
 
+       "github.com/google/uuid"
+
        "go.cypherpunks.su/keks"
        "go.cypherpunks.su/keks/pki"
        pkihash "go.cypherpunks.su/keks/pki/hash"
@@ -38,10 +40,20 @@ func main() {
        typ := flag.String("type", "data", "Type of the content, /load/t value")
        hashAlgo := flag.String("hash", "", "Algorithm identifier of the hash to use")
        verify := flag.Bool("verify", false, "Verify with provided -cer")
+       envelopeBindingHex := flag.String("envelope-binding", "", "Set envelope-binding")
 
        flag.Parse()
        log.SetFlags(log.Lshortfile)
 
+       envelopeBinding := uuid.Nil
+       var err error
+       if *envelopeBindingHex != "" {
+               envelopeBinding, err = uuid.Parse(*envelopeBindingHex)
+               if err != nil {
+                       log.Fatal(err)
+               }
+       }
+
        if *cerPath == "" {
                log.Fatal("no -cer is set")
        }
@@ -106,10 +118,14 @@ func main() {
                sd.Hashes = &sdHashes
                sigHashes := map[string][]byte{*hashAlgo: hasher.Sum(nil)}
                when := time.Now().UTC().Truncate(1000 * time.Microsecond)
-               err = sd.SignWith(cer.CerLoad(), signer, pki.SigTBS{
+               sigTbs := pki.SigTBS{
                        Hashes: &sigHashes,
                        When:   &when,
-               })
+               }
+               if envelopeBinding != uuid.Nil {
+                       sigTbs.EnvelopeBinding = &envelopeBinding
+               }
+               err = sd.SignWith(cer.CerLoad(), signer, sigTbs)
                if err != nil {
                        log.Fatal(err)
                }
index 54d4284c8e306046a6afd1559c7c72d52f070b0bf6d434fbff914f70da8e3eb6..cb57a064dc78fdca3d6e9616c08f933e3f9a878abb921fb2ad6d2538d1cb53ee 100644 (file)
@@ -7,4 +7,7 @@ require (
        go.cypherpunks.su/gogost/v6 v6.0.1
 )
 
-require golang.org/x/crypto v0.26.0 // indirect
+require (
+       go.cypherpunks.su/balloon/v3 v3.0.0 // indirect
+       golang.org/x/crypto v0.32.0 // indirect
+)
index 5d2bb87a4732ed2f37f198af93c68ab2206bcdf8a7050c1529506d4d37e81270..7d9b949eef2199e578d580cd2be939dce9f52af1710c13af637ad5da7320577c 100644 (file)
@@ -37,6 +37,8 @@ type SigTBS struct {
        Exp    *[]time.Time       `keks:"exp,omitempty"`
        When   *time.Time         `keks:"when,omitempty"`
        SID    uuid.UUID          `keks:"sid"`
+
+       EnvelopeBinding *uuid.UUID `keks:"envelope-binding,omitempty"`
 }
 
 type Sig struct {
diff --git a/go/pki/sntrup4591761-x25519/algo.go b/go/pki/sntrup4591761-x25519/algo.go
new file mode 100644 (file)
index 0000000..16caaf0
--- /dev/null
@@ -0,0 +1,6 @@
+package sntrup4591761x25519
+
+const (
+       SNTRUP4591761X25519            = "sntrup4591761-x25519"
+       SNTRUP4591761X25519HKDFBLAKE2b = "sntrup4591761-x25519-hkdf-blake2b"
+)
diff --git a/go/pki/sntrup4591761-x25519/go.mod b/go/pki/sntrup4591761-x25519/go.mod
new file mode 100644 (file)
index 0000000..57d8e0c
--- /dev/null
@@ -0,0 +1,5 @@
+module go.cypherpunks.su/keks/pki/sntrup4591761-x25519
+
+go 1.22
+
+require github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a
diff --git a/go/pki/sntrup4591761-x25519/go.sum b/go/pki/sntrup4591761-x25519/go.sum
new file mode 100644 (file)
index 0000000..18a1c89
--- /dev/null
@@ -0,0 +1,2 @@
+github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a h1:clYxJ3Os0EQUKDDVU8M0oipllX0EkuFNBfhVQuIfyF0=
+github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a/go.mod h1:z/9Ck1EDixEbBbZ2KH2qNHekEmDLTOZ+FyoIPWWSVOI=
diff --git a/go/pki/sntrup4591761-x25519/kp.go b/go/pki/sntrup4591761-x25519/kp.go
new file mode 100644 (file)
index 0000000..f692be1
--- /dev/null
@@ -0,0 +1,41 @@
+// GoKEKS/PKI -- PKI-related capabilities based on KEKS encoded formats
+// Copyright (C) 2024-2025 Sergey Matveev <stargrave@stargrave.org>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as
+// published by the Free Software Foundation, version 3 of the License.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public
+// License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sntrup4591761x25519
+
+import (
+       "crypto/ecdh"
+       "crypto/rand"
+
+       "github.com/companyzero/sntrup4591761"
+)
+
+func NewKeypair() (prv, pub []byte, err error) {
+       var pubSNTRUP *sntrup4591761.PublicKey
+       var prvSNTRUP *sntrup4591761.PrivateKey
+       pubSNTRUP, prvSNTRUP, err = sntrup4591761.GenerateKey(rand.Reader)
+       if err != nil {
+               return
+       }
+       var prvX25519 *ecdh.PrivateKey
+       prvX25519, err = ecdh.X25519().GenerateKey(rand.Reader)
+       if err != nil {
+               return
+       }
+       pubX25519 := prvX25519.PublicKey()
+       prv = append(prvSNTRUP[:], prvX25519.Bytes()...)
+       pub = append(pubSNTRUP[:], pubX25519.Bytes()...)
+       return
+}
index e6f7d312deea151853163284946684b38f96f0bc7092d3bd2061c31d09e057a7..4bcfe3563d86f0ff33911923c273c1850c80044e1f89435c2ebb8da48d22f686 100644 (file)
@@ -93,7 +93,7 @@ Example minimal certificate may look like:
 
 Same rules of serialisation must be used as with
 @code{@ref{signed-data-gost3410}}. Public key's
-identifier and and @code{cid} should be calculated
+identifier and @code{cid} should be calculated
 using big-endian Streebog-256 hash.
 
 @node cer-ed25519-blake2b
@@ -101,5 +101,18 @@ using big-endian Streebog-256 hash.
 
 Same calculation and serialisation rules must be used as with
 @code{@ref{signed-data-ed25519-blake2b}}.
-Public key's identifier and and @code{cid} should be calculated
+Public key's identifier and @code{cid} should be calculated
+using BLAKE2b hash with 128 or 256 bit output length specified.
+
+@node cer-sntrup4591761-x25519
+@subsection cer with SNTRUP4591761-X25519
+
+Certificate with combined Streamlined NTRU Prime 4591^761 and Curve25519
+public keys is used for KEM purposes, so should have "kem" key usage set.
+
+Its algorithm identifier is @code{sntrup4591761-x25519}. Its public key
+value is a concatenation of 1218-byte SNTRUP4591761 public key and
+32-byte Curve25519 one.
+
+Public key's identifier and @code{cid} should be calculated
 using BLAKE2b hash with 128 or 256 bit output length specified.
diff --git a/spec/format/enveloped-data.cddl b/spec/format/enveloped-data.cddl
new file mode 100644 (file)
index 0000000..e27ea3b
--- /dev/null
@@ -0,0 +1,56 @@
+ai = text ; algorithm identifier
+
+enveloped-data = {
+    dem: dem,
+    kem: [+ kem],
+    bind: uuid,
+    ? ciphertext: blob,
+}
+
+dem = dem-chacha20poly1305 / dem-kuznechik-ctracpkm-hmac-hkdf
+
+dem-chacha20poly1305 = {a: "chacha20poly1305"}
+
+dem-kuznechik-ctracpkm-hmac-hkdf = {
+    a: "kuznechik-ctracpkm-hmac-hkdf",
+    seed: bytes,
+    iv: bytes,
+}
+
+kem = kem-generic /
+      kem-balloon-blake2b-hkdf /
+      kem-gost3410-kexp15 /
+      kem-sntrup4591761-x25519-hkdf-blake2b
+
+kem-generic = {
+    a: ai,
+    cek: bytes,
+    * text => any
+}
+
+kem-balloon-blake2b = {
+    a: "balloon-blake2b-hkdf",
+    cek: bytes,
+    cost: {
+        s: int, ; space cost
+        t: int, ; time cost
+        p: int, ; parallel cost
+    },
+    salt: bytes,
+}
+
+kem-gost3410-kexp15 = {
+    a: "gost3410-kexp15",
+    cek: bytes,
+    ukm: bytes,
+    pub: bytes,
+    iv: bytes,
+    ? to: uuid,
+}
+
+kem-sntrup4591761-x25519-hkdf-blake2b = {
+    a: ai,
+    cek: bytes,
+    encap: bytes,
+    ? to: uuid,
+}
diff --git a/spec/format/enveloped-data.texi b/spec/format/enveloped-data.texi
new file mode 100644 (file)
index 0000000..3ff80ef
--- /dev/null
@@ -0,0 +1,154 @@
+@node enveloped-data
+@cindex enveloped-data
+@section enveloped-data format
+
+Enveloped data is an encrypted data.
+
+@verbatiminclude format/enveloped-data.cddl
+
+@code{/ciphertext} contains the ciphertext. It is encrypted with random
+"content encryption key" (CEK) with an algorithm specified in
+@code{/dem/a} (data encapsulation mechanism). @code{/dem} may contain
+additional fields supplementing the decryption process, like
+initialisation vector.
+
+@code{/ciphertext} is a BLOB, which chunk's length depends on DEM
+algorithm. If it is absent, then ciphertext is provided by other means,
+for example just following the enveloped-data structure.
+
+CEK is encapsulated in @code{/kem/*} entries (key encapsulation
+mechanism), using @code{/kem/*/a} algorithm. @code{/kem/*/cek} field
+contains an encrypted CEK.
+
+If KEM uses public-key based cryptography, then recipient's public
+key(s) should be provided by @ref{cer, certificate}, which may lack the
+signatures at all. Optional @code{/kem/*/to}, public key's identifier,
+may provide a hint for quickly searching for the key on the recipient's
+side.
+
+@code{/bind} value can be used to bind the enveloped
+@ref{signed-data, signed-data} to the envelope.
+Either UUIDv4 or UUIDv7 are recommended.
+
+@node enveloped-data-chacha20poly1305
+@subsection enveloped-data with ChaCha20-Poly1305 DEM
+
+@code{/dem/a} equals to "chacha20poly1305". Data is split on 64 KiB
+chunks which are encrypted the following way:
+
+@verbatim
+ChaCha20-Poly1305(
+    key=cek, nonce=BE(11-byte counter) || tail-flag,
+    data=16*0x00 || chunk, ad="")
+@end verbatim
+
+where @code{counter} starts at zero and incremented with each chunk.
+@code{tail-flag} is a byte indicating if that is the last chunk in the
+payload. It equals to 0x01 for the last chunk and to 0x00 for other ones.
+Last chunk should be smaller than previous ones, maybe even empty.
+
+@code{/ciphertext}'s chunk length equals to 16+64KiB+16 bytes.
+
+@node enveloped-data-kuznechik-ctracpkm-hmac-hkdf
+@subsection enveloped-data-kuznechik-ctracpkm-hmac-hkdf
+
+@code{/dem/a} equals to "kuznechik-ctracpkm-hmac-hkdf".
+@code{/dem/seed} contains 16 bytes for the HKDF invocation below.
+@code{/dem/iv} contains 8 bytes of initialisation vector.
+
+@verbatim
+Kenc, Kauth = HKDF-Extract(Streebog-512,
+    salt="keks/enveloped-data/kuznechik-ctracpkm-hmac-hkdf",
+    secret=seed || CEK)
+@end verbatim
+
+Encryption is performed with Kuznechik (ГОСТ Р 34.12-2015) block cipher
+in CTR-ACPKM mode of operation (Р 1323565.1.017) with 256KiB section
+size. Authentication of ciphertext is performed with Streebog-512 (ГОСТ
+Р 34.11-2012) in HMAC mode.
+
+@code{/ciphertext}'s chunk length equals to 64KiB bytes.
+
+@node enveloped-data-balloon-blake2b-hkdf
+@subsection enveloped-data with Balloon-BLAKE2b+HKDF-BLAKE2b KEM
+
+@code{/kem/*/a} equals to "balloon-blake2b-hkdf".
+Recipient map must also contain additional fields:
+
+@table @code
+@item /to/*/cost/s: uint64
+    Balloon's space cost (buffer size, number of hash-output sized blocks).
+@item /to/*/cost/t: uint64
+    Balloon's time cost (number of rounds).
+@item /to/*/cost/p: uint64
+    Balloon's parallel cost (number of threads).
+@item /to/*/salt: bin
+    Salt.
+@end table
+
+@url{https://crypto.stanford.edu/balloon/, Balloon} memory-hardened
+password hasher must be used with BLAKE2b-256 hash.
+
+@code{/kem/*/cek} is encrypted the following way:
+
+@verbatim
+KEK = HKDF-Extract(BLAKE2b-256,
+    salt="keks/enveloped-data/balloon-blake2b-hkdf",
+    secret=balloon(BLAKE2b-256, password, bind || salt, s, t, p))
+ChaCha20-Poly1305(data=16*0x00 || CEK, key=KEK, nonce=12*0x00, ad="")
+@end verbatim
+
+@node enveloped-data-gost3410-kexp15
+@subsection enveloped-data-gost3410-kexp15
+
+@code{/kem/*/a} equals to "gost3410-kexp15".
+Recipient map must also contain additional fields:
+
+@table @code
+@item /to/*/ukm: bytes
+    Additional 16-bytes keying material.
+@item /to/*/pub: bytes
+    Sender's ephemeral 512-bit public key.
+@item /to/*/iv: bytes
+    8-byte initialisation vector for KExp15.
+@end table
+
+ГОСТ Р 34.10-2012 VKO 512-bit parameter set C ("gost3410-512C") must be
+used for DH operation, with UKM taken from the structure. Its 512-bit
+output result is used for KExp15 (Р 1323565.1.017) key wrapping algorithm:
+
+@verbatim
+KExp15(Kenc, Kauth, IV, CEK):
+    return CTR(Kenc, CEK+CMAC(Kauth, IV+CEK), IV=IV)
+@end verbatim
+
+@node enveloped-data-sntrup4591761-x25519-hkdf-blake2b
+@subsection enveloped-data with SNTRUP4591761+x25519+HKDF-BLAKE2b KEM
+
+@code{/kem/*/a} equals to "sntrup4591761-x25519-hkdf-blake2b".
+Recipient certificate with
+@ref{cer-sntrup4591761-x25519, @code{sntrup4591761-x25519}} public key
+must be used. It should have "kem" key usage set.
+
+Recipient map must also contain additional field:
+@code{/kem/*/encap: bytes} -- concatenation of 1047 bytes of Streamlined
+NTRU Prime 4591^761's ciphertext with 32 bytes of ephemeral
+Curve25519 public key.
+
+Recipient performs Curve25519 and SNTRUP computation to
+derive/decapsulate two 32-byte shared keys. Then it combines
+them to get the decryption key of the CEK.
+@code{/kem/*/cek} is encrypted the following way:
+
+@verbatim
+KEK = HKDF-Extract(BLAKE2b-256,
+    salt="keks/enveloped-data/sntrup4591761-x25519-hkdf-blake2b",
+    secret=bind ||
+        sntrup4591761-sender-ciphertext ||
+        x25519-sender-public-key ||
+        sntrup4591761-recipient-public-key ||
+        x25519-recipient-public-key ||
+        sntrup4591761-shared-key ||
+        x25519-shared-key)
+ChaCha20-Poly1305(data=16*0x00 || CEK, key=KEK, nonce=12*0x00, ad="")
+@end verbatim
index f68bd8db415819cf72149d5b4725d2dab66b5ea076151cfba770192a4ec5699e..8941326e0fbf9ebfaf23162ed420c3b9fba58912fb006cc865349293bef19aa7 100644 (file)
@@ -9,4 +9,5 @@ They are written in
 @include format/signed-data.texi
 @include format/cer.texi
 @include format/hashed-data.texi
+@include format/enveloped-data.texi
 @include format/registry.texi
index 828c18ffacec1173ea029c23ceedec715fc6f9d19133f667dba491c5505e47b9..52e4850a04f5948af781b72eca10b90b40f742f58da11b68d47780b4557edd70 100644 (file)
@@ -23,3 +23,11 @@ In many libraries it is called "seed".
 
 @code{ed25519-blake2b} algorithm identifier is used, however actually no
 hash is involved in private key storage.
+
+@node private-key-sntrup4591761-x25519
+@subsection private-key with SNTRUP4591761-X25519
+
+Concatenation of Streamlined NTRU Prime 4591^761's 1600-byte private key
+and Curve25519's 32-byte one.
+
+@code{sntrup4591761-x25519} algorithm identifier is used.
index b476b766e21e21fb63377d6309cf1d5d9b2c5576aa6b797d3466da3750974770..6436925f2cd83cea8ce1acf2ec189e9cb1837688029a8d846f192f5ce0efc7d2 100644 (file)
@@ -33,11 +33,40 @@ There is example registry of known algorithm identifiers.
 
 @table @code
 @item ecdsa-nist256p, ecdsa-nist521p
-@item x25519
-@item x448
 @item gost3410-256A, gost3410-512C
     @code{@ref{cer-gost3410}},
     @code{@ref{private-key-gost3410}}
+@item x25519
+@item x448
+@end table
+
+@node AI DEM
+@subsection DEM
+
+@table @code
+@item chacha20poly1305
+    @code{@ref{enveloped-data-chacha20poly1305}}
+@item kuznechik-ctracpkm-hmac-hkdf
+    @code{@ref{enveloped-data-kuznechik-ctracpkm-hmac-hkdf}}
+@end table
+
+@node AI KEM
+@subsection KEM
+
+@table @code
+@item argon2id-hkdf-blake2b
+@item balloon-blake2b-hkdf
+    @code{@ref{enveloped-data-balloon-blake2b-hkdf}}
+@item gost3410-kexp15
+    @code{@ref{enveloped-data-gost3410-kexp15}}
+@item mlkem768-x25519
+@item sntrup761-x25519
+@item sntrup4591761-x25519
+    @code{@ref{cer-sntrup4591761-x25519}},
+    @code{@ref{private-key-sntrup4591761-x25519}}
+@item sntrup4591761-x25519-hkdf-blake2b
+    @code{@ref{enveloped-data-sntrup4591761-x25519-hkdf-blake2b}}
+@item sntrup761-x25519-hkdf-blake2b
 @end table
 
 @node AI Sign
index f473180151de4818762c72903272079fac758aadaecfe0c38635f811a0657e32..4c7a14dacafe1e8124c85744953c0fbc311859f02d7d92daa95c893c873b9d25 100644 (file)
@@ -25,6 +25,7 @@ url = text
 sig-tbs = {
     sid: uuid, ; signer's public key id
     ? hash: {ai => bytes}, ; when using prehashing
+    ? envelope-binding: uuid,
     ? when: tai64 / tai64n,
     * text => any
 }
index de102e60753e13fb2b7849a004b6743529aedce8abb1fcab443b149547a784d4..f5c490ed451b33aba67461c0feed0865373aa21ce0985e8a5ce73a35eba50507 100644 (file)
@@ -30,6 +30,10 @@ are placed outside it.
 help creating the whole verification chain. They are placed outside
 @code{/sigs}, because some of them may be shared among signers.
 
+If signed data is also intended to be @ref{enveloped-data, enveloped}
+(encrypted), then @code{/sigs/*/tbs/envelope-binding} should be set to
+envelop's @code{/bind} value.
+
 @node signed-data-gost3410
 @subsection signed-data with GOST R 34.10-2012