From: Sergey Matveev Date: Sat, 18 Jan 2025 14:48:43 +0000 (+0300) Subject: enveloped-data X-Git-Url: http://www.git.cypherpunks.su/?a=commitdiff_plain;h=c0bb5613dc84e8e128374141d3e5226306d9f5e3381a691222b2d19548726552;p=keks.git enveloped-data --- diff --git a/go/pki/algo.go b/go/pki/algo.go index c85029b..d0abc7d 100644 --- a/go/pki/algo.go +++ b/go/pki/algo.go @@ -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" ) diff --git a/go/pki/av.go b/go/pki/av.go index 289bac3..e9cb679 100644 --- a/go/pki/av.go +++ b/go/pki/av.go @@ -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) diff --git a/go/pki/cer.go b/go/pki/cer.go index 02a9120..528aac5 100644 --- a/go/pki/cer.go +++ b/go/pki/cer.go @@ -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") diff --git a/go/pki/cmd/kekscertool/main.go b/go/pki/cmd/kekscertool/main.go index 2658f41..2909a51 100644 --- a/go/pki/cmd/kekscertool/main.go +++ b/go/pki/cmd/kekscertool/main.go @@ -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 index 0000000..8338222 --- /dev/null +++ b/go/pki/cmd/keksenvtool/chapoly.go @@ -0,0 +1,141 @@ +// kekscertool -- dealing with KEKS-encoded certificates utility +// Copyright (C) 2024-2025 Sergey Matveev +// +// 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 . + +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 index 0000000..1b15f63 --- /dev/null +++ b/go/pki/cmd/keksenvtool/main.go @@ -0,0 +1,376 @@ +// kekscertool -- dealing with KEKS-encoded certificates utility +// Copyright (C) 2024-2025 Sergey Matveev +// +// 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 . + +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) + } + } +} diff --git a/go/pki/cmd/kekssdtool/main.go b/go/pki/cmd/kekssigntool/main.go similarity index 87% rename from go/pki/cmd/kekssdtool/main.go rename to go/pki/cmd/kekssigntool/main.go index 3e6e40a..88d0ca3 100644 --- a/go/pki/cmd/kekssdtool/main.go +++ b/go/pki/cmd/kekssigntool/main.go @@ -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) } diff --git a/go/pki/go.mod b/go/pki/go.mod index 54d4284..cb57a06 100644 --- a/go/pki/go.mod +++ b/go/pki/go.mod @@ -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 +) diff --git a/go/pki/signed-data.go b/go/pki/signed-data.go index 5d2bb87..7d9b949 100644 --- a/go/pki/signed-data.go +++ b/go/pki/signed-data.go @@ -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 index 0000000..16caaf0 --- /dev/null +++ b/go/pki/sntrup4591761-x25519/algo.go @@ -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 index 0000000..57d8e0c --- /dev/null +++ b/go/pki/sntrup4591761-x25519/go.mod @@ -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 index 0000000..18a1c89 --- /dev/null +++ b/go/pki/sntrup4591761-x25519/go.sum @@ -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 index 0000000..f692be1 --- /dev/null +++ b/go/pki/sntrup4591761-x25519/kp.go @@ -0,0 +1,41 @@ +// GoKEKS/PKI -- PKI-related capabilities based on KEKS encoded formats +// Copyright (C) 2024-2025 Sergey Matveev +// +// 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 . + +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 +} diff --git a/spec/format/cer.texi b/spec/format/cer.texi index e6f7d31..4bcfe35 100644 --- a/spec/format/cer.texi +++ b/spec/format/cer.texi @@ -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 index 0000000..e27ea3b --- /dev/null +++ b/spec/format/enveloped-data.cddl @@ -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 index 0000000..3ff80ef --- /dev/null +++ b/spec/format/enveloped-data.texi @@ -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 diff --git a/spec/format/index.texi b/spec/format/index.texi index f68bd8d..8941326 100644 --- a/spec/format/index.texi +++ b/spec/format/index.texi @@ -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 diff --git a/spec/format/private-key.texi b/spec/format/private-key.texi index 828c18f..52e4850 100644 --- a/spec/format/private-key.texi +++ b/spec/format/private-key.texi @@ -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. diff --git a/spec/format/registry.texi b/spec/format/registry.texi index b476b76..6436925 100644 --- a/spec/format/registry.texi +++ b/spec/format/registry.texi @@ -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 diff --git a/spec/format/signed-data.cddl b/spec/format/signed-data.cddl index f473180..4c7a14d 100644 --- a/spec/format/signed-data.cddl +++ b/spec/format/signed-data.cddl @@ -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 } diff --git a/spec/format/signed-data.texi b/spec/format/signed-data.texi index de102e6..f5c490e 100644 --- a/spec/format/signed-data.texi +++ b/spec/format/signed-data.texi @@ -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