- ibe.ts gains encryptOnG2RFC9380, EncryptCCAonG2 of kyber with the suite of tlock for Quicknet. Qid is H(id) on G1 with the RFC 9380 DST, sigma comes from crypto.getRandomValues, U = r·G2, V = sigma XOR H2(e(Qid, key)^r) and W = msg XOR H4(sigma). The key passes the canonical gate, and sigma and the masks are wiped. encryptOnG2WithSigma takes a given sigma, for the vectors only; index.ts exports neither. - tlock.ts adds timeRecipient, the age-encryption Recipient of OUTER_TIME_AGE, as Go's agewrap.TimeRecipient. It writes the stanza "tlock <round> <chain hash>" with the checks and texts of NewTimeRecipient: the scheme and the pinned key, then the round range. age-encryption has no labels, so the writer of phase 3 adds it alone. Vectors, in src/lib/dkc/testing/tlock-vectors.json from scripts/tlock-go-vectors.go: - Fixed-sigma encryptions of 1, 16 and 32 bytes for rounds 1000 and 1001. Go restates EncryptCCAonG2, since kyber draws sigma itself, and checks the restatement with ibe.DecryptCCAonG2 and tlock.TimeUnlock. encryptOnG2WithSigma reproduces them byte for byte. - The samples of scripts/tlock-ts-samples.mjs, which Node runs on the TypeScript sources: IBE bodies and age files that this library made for rounds 1000 and 1001. Go opened every one: the bodies with tlock.TimeUnlock and the age files with age.Decrypt and agewrap.NewTimeIdentity, the identity of step 11. It got the same file keys and plaintexts, and the samples are frozen with those verdicts. tlock.test.ts replays both blocks, the random round trip, the rejections with their texts, and an age file sealed with timeRecipient and opened with the step-11 identity of open.ts. Coverage of ibe.ts and tlock.ts is 100 %, now a threshold for tlock.ts too. Step 6 of the plan is recorded as done: the canonicality amendment is in spec-v0.8.2. npm run verify is green: 2,567 tests. The site does not change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
243a42c004
commit
66970cf82b
@ -0,0 +1,269 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// Prints src/lib/dkc/testing/tlock-vectors.json: the Go reference values for
|
||||||
|
// the tlock encryption of src/lib/dkc/ibe.ts and src/lib/dkc/tlock.ts (plan
|
||||||
|
// of phase 2, section 8, points 4 and 5).
|
||||||
|
//
|
||||||
|
// - encrypt: EncryptCCAonG2 of drand/kyber with the suite of tlock for
|
||||||
|
// Quicknet, restated with a fixed sigma, for messages of 1, 16 and 32
|
||||||
|
// bytes to rounds 1000 and 1001. kyber draws sigma from crypto/rand, so
|
||||||
|
// the restatement is checked: ibe.DecryptCCAonG2 opens every ciphertext
|
||||||
|
// with the published signature of its round, and tlock.BytesToCiphertext
|
||||||
|
// with tlock.TimeUnlock opens the 16-byte ones, as a tlock stanza body.
|
||||||
|
// - interop: the ciphertexts that scripts/tlock-ts-samples.mjs made with
|
||||||
|
// the TypeScript library, each opened by the reference. An IBE body goes
|
||||||
|
// through tlock.BytesToCiphertext and tlock.TimeUnlock; an age file
|
||||||
|
// through age.Decrypt with agewrap.NewTimeIdentity, the identity of
|
||||||
|
// capsule.Open at step 11. Each sample gets the verdict "ok" with what Go
|
||||||
|
// recovered, or "reject".
|
||||||
|
//
|
||||||
|
// H2, H3 and H4 are unexported in kyber; they are restated with its tags, as
|
||||||
|
// in scripts/ibe-go-vectors.go, and the decryptions above check them.
|
||||||
|
//
|
||||||
|
// Run it from a scratch module that requires the reference implementation
|
||||||
|
// (replace g.activething.com/go/DateKeys => ../datekeys-go and
|
||||||
|
// GOFLAGS=-mod=mod), passing the output of tlock-ts-samples.mjs:
|
||||||
|
//
|
||||||
|
// go run tlock-go-vectors.go ts-samples.json > tlock-vectors.json
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"g.activething.com/go/DateKeys/agewrap"
|
||||||
|
"g.activething.com/go/DateKeys/profile"
|
||||||
|
"g.activething.com/go/DateKeys/provider"
|
||||||
|
"github.com/drand/drand/v2/common"
|
||||||
|
"github.com/drand/drand/v2/crypto"
|
||||||
|
"github.com/drand/kyber"
|
||||||
|
bls "github.com/drand/kyber-bls12381"
|
||||||
|
"github.com/drand/kyber/encrypt/ibe"
|
||||||
|
"github.com/drand/tlock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The published Quicknet signatures of rounds 1000 and 1001, as in the
|
||||||
|
// fixtures and the mutation corpus; provider.Verify checks them below.
|
||||||
|
var published = map[uint64]string{
|
||||||
|
1000: "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39",
|
||||||
|
1001: "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41",
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
suite = bls.NewBLS12381Suite()
|
||||||
|
order, _ = new(big.Int).SetString("73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001", 16)
|
||||||
|
)
|
||||||
|
|
||||||
|
func must[T any](v T, err error) T {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func unhex(s string) []byte { return must(hex.DecodeString(s)) }
|
||||||
|
|
||||||
|
func concat(parts ...[]byte) []byte { return bytes.Join(parts, nil) }
|
||||||
|
|
||||||
|
func xor(a, b []byte) []byte {
|
||||||
|
out := make([]byte, len(a))
|
||||||
|
for i := range a {
|
||||||
|
out[i] = a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func h2(gt []byte, n int) []byte {
|
||||||
|
sum := sha256.Sum256(concat(ibe.H2Tag(), gt))
|
||||||
|
return sum[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func h4(sigma []byte, n int) []byte {
|
||||||
|
sum := sha256.Sum256(concat(ibe.H4Tag(), sigma))
|
||||||
|
return sum[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func h3(sigma, msg []byte) kyber.Scalar {
|
||||||
|
base := sha256.Sum256(concat(ibe.H3Tag(), sigma, msg))
|
||||||
|
for i := uint16(1); i < 65535; i++ {
|
||||||
|
d := sha256.Sum256(concat(binary.LittleEndian.AppendUint16(nil, i), base[:]))
|
||||||
|
d[0] >>= 1
|
||||||
|
if new(big.Int).SetBytes(d[:]).Cmp(order) < 0 {
|
||||||
|
r := suite.G1().Scalar()
|
||||||
|
if err := r.UnmarshalBinary(d[:]); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic("h3: rejection sampling failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt is EncryptCCAonG2 of kyber with the given sigma.
|
||||||
|
func encrypt(key kyber.Point, id, msg, sigma []byte) *ibe.Ciphertext {
|
||||||
|
qid := suite.G1().Point().(kyber.HashablePoint).Hash(id)
|
||||||
|
gid := suite.Pair(qid, key)
|
||||||
|
r := h3(sigma, msg)
|
||||||
|
gt := must(suite.GT().Point().Mul(r, gid).MarshalBinary())
|
||||||
|
return &ibe.Ciphertext{U: suite.G2().Point().Mul(r, nil), V: xor(sigma, h2(gt, len(msg))), W: xor(msg, h4(sigma, len(msg)))}
|
||||||
|
}
|
||||||
|
|
||||||
|
type encryptVector struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Round uint64 `json:"round"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Sigma string `json:"sigma"`
|
||||||
|
U string `json:"u"`
|
||||||
|
V string `json:"v"`
|
||||||
|
W string `json:"w"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sample struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Round uint64 `json:"round"`
|
||||||
|
FileKey string `json:"file_key,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
Plaintext string `json:"plaintext,omitempty"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Go string `json:"go"`
|
||||||
|
GoResult string `json:"go_result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
scheme := must(crypto.SchemeFromName(crypto.SigsOnG1ID))
|
||||||
|
quicknet := profile.Quicknet()
|
||||||
|
key := scheme.KeyGroup.Point()
|
||||||
|
if err := key.UnmarshalBinary(quicknet.PublicKey); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
release := func(round uint64) provider.Release {
|
||||||
|
r := provider.Release{Round: round, Signature: unhex(published[round])}
|
||||||
|
if err := provider.Verify(quicknet, provider.Condition{Round: round}, r); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
var vectors []encryptVector
|
||||||
|
for i, c := range []struct {
|
||||||
|
round uint64
|
||||||
|
n int
|
||||||
|
}{{1000, 16}, {1001, 16}, {1000, 1}, {1000, 32}} {
|
||||||
|
seed := sha256.Sum256(binary.BigEndian.AppendUint32([]byte("DateKeys tlock vector "), uint32(i)))
|
||||||
|
msgSeed := sha256.Sum256(seed[:])
|
||||||
|
msg, sigma := msgSeed[:c.n], seed[:c.n]
|
||||||
|
id := scheme.DigestBeacon(&common.Beacon{Round: c.round})
|
||||||
|
ct := encrypt(key, id, msg, sigma)
|
||||||
|
rel := release(c.round)
|
||||||
|
sig := scheme.SigGroup.Point()
|
||||||
|
if err := sig.UnmarshalBinary(rel.Signature); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if got, err := ibe.DecryptCCAonG2(suite, sig, ct); err != nil || !bytes.Equal(got, msg) {
|
||||||
|
panic(fmt.Sprintf("kyber does not decrypt the restated encryption %d: %v", i, err))
|
||||||
|
}
|
||||||
|
u := must(ct.U.MarshalBinary())
|
||||||
|
if c.n == 16 {
|
||||||
|
body := concat(u, ct.V, ct.W)
|
||||||
|
got := must(tlock.TimeUnlock(*scheme, key, common.Beacon{Round: rel.Round, Signature: rel.Signature}, must(tlock.BytesToCiphertext(*scheme, body))))
|
||||||
|
if !bytes.Equal(got, msg) {
|
||||||
|
panic("tlock does not decrypt the restated encryption")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vectors = append(vectors, encryptVector{
|
||||||
|
Name: fmt.Sprintf("a %d-byte message for round %d", c.n, c.round), Round: c.round, ID: hex.EncodeToString(id),
|
||||||
|
Msg: hex.EncodeToString(msg), Sigma: hex.EncodeToString(sigma), U: hex.EncodeToString(u),
|
||||||
|
V: hex.EncodeToString(ct.V), W: hex.EncodeToString(ct.W), Signature: published[c.round],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var in struct {
|
||||||
|
Generator string `json:"generator"`
|
||||||
|
Samples []sample `json:"samples"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(must(os.ReadFile(os.Args[1])), &in); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
for i := range in.Samples {
|
||||||
|
s := &in.Samples[i]
|
||||||
|
rel := release(s.Round)
|
||||||
|
s.Go = "reject"
|
||||||
|
switch s.Kind {
|
||||||
|
case "ibe":
|
||||||
|
ct, err := tlock.BytesToCiphertext(*scheme, unhex(s.Body))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", s.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fk, err := tlock.TimeUnlock(*scheme, key, common.Beacon{Round: rel.Round, Signature: rel.Signature}, ct)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", s.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Go, s.GoResult = "ok", hex.EncodeToString(fk)
|
||||||
|
case "age":
|
||||||
|
id := must(agewrap.NewTimeIdentity(quicknet, s.Round, rel))
|
||||||
|
r, err := age.Decrypt(bytes.NewReader(unhex(s.File)), id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", s.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pt, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", s.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Go, s.GoResult = "ok", hex.EncodeToString(pt)
|
||||||
|
default:
|
||||||
|
panic("unknown sample kind " + s.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{
|
||||||
|
"description": "Go reference values for the tlock encryption of src/lib/dkc/ibe.ts and src/lib/dkc/tlock.ts; " +
|
||||||
|
"see scripts/tlock-go-vectors.go for how each block is obtained.",
|
||||||
|
"generator": "scripts/tlock-go-vectors.go",
|
||||||
|
"libraries": libraries(),
|
||||||
|
"scheme": scheme.Name,
|
||||||
|
"public_key": hex.EncodeToString(quicknet.PublicKey),
|
||||||
|
"encrypt": vectors,
|
||||||
|
"interop": map[string]any{"generator": in.Generator, "samples": in.Samples},
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(out); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraries names the versions of the libraries this program ran with.
|
||||||
|
func libraries() string {
|
||||||
|
info, ok := debug.ReadBuildInfo()
|
||||||
|
if !ok {
|
||||||
|
panic("no build info")
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, d := range info.Deps {
|
||||||
|
switch d.Path {
|
||||||
|
case "filippo.io/age", "github.com/drand/tlock", "github.com/drand/kyber", "github.com/drand/kyber-bls12381", "github.com/drand/drand/v2":
|
||||||
|
out = append(out, d.Path+" "+d.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Writes, as JSON, tlock ciphertexts made by the TypeScript library of this
|
||||||
|
// repository, for scripts/tlock-go-vectors.go to open with the Go reference
|
||||||
|
// (plan of phase 2, section 8, points 4 and 5):
|
||||||
|
//
|
||||||
|
// - IBE: a random 16-byte file key encrypted with encryptOnG2RFC9380 for
|
||||||
|
// rounds 1000 and 1001 of Quicknet, as the tlock stanza body U || V || W;
|
||||||
|
// - age: an age file whose only stanza timeRecipient wrote, through the
|
||||||
|
// Encrypter of age-encryption, for rounds 1000 and 1001.
|
||||||
|
//
|
||||||
|
// The randomness makes every run different: the output is generated once and
|
||||||
|
// frozen, with the verdicts of Go, in src/lib/dkc/testing/tlock-vectors.json.
|
||||||
|
// Node runs the TypeScript sources directly (type stripping, Node 22.6+):
|
||||||
|
//
|
||||||
|
// node scripts/tlock-ts-samples.mjs > ts-samples.json
|
||||||
|
|
||||||
|
import { Encrypter } from 'age-encryption';
|
||||||
|
import { ciphertextToBody, encryptOnG2RFC9380, roundIdentity } from '../src/lib/dkc/ibe.ts';
|
||||||
|
import { quicknet } from '../src/lib/dkc/profile.ts';
|
||||||
|
import { timeRecipient } from '../src/lib/dkc/tlock.ts';
|
||||||
|
|
||||||
|
const hex = (b) => Buffer.from(b).toString('hex');
|
||||||
|
const p = quicknet();
|
||||||
|
const samples = [];
|
||||||
|
for (const round of [1000, 1001]) {
|
||||||
|
const fileKey = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const body = ciphertextToBody(encryptOnG2RFC9380(p.publicKey, roundIdentity(round), fileKey));
|
||||||
|
samples.push({ name: `IBE, round ${round}`, kind: 'ibe', round, file_key: hex(fileKey), body: hex(body) });
|
||||||
|
const e = new Encrypter();
|
||||||
|
e.addRecipient(timeRecipient(p, round));
|
||||||
|
const plaintext = new TextEncoder().encode(`DateKeys: sealed by the TypeScript library for round ${round}`);
|
||||||
|
samples.push({ name: `age file, round ${round}`, kind: 'age', round, plaintext: hex(plaintext), file: hex(await e.encrypt(plaintext)) });
|
||||||
|
}
|
||||||
|
process.stdout.write(`${JSON.stringify({ generator: 'scripts/tlock-ts-samples.mjs', samples }, null, 1)}\n`);
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"description": "Go reference values for the tlock encryption of src/lib/dkc/ibe.ts and src/lib/dkc/tlock.ts; see scripts/tlock-go-vectors.go for how each block is obtained.",
|
||||||
|
"encrypt": [
|
||||||
|
{
|
||||||
|
"name": "a 16-byte message for round 1000",
|
||||||
|
"round": 1000,
|
||||||
|
"id": "f652498d092acd949bad74e40683bf3824fb817980504a0c7e6722cfc5a9c0a3",
|
||||||
|
"msg": "16e27bcb7ff0267cd0db9c1dc1459696",
|
||||||
|
"sigma": "3d4725349d1d12947a3195aa2cb5dd00",
|
||||||
|
"u": "80364e97a868ee0dcdcf79a71ab7901f97ae9d38069110e5eacf630842857a52685ebaaf41b3e8682664893748443a3f1137cc7e1298814bab8d47d13416083a3b32d224713eaf141bef1c3775d580a939ffb316a603b68bd3d8799a95feb355",
|
||||||
|
"v": "518c9d924cdd84ef24472ee2dae6c5f5",
|
||||||
|
"w": "3cb104bcb0c445ea0b576a9aa64bfa01",
|
||||||
|
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a 16-byte message for round 1001",
|
||||||
|
"round": 1001,
|
||||||
|
"id": "ce43c3353a7ad7aac3408cad0bf921b6a7dda89be75d9cb2b3b5a152cefc8afd",
|
||||||
|
"msg": "4a1a048e28b2a9497373df269686925b",
|
||||||
|
"sigma": "47bd5bdca9e4341af640abf2e2624261",
|
||||||
|
"u": "84ea313521e2f15b2498e0dbb1125226648e963f7be8a1314444f7d38a92506f38acd599401bbf27f2f9249a8049734a17beff0781a1810ef7a9d53ea50372835cfa0d2260797d2bffb820f4ad4d78506667132cc4db65a483604df02332da71",
|
||||||
|
"v": "f0e5eafdb06ff7b9d7a32b607d2ee510",
|
||||||
|
"w": "d8e72ed588af9cb7ea6bb5b57d737108",
|
||||||
|
"signature": "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a 1-byte message for round 1000",
|
||||||
|
"round": 1000,
|
||||||
|
"id": "f652498d092acd949bad74e40683bf3824fb817980504a0c7e6722cfc5a9c0a3",
|
||||||
|
"msg": "bd",
|
||||||
|
"sigma": "d6",
|
||||||
|
"u": "92f597b1d29b056f019cd7afd0eb51ef2f1fc1708915f742762062ddf7e500389373638651935810699f9cab987c57390b9677f844e89fb2c8560f74ae68159dc9df9c0ee667d5c507718b51b490feb780a78cdff5146e27a77ddc36f9e45a48",
|
||||||
|
"v": "b8",
|
||||||
|
"w": "4a",
|
||||||
|
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a 32-byte message for round 1000",
|
||||||
|
"round": 1000,
|
||||||
|
"id": "f652498d092acd949bad74e40683bf3824fb817980504a0c7e6722cfc5a9c0a3",
|
||||||
|
"msg": "3ccb765d8df56d226b3f7e20ce159302b1d3b2924e9a33345d31fd3aa0f3ec5e",
|
||||||
|
"sigma": "bdd1ba60cac27c3e6e5eea1678cf1c5586e694a9d775963153acb4402ce10d59",
|
||||||
|
"u": "841d1a934afcdbf1a6af15d6218406a8512ddc2dff465469a95719548619fc47ef9753fc3f9427066ba8e0b1791a01e80997324dd9f7b3976d340ef73f6461b330e363006519439df2ecad28ed903f48fbcf3d2616f8249b63dd4c0f8771b9a8",
|
||||||
|
"v": "5d695cd7e98b4cb4f5938941647878361ac7bf7db79843c92825cef241be9675",
|
||||||
|
"w": "19fcedeece1f38d41bbe569f06b8d3b1f0fd1cdacb1b08d8033d4202cbd7c395",
|
||||||
|
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generator": "scripts/tlock-go-vectors.go",
|
||||||
|
"interop": {
|
||||||
|
"generator": "scripts/tlock-ts-samples.mjs",
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"name": "IBE, round 1000",
|
||||||
|
"kind": "ibe",
|
||||||
|
"round": 1000,
|
||||||
|
"file_key": "aa07feff048bdc07e5a2ea0c2e35474c",
|
||||||
|
"body": "add5cd1894c304db02a4134742163bf672b9ff44802a8f388b22bfa2702130b4044612a950b7083da8dae36dea71a86b001ae693d556092d8ccf038e667d61fe8bf5915d9fcbb3373e8d6f46799e610bc580a17aa87174d0c2429ed63b240b4feb23d3bcc12befe90d39c5ab0147a6347bd576ebc613996bc61c4338e38a21b6",
|
||||||
|
"go": "ok",
|
||||||
|
"go_result": "aa07feff048bdc07e5a2ea0c2e35474c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "age file, round 1000",
|
||||||
|
"kind": "age",
|
||||||
|
"round": 1000,
|
||||||
|
"plaintext": "446174654b6579733a207365616c6564206279207468652054797065536372697074206c69627261727920666f7220726f756e642031303030",
|
||||||
|
"file": "6167652d656e6372797074696f6e2e6f72672f76310a2d3e20746c6f636b203130303020353264623962613730653063633066366561663738303364643037343437613166353437373733356664336636363137393262613934363030633834653937310a74472b49697069425a395753487674777947497733465761555542646e514a474e36637541303051452f6c59584b4b743664686f7444745352616b362b334a780a4371654e4837436868536970523551324c486a6d6d6f32313478716657637135542b4830446f73797a5369664f5448524371637668655453505a4977797052470a755738495a50364a4d5356642f655145447768524c7834304a6972687258443442474b3273584b6761516f0a2d2d2d2034393658794d664878427677543845625a4c4f6d41345a364f4a36487245497459316171614a79726165550a0aafc6b79c266610bd43455d546ad8215ffd491c2a358b1de42751d9d032a70f8c39cd0bd384885e970020a79805b9547364b7254209f2ae5eda3eae9df7abf8d66ed004180dec058068447c010279de8624c9d06442a77a72",
|
||||||
|
"go": "ok",
|
||||||
|
"go_result": "446174654b6579733a207365616c6564206279207468652054797065536372697074206c69627261727920666f7220726f756e642031303030"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IBE, round 1001",
|
||||||
|
"kind": "ibe",
|
||||||
|
"round": 1001,
|
||||||
|
"file_key": "a3bbf76b3f17c2a7a3d0b9fd62208d60",
|
||||||
|
"body": "98a8f585fe3ded2e59b27335996af344ed5b9fb9c80b3016416ba93b9f80e610b6286cf3a0172a775b097da6646e5237039fab9ac191490f0ee900aeb739f85cf14a673b0ebea19dea85454ddd3431679b726a0015adc2c408617ba3e0d0e22c768e45f47369a53dd32cb51c25a89e57ec4ee19294e0f229f259b9293fea8277",
|
||||||
|
"go": "ok",
|
||||||
|
"go_result": "a3bbf76b3f17c2a7a3d0b9fd62208d60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "age file, round 1001",
|
||||||
|
"kind": "age",
|
||||||
|
"round": 1001,
|
||||||
|
"plaintext": "446174654b6579733a207365616c6564206279207468652054797065536372697074206c69627261727920666f7220726f756e642031303031",
|
||||||
|
"file": "6167652d656e6372797074696f6e2e6f72672f76310a2d3e20746c6f636b203130303120353264623962613730653063633066366561663738303364643037343437613166353437373733356664336636363137393262613934363030633834653937310a70584e2b37746e6f4137304e524653715a484d6f7253637456326749636a39774a6f6b506c6134772f50732b4665725872774c32554a796a67434164423075530a42595075456b42415178393473654c5a5344694a7a736f6c366e474544705a6966654c6b324c6b2f544b794c766861495037433976645554334643664563314a0a4c59685074457772702b4c4b5633354a597031484d6f524832393970646e656f787a2b674f645a6d4f6d6b0a2d2d2d20644d547177433836776b53376d4c42594b6147684b736a634b4c48725a4532346c612f6b53366c306736670aedaec2f1c62d0f0229007f0e6f730d4cbda71a2ecd781d087b5f08245abe8854f35fa7b8319db3b89270feb1306a772f118caa974ae80255597efcd3b33b1c95ca5e8f930e2338c85f91b72e035c7dac242f4118971607971b",
|
||||||
|
"go": "ok",
|
||||||
|
"go_result": "446174654b6579733a207365616c6564206279207468652054797065536372697074206c69627261727920666f7220726f756e642031303031"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"libraries": "filippo.io/age v1.3.2, github.com/drand/drand/v2 v2.1.7, github.com/drand/kyber v1.3.2, github.com/drand/kyber-bls12381 v0.3.4, github.com/drand/tlock v1.2.0",
|
||||||
|
"public_key": "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a",
|
||||||
|
"scheme": "bls-unchained-g1-rfc9380"
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
// Tests of the tlock encryption (ibe.ts, tlock.ts) against the Go reference:
|
||||||
|
// src/lib/dkc/testing/tlock-vectors.json, written by scripts/tlock-go-vectors.go
|
||||||
|
// from the samples of scripts/tlock-ts-samples.mjs (plan of phase 2, section
|
||||||
|
// 8, points 4 and 5).
|
||||||
|
|
||||||
|
import { Decrypter, Encrypter } from 'age-encryption';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { checkTimeStanzas, parseAgeHeader } from './age.ts';
|
||||||
|
import { DateKeysError } from './errors.ts';
|
||||||
|
import { ciphertextFromBody, decryptOnG2, encryptOnG2RFC9380, encryptOnG2WithSigma, IbeError, roundIdentity } from './ibe.ts';
|
||||||
|
import { timeIdentity } from './open.ts';
|
||||||
|
import { chainHashHex, maxRound, type Profile, quicknet } from './profile.ts';
|
||||||
|
import type { Release } from './release.ts';
|
||||||
|
import { h, hx } from './testing/testdata.ts';
|
||||||
|
import { timeRecipient } from './tlock.ts';
|
||||||
|
|
||||||
|
type Sample = { name: string; kind: 'ibe' | 'age'; round: number; file_key?: string; body?: string; plaintext?: string; file?: string; go: string; go_result?: string };
|
||||||
|
const V = JSON.parse(readFileSync(new URL('./testing/tlock-vectors.json', import.meta.url), 'utf8')) as {
|
||||||
|
scheme: string;
|
||||||
|
public_key: string;
|
||||||
|
encrypt: { name: string; round: number; id: string; msg: string; sigma: string; u: string; v: string; w: string; signature: string }[];
|
||||||
|
interop: { generator: string; samples: Sample[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = quicknet();
|
||||||
|
const SIGNATURES: Record<number, string> = Object.fromEntries(V.encrypt.map((v) => [v.round, v.signature]));
|
||||||
|
const release = (round: number): Release => ({ round, signature: h(SIGNATURES[round]!) });
|
||||||
|
|
||||||
|
// Opens an age file with the tlock identity of step 11.
|
||||||
|
async function openAge(file: Uint8Array, round: number): Promise<Uint8Array> {
|
||||||
|
const d = new Decrypter();
|
||||||
|
d.addIdentity(timeIdentity(p, round, release(round)));
|
||||||
|
return d.decrypt(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tlock encryption', () => {
|
||||||
|
it('reads vectors of the Quicknet scheme and key', () => {
|
||||||
|
expect([V.scheme, V.public_key]).toEqual(['bls-unchained-g1-rfc9380', hx(p.publicKey)]);
|
||||||
|
expect(Object.keys(SIGNATURES).sort()).toEqual(['1000', '1001']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypts as the reference, byte for byte, for a given sigma', () => {
|
||||||
|
for (const v of V.encrypt) {
|
||||||
|
expect(hx(roundIdentity(v.round)), v.name).toBe(v.id);
|
||||||
|
const ct = encryptOnG2WithSigma(p.publicKey, h(v.id), h(v.msg), h(v.sigma));
|
||||||
|
expect([hx(ct.U), hx(ct.V), hx(ct.W)], v.name).toEqual([v.u, v.v, v.w]);
|
||||||
|
expect(hx(decryptOnG2(h(v.signature), ct)), v.name).toBe(v.msg);
|
||||||
|
}
|
||||||
|
expect(V.encrypt.map((v) => h(v.msg).length).sort((a, b) => a - b)).toEqual([1, 16, 16, 32]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('made ciphertexts that the reference opened: IBE bodies with tlock.TimeUnlock, age files with agewrap.TimeIdentity', async () => {
|
||||||
|
expect(V.interop.generator).toBe('scripts/tlock-ts-samples.mjs');
|
||||||
|
expect(V.interop.samples.map((s) => `${s.kind} ${s.round}`).sort()).toEqual(['age 1000', 'age 1001', 'ibe 1000', 'ibe 1001']);
|
||||||
|
for (const s of V.interop.samples) {
|
||||||
|
expect(s.go, s.name).toBe('ok');
|
||||||
|
if (s.kind === 'ibe') {
|
||||||
|
expect(s.go_result, s.name).toBe(s.file_key);
|
||||||
|
expect(hx(decryptOnG2(release(s.round).signature, ciphertextFromBody(h(s.body!)))), s.name).toBe(s.file_key);
|
||||||
|
} else {
|
||||||
|
expect(s.go_result, s.name).toBe(s.plaintext);
|
||||||
|
expect(hx(await openAge(h(s.file!), s.round)), s.name).toBe(s.plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a new sigma every time, and the signature of the round opens every ciphertext', () => {
|
||||||
|
const msg = h('00112233445566778899aabbccddeeff');
|
||||||
|
const a = encryptOnG2RFC9380(p.publicKey, roundIdentity(1001), msg);
|
||||||
|
const b = encryptOnG2RFC9380(p.publicKey, roundIdentity(1001), msg);
|
||||||
|
expect(hx(a.U)).not.toBe(hx(b.U));
|
||||||
|
for (const ct of [a, b]) expect(decryptOnG2(release(1001).signature, ct)).toEqual(msg);
|
||||||
|
expect(() => decryptOnG2(release(1000).signature, a)).toThrow(IbeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a message longer than 32 bytes, a sigma of another length and a key that is not a canonical point', () => {
|
||||||
|
const id = roundIdentity(1000);
|
||||||
|
const reason = (fn: () => unknown): [string, string] => {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (e) {
|
||||||
|
return [(e as IbeError).reason, (e as IbeError).message];
|
||||||
|
}
|
||||||
|
return ['none', ''];
|
||||||
|
};
|
||||||
|
const infinity = new Uint8Array(96);
|
||||||
|
infinity[0] = 0xc0;
|
||||||
|
const offCurve = p.publicKey.slice();
|
||||||
|
offCurve[95]! ^= 1;
|
||||||
|
expect(reason(() => encryptOnG2RFC9380(p.publicKey, id, new Uint8Array(33)))).toEqual(['length', 'ibe: a message of 33 bytes, want at most 32']);
|
||||||
|
expect(reason(() => encryptOnG2WithSigma(p.publicKey, id, new Uint8Array(16), new Uint8Array(15)))).toEqual([
|
||||||
|
'length',
|
||||||
|
'ibe: sigma of 15 bytes for a message of 16',
|
||||||
|
]);
|
||||||
|
expect(reason(() => encryptOnG2RFC9380(p.publicKey.subarray(1), id, new Uint8Array(16)))).toEqual(['length', 'ibe: the public key of 95 bytes, want 96']);
|
||||||
|
expect(reason(() => encryptOnG2RFC9380(infinity, id, new Uint8Array(16)))).toEqual(['identity', 'ibe: the public key is the point at infinity']);
|
||||||
|
expect(reason(() => encryptOnG2RFC9380(offCurve, id, new Uint8Array(16)))[0]).toBe('encoding');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timeRecipient', () => {
|
||||||
|
it('writes the stanza of tlock, which the identity of step 11 opens', async () => {
|
||||||
|
const stanzas = await timeRecipient(p, 1000).wrapFileKey(h('0f'.repeat(16)));
|
||||||
|
expect(stanzas).toHaveLength(1);
|
||||||
|
expect(stanzas[0]!.args).toEqual(['tlock', '1000', chainHashHex(p)]);
|
||||||
|
expect(stanzas[0]!.body).toHaveLength(128);
|
||||||
|
const e = new Encrypter();
|
||||||
|
e.addRecipient(timeRecipient(p, 1000));
|
||||||
|
const file = await e.encrypt('a control sealed until round 1000');
|
||||||
|
checkTimeStanzas(parseAgeHeader(file).stanzas, p, 1000);
|
||||||
|
expect(new TextDecoder().decode(await openAge(file, 1000))).toBe('a control sealed until round 1000');
|
||||||
|
await expect(openAge(file, 1001)).rejects.toThrow(DateKeysError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the profile, then the round, with the texts of NewTimeRecipient', () => {
|
||||||
|
const failure = (q: Profile, round: number): [string, string] => {
|
||||||
|
try {
|
||||||
|
timeRecipient(q, round);
|
||||||
|
} catch (e) {
|
||||||
|
return [(e as DateKeysError).code, (e as DateKeysError).message];
|
||||||
|
}
|
||||||
|
return ['none', ''];
|
||||||
|
};
|
||||||
|
const infinity = new Uint8Array(96);
|
||||||
|
infinity[0] = 0xc0;
|
||||||
|
const flags = p.publicKey.slice();
|
||||||
|
flags[0]! ^= 0x80;
|
||||||
|
expect(failure({ ...p, scheme: 'pedersen-bls-unchained' }, 1000)).toEqual([
|
||||||
|
'ERR_UNKNOWN_PROFILE',
|
||||||
|
'agewrap: profile datekeys:quicknet:v1 uses scheme pedersen-bls-unchained; only bls-unchained-g1-rfc9380 is supported here: ERR_UNKNOWN_PROFILE',
|
||||||
|
]);
|
||||||
|
expect(failure({ ...p, publicKey: flags }, 1000)).toEqual([
|
||||||
|
'ERR_UNKNOWN_PROFILE',
|
||||||
|
'agewrap: pinned public key of datekeys:quicknet:v1 is not the canonical encoding of a point of the key group: ERR_UNKNOWN_PROFILE',
|
||||||
|
]);
|
||||||
|
expect(failure({ ...p, publicKey: infinity }, 1000)).toEqual([
|
||||||
|
'ERR_UNKNOWN_PROFILE',
|
||||||
|
'agewrap: pinned public key of datekeys:quicknet:v1 is the identity element: ERR_UNKNOWN_PROFILE',
|
||||||
|
]);
|
||||||
|
for (const round of [0, maxRound(p) + 1, 1000.5]) {
|
||||||
|
expect(failure(p, round), String(round)).toEqual(['ERR_DATEKEY_INVALID', `agewrap: round ${round} outside the range of datekeys:quicknet:v1: ERR_DATEKEY_INVALID`]);
|
||||||
|
}
|
||||||
|
// The profile comes first.
|
||||||
|
expect(failure({ ...p, publicKey: infinity }, 0)[0]).toBe('ERR_UNKNOWN_PROFILE');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
// The tlock recipient of OUTER_TIME_AGE (spec §32, §35), as Go's
|
||||||
|
// agewrap.TimeRecipient: it wraps the file key with the IBE of ibe.ts for
|
||||||
|
// one round of a pinned profile, and writes the stanza
|
||||||
|
// "tlock <round> <chain hash>" that tlock and its tle CLI write.
|
||||||
|
//
|
||||||
|
// Go gives its recipient a random label, so that age refuses any other
|
||||||
|
// recipient in the same file. age-encryption has no labels: the writer of
|
||||||
|
// OUTER_TIME_AGE adds this recipient alone (spec §32).
|
||||||
|
|
||||||
|
import { type Recipient, Stanza } from 'age-encryption';
|
||||||
|
import { checkCompressedPoint } from './bls12381.ts';
|
||||||
|
import { DateKeysError } from './errors.ts';
|
||||||
|
import { ciphertextToBody, encryptOnG2RFC9380, roundIdentity } from './ibe.ts';
|
||||||
|
import { chainHashHex, maxRound, type Profile, QUICKNET_SCHEME } from './profile.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient that wraps a file key for `round` under the pinned profile
|
||||||
|
* `p`, of the scheme of Quicknet. Its checks and texts are those of Go's
|
||||||
|
* NewTimeRecipient: the profile first, then the range of the round.
|
||||||
|
*/
|
||||||
|
export function timeRecipient(p: Profile, round: number): Recipient {
|
||||||
|
if (p.scheme !== QUICKNET_SCHEME) {
|
||||||
|
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `agewrap: profile ${p.id} uses scheme ${p.scheme}; only ${QUICKNET_SCHEME} is supported here`);
|
||||||
|
}
|
||||||
|
const key = checkCompressedPoint('G2', p.publicKey);
|
||||||
|
if (key === 'invalid') {
|
||||||
|
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `agewrap: pinned public key of ${p.id} is not the canonical encoding of a point of the key group`);
|
||||||
|
}
|
||||||
|
if (key === 'identity') throw new DateKeysError('ERR_UNKNOWN_PROFILE', `agewrap: pinned public key of ${p.id} is the identity element`);
|
||||||
|
if (!Number.isSafeInteger(round) || round < 1 || round > maxRound(p)) {
|
||||||
|
throw new DateKeysError('ERR_DATEKEY_INVALID', `agewrap: round ${round} outside the range of ${p.id}`);
|
||||||
|
}
|
||||||
|
const args = ['tlock', String(round), chainHashHex(p)];
|
||||||
|
const id = roundIdentity(round);
|
||||||
|
const publicKey = p.publicKey.slice();
|
||||||
|
return {
|
||||||
|
wrapFileKey(fileKey: Uint8Array): Stanza[] {
|
||||||
|
return [new Stanza([...args], ciphertextToBody(encryptOnG2RFC9380(publicKey, id, fileKey)))];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in new issue