encryptFiles gains authorKey (alg 1, an AuthorSigner such as AuthorKey), cmsSigner (alg 2, a CMS signature with certificates), sealer (seal_type 2, an RFC 3161 token) and largeArea, as EncryptOptions of Go at spec-v0.12: the same checks in the same order with the same texts, the signature and the seal made with the final control and head and before anything is written, and the security area evaluated by the reader of this library in the context of the capsule before it is written, as Go's security does. The hooks may be asynchronous. The area grows to 64 KiB only when what was signed does not fit and largeArea allows it, and the larger capsule counts in the limit of memory. security.ts encodes the area with its signature and seal, and securitycms.ts encodes SIGNERS. scripts/signing-go-vectors_test.go, run as a test in an export of datekeys-go at spec-v0.12, writes testing/signing-vectors.json: with the draws of crypto/rand of Go and the signatures and tokens of its hooks, encryptFiles writes the eight signed and sealed capsules of Go byte for byte, asks the hooks over the same messages, and fails with the text of Go in the other 15 recipes; and Go opens the five capsules that scripts/signing-ts-samples.mjs writes with this library, its own random values and certificates, with the same verdicts and lines. check-build.mjs fails when a page loads the author keys with the page, or when /inspect can load them at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
96614d5e79
commit
9e5e3ba081
@ -0,0 +1,492 @@
|
||||
// Writes src/lib/dkc/testing/signing-vectors.json, the interoperability of
|
||||
// the hooks of the writer of this library with the Go reference at
|
||||
// spec-v0.12: what capsule.EncryptFiles writes when it signs with alg 1 or
|
||||
// alg 2 and seals with seal_type 2, and how Go reads what encryptFiles of
|
||||
// this library writes.
|
||||
//
|
||||
// cases: for each recipe of this file, EncryptFiles runs while crypto/rand
|
||||
// reads a ChaCha20 keystream under SHA-256(seed) and a zero nonce, and each
|
||||
// draw is recorded in hexadecimal, in its order: the salt of the head,
|
||||
// capsule_id, I_PAYLOAD, what age draws for the measured seal, the real seal
|
||||
// and PAYLOAD_AGE. The hooks are those of the tests of package capsule
|
||||
// (signed_test.go): an author key from a seed (alg 1), and the CMS signatures
|
||||
// and RFC 3161 tokens of internal/cms/cmstest (alg 2 and seal_type 2), whose
|
||||
// ECDSA and RSA draw from Go's internal generator, which only
|
||||
// testing/cryptotest.SetGlobalRandom fixes, in a test binary: this file runs
|
||||
// as a test. What each hook was given and returned is recorded, so that the
|
||||
// tests of this library hand the writer the same signatures and tokens, check
|
||||
// that it asks for them over the same messages, and write the same bytes with
|
||||
// the same draws. For each capsule it records its length, its SHA-256, its
|
||||
// SECURITY_CBOR and the size of its area, and what capsule.Open gives, with
|
||||
// and without the author key saved under a label: the verdicts, the lines that
|
||||
// show them, the head and the files. For each error, its text.
|
||||
//
|
||||
// samples: with -samples, the capsules that scripts/signing-ts-samples.mjs
|
||||
// writes with encryptFiles of this library, with its own random values and
|
||||
// its own certificates, opened with capsule.Open, with their verdicts and
|
||||
// lines.
|
||||
//
|
||||
// It imports internal packages, so it runs as a test in an export of
|
||||
// datekeys-go at the tag spec-v0.12 made with git archive, which it does not
|
||||
// change, never in the repository itself. From the root of this repository:
|
||||
//
|
||||
// node scripts/signing-ts-samples.mjs > /tmp/ts-signing.json
|
||||
// commit=$(git -C ../datekeys-go rev-parse 'spec-v0.12^{commit}')
|
||||
// tmp=$(mktemp -d)
|
||||
// git -C ../datekeys-go archive "$commit" | tar -x -C "$tmp"
|
||||
// mkdir "$tmp/signingvectors"
|
||||
// cp scripts/signing-go-vectors_test.go "$tmp/signingvectors/"
|
||||
// (cd "$tmp/signingvectors" && go test -run TestSigningVectors -count=1 \
|
||||
// -args -source "$commit" -samples /tmp/ts-signing.json \
|
||||
// -out "$OLDPWD/src/lib/dkc/testing/signing-vectors.json")
|
||||
// rm -rf "$tmp"
|
||||
//
|
||||
// The cases are the same on every run with Go 1.26.8; the samples are
|
||||
// random, and frozen with what Go gives for them.
|
||||
package signingvectors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"testing/cryptotest"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/chacha20"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
"g.activething.com/go/DateKeys/authorkey"
|
||||
"g.activething.com/go/DateKeys/capsule"
|
||||
"g.activething.com/go/DateKeys/datekey"
|
||||
"g.activething.com/go/DateKeys/internal/cms/cmstest"
|
||||
"g.activething.com/go/DateKeys/internal/testkit"
|
||||
"g.activething.com/go/DateKeys/profile"
|
||||
"g.activething.com/go/DateKeys/provider"
|
||||
)
|
||||
|
||||
var (
|
||||
sourceFlag = flag.String("source", "", "the commit of datekeys-go that this tree exports")
|
||||
outFlag = flag.String("out", "", "the JSON file to write")
|
||||
samplesFlag = flag.String("samples", "", "the output of scripts/signing-ts-samples.mjs")
|
||||
)
|
||||
|
||||
type obj = map[string]any
|
||||
|
||||
func h(b []byte) string { return hex.EncodeToString(b) }
|
||||
|
||||
func sum(b []byte) string {
|
||||
s := sha256.Sum256(b)
|
||||
return h(s[:])
|
||||
}
|
||||
|
||||
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)) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The recipes
|
||||
|
||||
type authorIn struct {
|
||||
Seed string `json:"seed"` // hex, 32 bytes
|
||||
Bad string `json:"bad,omitempty"` // zero_signature, short_key
|
||||
}
|
||||
|
||||
type cmsIn struct {
|
||||
Signers []string `json:"signers"` // names of the certificates that sign
|
||||
Extra string `json:"extra,omitempty"` // a required signer that does not sign
|
||||
Unsealed bool `json:"unsealed,omitempty"` // no seal in the signatures
|
||||
Junk int `json:"junk,omitempty"` // bytes of an unsigned attribute; -1 for the most that still fails as too large
|
||||
Error string `json:"error,omitempty"` // Sign fails with this text
|
||||
Garbage bool `json:"garbage,omitempty"` // Sign returns bytes that are no signature
|
||||
}
|
||||
|
||||
type sealerIn struct {
|
||||
Error string `json:"error,omitempty"` // Seal fails with this text
|
||||
Late bool `json:"late,omitempty"` // the authority seals an hour after the time of the round
|
||||
}
|
||||
|
||||
type fileIn struct {
|
||||
Path string `json:"path"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type recipe struct {
|
||||
Name string `json:"name"`
|
||||
Seed string `json:"seed"`
|
||||
Files []fileIn `json:"files,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
AuthorKey *authorIn `json:"author_key,omitempty"`
|
||||
CMS *cmsIn `json:"cms,omitempty"`
|
||||
Sealer *sealerIn `json:"sealer,omitempty"`
|
||||
LargeArea bool `json:"large_area,omitempty"`
|
||||
TestAreaLen uint32 `json:"test_area_len,omitempty"`
|
||||
}
|
||||
|
||||
const authorSeed = "a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0"
|
||||
|
||||
func recipes() []recipe {
|
||||
files := []fileIn{{"nota.txt", "Hola.\n"}, {"fotos/año 2026.txt", "Una foto que no es una foto.\n"}}
|
||||
key := func() *authorIn { return &authorIn{Seed: authorSeed} }
|
||||
var out []recipe
|
||||
add := func(r recipe) {
|
||||
r.Seed = "signing " + r.Name
|
||||
out = append(out, r)
|
||||
}
|
||||
// Capsules.
|
||||
add(recipe{Name: "alg 1", Files: files, Comment: "Firmado con mi clave.", Author: "Ana López", AuthorKey: key()})
|
||||
add(recipe{Name: "alg 1 and a seal", Files: files, AuthorKey: key(), Sealer: &sealerIn{}})
|
||||
add(recipe{Name: "a seal alone", Files: files[:1], Sealer: &sealerIn{}})
|
||||
add(recipe{Name: "alg 2, two signers, sealed", Files: files, Comment: "Firmado por los dos.", CMS: &cmsIn{Signers: []string{"Ana López", "Luis Gómez"}}})
|
||||
add(recipe{Name: "alg 2, a large area", Files: files[:1], CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40000}, LargeArea: true})
|
||||
add(recipe{Name: "alg 1, a large area that does not widen", Comment: "Solo un comentario.", AuthorKey: key(), LargeArea: true})
|
||||
add(recipe{Name: "alg 1, an area of 512", Files: files[:1], AuthorKey: key(), TestAreaLen: 512})
|
||||
add(recipe{Name: "a seal after the date", Files: files[:1], AuthorKey: key(), Sealer: &sealerIn{Late: true}})
|
||||
// Errors.
|
||||
add(recipe{Name: "AuthorKey and CMSSigner", Files: files, AuthorKey: key(), CMS: &cmsIn{Signers: []string{"Ana López"}}})
|
||||
add(recipe{Name: "CMSSigner and Sealer", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}}, Sealer: &sealerIn{}})
|
||||
add(recipe{Name: "a test area and LargeArea", Files: files, AuthorKey: key(), TestAreaLen: 512, LargeArea: true})
|
||||
add(recipe{Name: "an author key of 31 bytes", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "short_key"}})
|
||||
add(recipe{Name: "a signature of zeros", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "zero_signature"}})
|
||||
add(recipe{Name: "a signature of zeros and a seal", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "zero_signature"}, Sealer: &sealerIn{}})
|
||||
add(recipe{Name: "alg 2 that does not fit", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40000}})
|
||||
add(recipe{Name: "the signing application fails", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Error: "la persona canceló la firma"}})
|
||||
add(recipe{Name: "the authority fails", Files: files, AuthorKey: key(), Sealer: &sealerIn{Error: "the authority does not answer"}})
|
||||
add(recipe{Name: "alg 2 without a required signer", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Extra: "Luis Gómez"}})
|
||||
add(recipe{Name: "alg 2 without seals", Files: files, CMS: &cmsIn{Signers: []string{"Luis Gómez"}, Unsealed: true}})
|
||||
add(recipe{Name: "alg 2 that is not a signature", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Garbage: true}})
|
||||
add(recipe{Name: "SIGNERS empty", Files: files, CMS: &cmsIn{}})
|
||||
add(recipe{Name: "SIGNERS twice", Files: files, CMS: &cmsIn{Signers: []string{"Ana López", "Ana López"}}})
|
||||
// Last, since its search draws from the generator of the test.
|
||||
add(recipe{Name: "alg 2 too large for any area", Files: files[:1], CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: -1}, LargeArea: true})
|
||||
return out
|
||||
}
|
||||
|
||||
// seeded is the crypto/rand.Reader of a case: the ChaCha20 keystream under
|
||||
// SHA-256(seed) and a zero nonce. It records every draw.
|
||||
type seeded struct {
|
||||
c *chacha20.Cipher
|
||||
draws [][]byte
|
||||
}
|
||||
|
||||
func newSeeded(seed string) *seeded {
|
||||
key := sha256.Sum256([]byte(seed))
|
||||
return &seeded{c: must(chacha20.NewUnauthenticatedCipher(key[:], make([]byte, chacha20.NonceSize)))}
|
||||
}
|
||||
|
||||
func (s *seeded) Read(p []byte) (int, error) {
|
||||
clear(p)
|
||||
s.c.XORKeyStream(p, p)
|
||||
s.draws = append(s.draws, bytes.Clone(p))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The keys and the hooks
|
||||
|
||||
type certs struct {
|
||||
byName map[string]cmstest.Signer
|
||||
tsa cmstest.Signer
|
||||
}
|
||||
|
||||
var certFrom, certTo = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func newCerts() *certs {
|
||||
c := &certs{byName: map[string]cmstest.Signer{}}
|
||||
c.byName["Ana López"] = cmstest.NewECDSA("Ana López", elliptic.P256(), certFrom, certTo)
|
||||
c.byName["Luis Gómez"] = cmstest.NewRSA("Luis Gómez", 2048, certFrom, certTo)
|
||||
c.tsa = cmstest.NewECDSA("TSA de prueba", elliptic.P256(), certFrom, certTo)
|
||||
return c
|
||||
}
|
||||
|
||||
type hooks struct{ rec obj }
|
||||
|
||||
func (k *hooks) set(name string, v any) { k.rec[name] = v }
|
||||
|
||||
type authorHook struct {
|
||||
key *authorkey.Key
|
||||
bad string
|
||||
k *hooks
|
||||
}
|
||||
|
||||
func (a *authorHook) Public() []byte {
|
||||
pub := a.key.Public()
|
||||
if a.bad == "short_key" {
|
||||
pub = pub[:31]
|
||||
}
|
||||
a.k.set("author_public", h(pub))
|
||||
return pub
|
||||
}
|
||||
|
||||
func (a *authorHook) Sign(msg []byte) []byte {
|
||||
sig := a.key.Sign(msg)
|
||||
if a.bad == "zero_signature" {
|
||||
sig = make([]byte, ed25519.SignatureSize)
|
||||
}
|
||||
a.k.set("author_message", h(msg))
|
||||
a.k.set("author_signature", h(sig))
|
||||
return sig
|
||||
}
|
||||
|
||||
type cmsHook struct {
|
||||
in cmsIn
|
||||
c *certs
|
||||
when time.Time
|
||||
k *hooks
|
||||
}
|
||||
|
||||
func (s *cmsHook) signers() []cmstest.Signer {
|
||||
var out []cmstest.Signer
|
||||
for _, n := range s.in.Signers {
|
||||
out = append(out, s.c.byName[n])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *cmsHook) Signers() [][32]byte {
|
||||
out := [][32]byte{}
|
||||
for _, x := range s.signers() {
|
||||
out = append(out, sha256.Sum256(x.Cert.Raw))
|
||||
}
|
||||
if s.in.Extra != "" {
|
||||
out = append(out, sha256.Sum256(s.c.byName[s.in.Extra].Cert.Raw))
|
||||
}
|
||||
list := []string{}
|
||||
for _, x := range out {
|
||||
list = append(list, h(x[:]))
|
||||
}
|
||||
s.k.set("cms_signers", list)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *cmsHook) der(msg []byte, junk int) []byte {
|
||||
o := cmstest.Options{Junk: junk}
|
||||
if !s.in.Unsealed {
|
||||
o.Token = func(sig []byte) []byte {
|
||||
return cmstest.Token(sig, s.when, cmstest.TokenOptions{Accuracy: time.Second}, s.c.tsa)
|
||||
}
|
||||
}
|
||||
return cmstest.Signature(msg, o, s.signers()...)
|
||||
}
|
||||
|
||||
func (s *cmsHook) Sign(msg []byte) ([]byte, error) {
|
||||
s.k.set("cms_message", h(msg))
|
||||
if s.in.Error != "" {
|
||||
return nil, errors.New(s.in.Error)
|
||||
}
|
||||
var der []byte
|
||||
switch {
|
||||
case s.in.Garbage:
|
||||
der = []byte("not a signature")
|
||||
case s.in.Junk < 0:
|
||||
// The most junk whose signature still fits in key 2 of the
|
||||
// reader, 64 KiB, while SECURITY_CBOR is more than 64 KiB.
|
||||
base := len(s.der(msg, 1))
|
||||
junk := 65536 - 45 - base
|
||||
for der = s.der(msg, junk); len(der) > 65536-45; der = s.der(msg, junk) {
|
||||
junk--
|
||||
}
|
||||
default:
|
||||
der = s.der(msg, s.in.Junk)
|
||||
}
|
||||
s.k.set("cms_der", h(der))
|
||||
return der, nil
|
||||
}
|
||||
|
||||
type sealHook struct {
|
||||
in sealerIn
|
||||
c *certs
|
||||
when time.Time
|
||||
k *hooks
|
||||
}
|
||||
|
||||
func (s *sealHook) Seal(subject [32]byte) ([]byte, error) {
|
||||
s.k.set("seal_subject", h(subject[:]))
|
||||
if s.in.Error != "" {
|
||||
return nil, errors.New(s.in.Error)
|
||||
}
|
||||
token := cmstest.Token(subject[:], s.when, cmstest.TokenOptions{}, s.c.tsa)
|
||||
s.k.set("seal_token", h(token))
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Writing and opening
|
||||
|
||||
func sourceOf(f fileIn) capsule.Source {
|
||||
return capsule.Source{Path: f.Path, Size: int64(len(f.Text)), Open: func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader([]byte(f.Text))), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// The genesis of Quicknet: the capsules open at round 1000.
|
||||
var genesis = time.Unix(profile.Quicknet().GenesisTime, 0).UTC()
|
||||
|
||||
func run(r recipe, c *certs) (*capsule.Result, []byte, *seeded, obj, error) {
|
||||
q := profile.Quicknet()
|
||||
opts := capsule.EncryptOptions{
|
||||
Profile: q, UnlockAt: must(datekey.RoundTime(q, 1000)), Now: func() time.Time { return genesis },
|
||||
Comment: r.Comment, Author: r.Author, LargeArea: r.LargeArea,
|
||||
TestVectors: r.TestAreaLen != 0, TestAreaLen: r.TestAreaLen,
|
||||
}
|
||||
k := &hooks{rec: obj{}}
|
||||
if r.AuthorKey != nil {
|
||||
opts.AuthorKey = &authorHook{key: must(authorkey.NewFromSeed(unhex(r.AuthorKey.Seed))), bad: r.AuthorKey.Bad, k: k}
|
||||
}
|
||||
if r.CMS != nil {
|
||||
opts.CMSSigner = &cmsHook{in: *r.CMS, c: c, when: genesis, k: k}
|
||||
}
|
||||
if r.Sealer != nil {
|
||||
when := genesis
|
||||
if r.Sealer.Late {
|
||||
when = opts.UnlockAt.Add(time.Hour)
|
||||
}
|
||||
opts.Sealer = &sealHook{in: *r.Sealer, c: c, when: when, k: k}
|
||||
}
|
||||
var sources []capsule.Source
|
||||
for _, f := range r.Files {
|
||||
sources = append(sources, sourceOf(f))
|
||||
}
|
||||
s := newSeeded(r.Seed)
|
||||
old := rand.Reader
|
||||
rand.Reader = s
|
||||
var dst bytes.Buffer
|
||||
res, err := capsule.EncryptFiles(&dst, sources, opts)
|
||||
rand.Reader = old
|
||||
return res, dst.Bytes(), s, k.rec, err
|
||||
}
|
||||
|
||||
func releases() provider.ReleaseSource {
|
||||
return testkit.NewSource(testkit.Release(1000))
|
||||
}
|
||||
|
||||
// opened is what capsule.Open gives for dkc, with the author keys saved.
|
||||
func opened(dkc []byte, keys map[string]string) obj {
|
||||
files := &testkit.MemorySink{}
|
||||
o := capsule.OpenOptions{
|
||||
Registry: testkit.Registry(), Source: releases(), Sink: files, AuthorKeys: keys,
|
||||
Now: func() time.Time { return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
res, err := capsule.Open(context.Background(), nil, bytes.NewReader(dkc), o)
|
||||
v := obj{}
|
||||
if err != nil {
|
||||
v["result"] = datekeys.Code(err)
|
||||
if v["result"] == "" {
|
||||
v["result"] = "error: " + err.Error()
|
||||
}
|
||||
return v
|
||||
}
|
||||
v["result"] = "ok"
|
||||
fs := []obj{}
|
||||
for i, f := range res.Head.Files {
|
||||
fs = append(fs, obj{"path": f.Path, "size": f.Size, "sha256": sum(files.Files[i])})
|
||||
}
|
||||
v["files"] = fs
|
||||
v["head"] = h(must(capsule.EncodeHead(res.Head)))
|
||||
v["verdicts"] = []string{string(res.Verdicts.Signature), string(res.Verdicts.Seal)}
|
||||
if res.Verdicts.AuthorKey != ([32]byte{}) {
|
||||
v["author_key"] = h(res.Verdicts.AuthorKey[:])
|
||||
}
|
||||
v["lines"] = res.Verdicts.Lines()
|
||||
v["area_len"] = res.AreaLen
|
||||
v["length"] = res.PayloadLength
|
||||
return v
|
||||
}
|
||||
|
||||
func caseOf(r recipe, c *certs) obj {
|
||||
res, dkc, s, rec, err := run(r, c)
|
||||
draws := []obj{}
|
||||
for _, d := range s.draws {
|
||||
draws = append(draws, obj{"n": len(d), "hex": h(d)})
|
||||
}
|
||||
out := obj{"recipe": r, "draws": draws}
|
||||
if len(rec) > 0 {
|
||||
out["hooks"] = rec
|
||||
}
|
||||
if err != nil {
|
||||
out["error"] = err.Error()
|
||||
if len(dkc) != 0 {
|
||||
panic(r.Name + ": an error after writing")
|
||||
}
|
||||
return out
|
||||
}
|
||||
out["written"] = obj{"length": len(dkc), "sha256": sum(dkc), "capsule_id": h(res.CapsuleID[:]), "body_length": res.Length}
|
||||
out["opened"] = opened(dkc, nil)
|
||||
if r.AuthorKey != nil {
|
||||
pub := must(authorkey.PublicString(must(authorkey.NewFromSeed(unhex(r.AuthorKey.Seed))).Public()))
|
||||
out["opened_saved"] = opened(dkc, map[string]string{pub: "mi clave de 2026"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type sampleIn struct {
|
||||
Name string `json:"name"`
|
||||
DKC string `json:"dkc"`
|
||||
AuthorKeys map[string]string `json:"author_keys,omitempty"`
|
||||
TS obj `json:"ts"`
|
||||
}
|
||||
|
||||
func TestSigningVectors(t *testing.T) {
|
||||
if *sourceFlag == "" || *outFlag == "" {
|
||||
t.Skip("run with -args -source COMMIT -out FILE [-samples FILE]")
|
||||
}
|
||||
cryptotest.SetGlobalRandom(t, 20261006)
|
||||
c := newCerts()
|
||||
cases := []obj{}
|
||||
for _, r := range recipes() {
|
||||
v := caseOf(r, c)
|
||||
cases = append(cases, v)
|
||||
if e, ok := v["error"]; ok {
|
||||
t.Logf("%s: %s", r.Name, e)
|
||||
} else {
|
||||
t.Logf("%s: %d bytes, %v", r.Name, v["written"].(obj)["length"], v["opened"].(obj)["verdicts"])
|
||||
}
|
||||
}
|
||||
samples := []obj{}
|
||||
if *samplesFlag != "" {
|
||||
var in struct {
|
||||
Samples []sampleIn `json:"samples"`
|
||||
}
|
||||
must(0, json.Unmarshal(must(os.ReadFile(*samplesFlag)), &in))
|
||||
for _, s := range in.Samples {
|
||||
dkc := unhex(s.DKC)
|
||||
v := obj{"name": s.Name, "dkc": s.DKC, "ts": s.TS, "opened": opened(dkc, nil)}
|
||||
if s.AuthorKeys != nil {
|
||||
v["author_keys"] = s.AuthorKeys
|
||||
v["opened_saved"] = opened(dkc, s.AuthorKeys)
|
||||
}
|
||||
samples = append(samples, v)
|
||||
t.Logf("sample %s: %v", s.Name, v["opened"].(obj)["verdicts"])
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
must(0, enc.Encode(obj{
|
||||
"description": "What capsule.EncryptFiles of datekeys-go writes when it signs and seals, for each recipe, while crypto/rand reads the keystream of ChaCha20 under SHA-256(seed) with a zero nonce, with each draw and what each hook was given and returned, and how capsule.Open reads it; the text of each error; and how capsule.Open reads the samples that encryptFiles of datekeys-ts wrote (scripts/signing-go-vectors_test.go). The capsules are time_only for round 1000 of Quicknet, written at its genesis.",
|
||||
"source": *sourceFlag,
|
||||
"go": runtime.Version(),
|
||||
"release": h(testkit.Release(1000).Signature),
|
||||
"cases": cases,
|
||||
"samples": samples,
|
||||
}))
|
||||
must(0, os.WriteFile(*outFlag, buf.Bytes(), 0o644))
|
||||
t.Logf("wrote %s, %d bytes", *outFlag, buf.Len())
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// Writes, as JSON, the capsules that scripts/signing-go-vectors_test.go opens
|
||||
// with the Go reference: capsules of format 3 that encryptFiles of this
|
||||
// library writes as any caller writes them, with the random values of
|
||||
// crypto.getRandomValues, signed with alg 1 by a fresh AuthorKey of
|
||||
// authorkey.ts, sealed with seal_type 2, and signed with alg 2, with the
|
||||
// certificates, signatures and tokens that src/lib/dkc/testing/cmsbuild.ts
|
||||
// makes, its own and not those of Go; and, for each, what this library reads
|
||||
// in it. time_only for round 1000 of Quicknet, written at its genesis.
|
||||
//
|
||||
// The capsules are random, so the output is generated once and frozen with
|
||||
// what Go reads in src/lib/dkc/testing/signing-vectors.json. Node runs the
|
||||
// TypeScript sources directly (type stripping, Node 22.6+):
|
||||
//
|
||||
// node scripts/signing-ts-samples.mjs > ts-signing.json
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { AuthorKey } from '../src/lib/dkc/authorkey.ts';
|
||||
import { fromHex, toHex } from '../src/lib/dkc/bytes.ts';
|
||||
import { parseRFC3339 } from '../src/lib/dkc/datekey.ts';
|
||||
import { encryptFiles } from '../src/lib/dkc/encrypt.ts';
|
||||
import { TIME_ONLY } from '../src/lib/dkc/header.ts';
|
||||
import { open } from '../src/lib/dkc/open.ts';
|
||||
import { quicknet } from '../src/lib/dkc/profile.ts';
|
||||
import { suppliedRelease } from '../src/lib/dkc/release.ts';
|
||||
import { verdictLines } from '../src/lib/dkc/security.ts';
|
||||
import { MemorySink } from '../src/lib/dkc/sink.ts';
|
||||
import * as b from '../src/lib/dkc/testing/cmsbuild.ts';
|
||||
|
||||
const GENESIS = parseRFC3339('2023-08-23T15:09:27Z');
|
||||
const at = (r) => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
|
||||
const signedAt = new Date(GENESIS.seconds * 1000);
|
||||
// The release of round 1000 of Quicknet, as drand published it, from a fixture.
|
||||
const fixture = JSON.parse(readFileSync(new URL('../testdata/fixtures/format3_single.json', import.meta.url), 'utf8'));
|
||||
const RELEASE = { round: fixture.release.round, signature: fromHex(fixture.release.signature) };
|
||||
const te = new TextEncoder();
|
||||
const file = (path, text) => {
|
||||
const bytes = te.encode(text);
|
||||
return { path, size: bytes.length, open: () => new Blob([bytes]).stream() };
|
||||
};
|
||||
const files = [file('carta.txt', 'Querida Ana:\n'), file('docs/año 2026/acta.txt', 'Acta de la reunión.\n')];
|
||||
const options = (extra) => ({ profile: quicknet(), unlockAt: at(1000), policy: TIME_ONLY, now: () => GENESIS, ...extra });
|
||||
|
||||
const from = new Date('2020-01-01T00:00:00Z');
|
||||
const to = new Date('2040-01-01T00:00:00Z');
|
||||
const tsa = await b.newECDSA('Autoridad de sellado de prueba', 'P-256', from, to);
|
||||
const ana = await b.newECDSA('Ana Pérez', 'P-384', from, to);
|
||||
const luis = await b.newRSA('Luis Martín', 2048, from, to);
|
||||
const sealer = { seal: (subject) => b.token(subject, signedAt, {}, tsa) };
|
||||
const certHash = async (s) => new Uint8Array(await crypto.subtle.digest('SHA-256', s.cert));
|
||||
const cms = (signers, extra = {}) => ({
|
||||
signers: () => signers.hashes,
|
||||
sign: (msg) => b.signature(msg, { token: (sig) => b.token(sig, signedAt, { accuracy: b.accuracyOf(1) }, tsa), ...extra }, ...signers.list),
|
||||
});
|
||||
const signersOf = async (...list) => ({ list, hashes: await Promise.all(list.map(certHash)) });
|
||||
|
||||
const key = AuthorKey.generate();
|
||||
const saved = { [key.publicString()]: 'la clave de prueba' };
|
||||
const samples = [];
|
||||
const add = async (name, extra, authorKeys) => {
|
||||
const w = await encryptFiles(files, options(extra));
|
||||
const sink = new MemorySink();
|
||||
const o = await open(w.dkc, {
|
||||
source: suppliedRelease(RELEASE),
|
||||
now: () => at(1000),
|
||||
sink,
|
||||
...(authorKeys === undefined ? {} : { authorKeys: new Map(Object.entries(authorKeys)) }),
|
||||
});
|
||||
if (o.error !== undefined) throw o.error;
|
||||
samples.push({
|
||||
name,
|
||||
dkc: toHex(w.dkc),
|
||||
...(authorKeys === undefined ? {} : { author_keys: authorKeys }),
|
||||
ts: { verdicts: [o.verdicts.signature, o.verdicts.seal], lines: verdictLines(o.verdicts), area_len: o.areaLen },
|
||||
});
|
||||
};
|
||||
await add('alg 1', { authorKey: key, comment: 'Firmado.' }, saved);
|
||||
await add('alg 1 and a seal', { authorKey: key, sealer }, saved);
|
||||
await add('a seal alone', { sealer });
|
||||
await add('alg 2, two signers, sealed', { cmsSigner: cms(await signersOf(ana, luis)) });
|
||||
await add('alg 2, a large area', { cmsSigner: cms(await signersOf(ana), { junk: 40000 }), largeArea: true });
|
||||
process.stdout.write(`${JSON.stringify({ samples }, null, 1)}\n`);
|
||||
@ -0,0 +1,519 @@
|
||||
// Tests of the hooks of encryptFiles (writer.ts): the signature of alg 1 with
|
||||
// an author key, the signature of alg 2 with certificates, the seal of
|
||||
// seal_type 2 and the large area (spec §29.2, §29.3, §29.8 to §29.11, §62.1
|
||||
// rules 13, 17, 19 and 21), against src/lib/dkc/testing/signing-vectors.json,
|
||||
// written by scripts/signing-go-vectors_test.go:
|
||||
//
|
||||
// - cases: with the draws of crypto/rand of capsule.EncryptFiles of Go, in
|
||||
// their order, and its hooks' signatures and tokens, encryptFiles writes
|
||||
// the capsule of Go byte for byte, asks the hooks over the same messages,
|
||||
// and reads in it the verdicts and lines of capsule.Open; and it fails with
|
||||
// the text of Go where Go fails;
|
||||
// - samples: what encryptFiles wrote with its own random values and its own
|
||||
// certificates (scripts/signing-ts-samples.mjs), which capsule.Open of Go
|
||||
// reads with the verdicts and lines that open gives here.
|
||||
//
|
||||
// And the hooks as this library defines them: asynchronous, checked as types,
|
||||
// called before anything is written, and their failures.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthorKey } from './authorkey.ts';
|
||||
import { ALG_ED25519, authorMessage, controlCommit, headDigest, sealSubject, signersDigest } from './author.ts';
|
||||
import { AREA_LEN, AREA_UNIT, LARGE_AREA_LEN, parseBodyFrame } from './body.ts';
|
||||
import type { Control } from './control.ts';
|
||||
import { type Instant, parseRFC3339, resolveDateKey } from './datekey.ts';
|
||||
import { encrypt, encryptFiles, type EncryptOptions, type FileSource, MAX_MEMORY_DKC } from './encrypt.ts';
|
||||
import { bodyLength, capsuleLength } from './lengths.ts';
|
||||
import { FORMAT_3, headerBinding } from './framing.ts';
|
||||
import { TIME_ONLY } from './header.ts';
|
||||
import { open, type OpenOptions, payloadIdentity } from './open.ts';
|
||||
import { BLOQUE256, REFORZADO } from './padding.ts';
|
||||
import { quicknet } from './profile.ts';
|
||||
import { suppliedRelease } from './release.ts';
|
||||
import { evaluateSecurity, verdictLines } from './security.ts';
|
||||
import { encodeSigners } from './securitycms.ts';
|
||||
import { MemorySink } from './sink.ts';
|
||||
import { split } from './testing/capsule.ts';
|
||||
import * as b from './testing/cmsbuild.ts';
|
||||
import { encryptFilesWith, encryptVectors } from './testing/encrypt.ts';
|
||||
import { h, hx } from './testing/testdata.ts';
|
||||
import { decrypt } from './agefile.ts';
|
||||
import type { AuthorSigner, CmsSigner, Sealer } from './writer.ts';
|
||||
|
||||
interface Recipe {
|
||||
name: string;
|
||||
seed: string;
|
||||
files?: { path: string; text: string }[];
|
||||
comment?: string;
|
||||
author?: string;
|
||||
author_key?: { seed: string; bad?: string };
|
||||
cms?: { signers: string[] | null; extra?: string; unsealed?: boolean; junk?: number; error?: string; garbage?: boolean };
|
||||
sealer?: { error?: string };
|
||||
large_area?: boolean;
|
||||
test_area_len?: number;
|
||||
}
|
||||
interface Opened {
|
||||
result: string;
|
||||
files?: { path: string; size: number; sha256: string }[];
|
||||
head?: string;
|
||||
verdicts?: [string, string];
|
||||
author_key?: string;
|
||||
lines?: string[];
|
||||
area_len?: number;
|
||||
length?: number;
|
||||
}
|
||||
interface Case {
|
||||
recipe: Recipe;
|
||||
draws: { n: number; hex: string }[];
|
||||
hooks?: Record<string, string | string[]>;
|
||||
error?: string;
|
||||
written?: { length: number; sha256: string; capsule_id: string; body_length: number };
|
||||
opened?: Opened;
|
||||
opened_saved?: Opened;
|
||||
}
|
||||
interface Sample {
|
||||
name: string;
|
||||
dkc: string;
|
||||
author_keys?: Record<string, string>;
|
||||
ts: { verdicts: [string, string]; lines: string[]; area_len: number };
|
||||
opened: Opened;
|
||||
opened_saved?: Opened;
|
||||
}
|
||||
|
||||
const V = JSON.parse(readFileSync(new URL('./testing/signing-vectors.json', import.meta.url), 'utf8')) as {
|
||||
release: string;
|
||||
cases: Case[];
|
||||
samples: Sample[];
|
||||
};
|
||||
|
||||
const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z');
|
||||
const at = (r: number): Instant => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
|
||||
const RELEASE = { round: 1000, signature: h(V.release) };
|
||||
const te = new TextEncoder();
|
||||
const opening = (extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(RELEASE), now: () => at(1000), ...extra });
|
||||
const options = (extra: Partial<EncryptOptions> = {}): EncryptOptions => ({ profile: quicknet(), unlockAt: at(1000), policy: TIME_ONLY, now: () => GENESIS, ...extra });
|
||||
const source = (path: string, text: string | Uint8Array): FileSource => {
|
||||
const bytes = typeof text === 'string' ? te.encode(text) : text;
|
||||
return { path, size: bytes.length, open: () => new Blob([bytes as Uint8Array<ArrayBuffer>]).stream() };
|
||||
};
|
||||
const SAVED_LABEL = 'mi clave de 2026';
|
||||
|
||||
async function sha256Hex(b: Uint8Array): Promise<string> {
|
||||
return hx(new Uint8Array(await crypto.subtle.digest('SHA-256', b as Uint8Array<ArrayBuffer>)));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Hands each call of crypto.getRandomValues the next of `draws`, which must
|
||||
// have its length; but for the blinding of the scalar multiplications of
|
||||
// @noble/curves, which changes no result and which Go does not draw.
|
||||
function replay(draws: readonly Uint8Array[]): () => number {
|
||||
const queue = [...draws];
|
||||
const own = crypto.getRandomValues.bind(crypto);
|
||||
vi.spyOn(crypto, 'getRandomValues').mockImplementation(<T extends ArrayBufferView | null>(a: T): T => {
|
||||
if (new Error().stack!.includes('mulCTBlinded')) return own(a!) as T;
|
||||
const d = queue.shift();
|
||||
const view = new Uint8Array(a!.buffer, a!.byteOffset, a!.byteLength);
|
||||
if (d === undefined || d.length !== view.length) throw new Error(`replay: a draw of ${view.length} bytes, not the next of Go (${d?.length})`);
|
||||
view.set(d);
|
||||
return a;
|
||||
});
|
||||
return () => queue.length;
|
||||
}
|
||||
|
||||
// What a capsule opens to, in the shape of the vectors.
|
||||
async function openedOf(dkc: Uint8Array, keys?: Record<string, string>): Promise<Opened> {
|
||||
const sink = new MemorySink();
|
||||
const o = await open(dkc, opening({ sink, ...(keys === undefined ? {} : { authorKeys: new Map(Object.entries(keys)) }) }));
|
||||
if (o.error !== undefined) return { result: o.error.code };
|
||||
const head = sink.opened!.head;
|
||||
const files = await Promise.all(head.files.map(async (f, i) => ({ path: f.path, size: f.size, sha256: await sha256Hex(sink.opened!.files[i]!) })));
|
||||
return {
|
||||
result: 'ok',
|
||||
files,
|
||||
verdicts: [o.verdicts!.signature, o.verdicts!.seal],
|
||||
...(o.verdicts!.authorKey === undefined ? {} : { author_key: hx(o.verdicts!.authorKey) }),
|
||||
lines: verdictLines(o.verdicts!),
|
||||
area_len: o.areaLen!,
|
||||
};
|
||||
}
|
||||
|
||||
const pick = (o: Opened): Opened => {
|
||||
const { head: _head, length: _length, ...rest } = o;
|
||||
return rest;
|
||||
};
|
||||
|
||||
describe('encryptFiles with the hooks of capsule.EncryptFiles of Go (signing-vectors.json, cases)', () => {
|
||||
it('holds the recipes that it should', () => {
|
||||
const names = V.cases.map((c) => c.recipe.name);
|
||||
expect(names.length).toBe(23);
|
||||
expect(V.cases.filter((c) => c.error === undefined).length).toBe(8);
|
||||
});
|
||||
|
||||
for (const c of V.cases) {
|
||||
const r = c.recipe;
|
||||
it(`${r.name}: ${c.error === undefined ? 'the capsule of Go, byte for byte, and its verdicts' : 'the error of Go'}`, async () => {
|
||||
const draws = c.draws.map((d) => h(d.hex));
|
||||
const asked: Record<string, string | string[]> = {};
|
||||
const hooks = c.hooks ?? {};
|
||||
const opts: Partial<EncryptOptions> = {
|
||||
...(r.comment === undefined ? {} : { comment: r.comment }),
|
||||
...(r.author === undefined ? {} : { author: r.author }),
|
||||
...(r.large_area === undefined ? {} : { largeArea: r.large_area }),
|
||||
};
|
||||
if (r.author_key !== undefined) {
|
||||
// The key of the seed, or what the hook of Go returned.
|
||||
const key = AuthorKey.fromSeed(h(r.author_key.seed));
|
||||
const signer: AuthorSigner = {
|
||||
publicKey: () => {
|
||||
asked.author_public = hx(key.publicKey());
|
||||
return h(hooks.author_public as string);
|
||||
},
|
||||
sign: async (msg) => {
|
||||
asked.author_message = hx(msg);
|
||||
const sig = r.author_key!.bad === 'zero_signature' ? new Uint8Array(64) : key.sign(msg);
|
||||
asked.author_signature = hx(sig);
|
||||
return sig;
|
||||
},
|
||||
};
|
||||
(opts as { authorKey: AuthorSigner }).authorKey = signer;
|
||||
}
|
||||
if (r.cms !== undefined) {
|
||||
const cms: CmsSigner = {
|
||||
signers: () => ((hooks.cms_signers as string[] | undefined) ?? []).map(h),
|
||||
sign: (msg) => {
|
||||
asked.cms_message = hx(msg);
|
||||
if (r.cms!.error !== undefined) throw new Error(r.cms!.error);
|
||||
return h(hooks.cms_der as string);
|
||||
},
|
||||
};
|
||||
(opts as { cmsSigner: CmsSigner }).cmsSigner = cms;
|
||||
}
|
||||
if (r.sealer !== undefined) {
|
||||
const sealer: Sealer = {
|
||||
seal: async (subject) => {
|
||||
asked.seal_subject = hx(subject);
|
||||
if (r.sealer!.error !== undefined) throw new Error(r.sealer!.error);
|
||||
return h(hooks.seal_token as string);
|
||||
},
|
||||
};
|
||||
(opts as { sealer: Sealer }).sealer = sealer;
|
||||
}
|
||||
const files = (r.files ?? []).map((f) => source(f.path, f.text));
|
||||
// The salt, capsule_id and I_PAYLOAD are draws of the writer; then Go
|
||||
// seals a measured control (four draws, which this writer does not
|
||||
// need: it measures with a formula), seals the control (the file key,
|
||||
// sigma, a random label that age-encryption does not draw, the nonce)
|
||||
// and writes PAYLOAD_AGE (the file key, the ephemeral share, the
|
||||
// nonce).
|
||||
const fixed = draws.length >= 3 ? { salt: draws[0]!, capsuleId: draws[1]!, payloadIdentity: draws[2]! } : {};
|
||||
let left = (): number => 0;
|
||||
if (draws.length === 14) {
|
||||
expect(c.draws.map((d) => d.n)).toEqual([32, 16, 32, 16, 16, 16, 16, 16, 16, 16, 16, 16, 32, 16]);
|
||||
left = replay([7, 8, 10, 11, 12, 13].map((i) => draws[i]!));
|
||||
}
|
||||
const write = encryptFilesWith(files, options(opts), fixed, r.test_area_len === undefined ? undefined : { areaLen: r.test_area_len });
|
||||
if (c.error !== undefined) {
|
||||
await expect(write).rejects.toThrow(c.error);
|
||||
await write.catch((err: Error) => expect(err.message).toBe(c.error));
|
||||
} else {
|
||||
const w = await write;
|
||||
expect(left()).toBe(0);
|
||||
vi.restoreAllMocks();
|
||||
expect(w.dkc!.length).toBe(c.written!.length);
|
||||
expect(await sha256Hex(w.dkc!)).toBe(c.written!.sha256);
|
||||
expect(hx(w.capsuleId)).toBe(c.written!.capsule_id);
|
||||
expect(w.length).toBe(c.written!.body_length);
|
||||
expect(await openedOf(w.dkc!)).toEqual(pick(c.opened!));
|
||||
if (c.opened_saved !== undefined) {
|
||||
const key = AuthorKey.fromSeed(h(r.author_key!.seed));
|
||||
expect(await openedOf(w.dkc!, { [key.publicString()]: SAVED_LABEL })).toEqual(pick(c.opened_saved));
|
||||
}
|
||||
}
|
||||
// Each hook was asked over the message of Go, and answered as Go's.
|
||||
for (const k of ['author_message', 'author_signature', 'cms_message', 'seal_subject']) {
|
||||
if (k in hooks || k in asked) expect(asked[k], k).toBe(hooks[k]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('capsules of encryptFiles, opened by Go (signing-vectors.json, samples)', () => {
|
||||
it('holds the five samples', () => {
|
||||
expect(V.samples.map((s) => s.name)).toEqual(['alg 1', 'alg 1 and a seal', 'a seal alone', 'alg 2, two signers, sealed', 'alg 2, a large area']);
|
||||
});
|
||||
|
||||
for (const s of V.samples) {
|
||||
it(`${s.name}: Go reads the verdicts and lines that this library reads`, async () => {
|
||||
const dkc = h(s.dkc);
|
||||
const here = await openedOf(dkc);
|
||||
expect(here).toEqual(pick(s.opened));
|
||||
expect([s.opened.verdicts, s.opened.area_len]).toEqual([s.ts.verdicts.map((v) => (v === 'F3' ? 'F4' : v)), s.ts.area_len]);
|
||||
if (s.author_keys !== undefined) {
|
||||
expect(await openedOf(dkc, s.author_keys)).toEqual(pick(s.opened_saved!));
|
||||
expect(s.opened_saved!.lines).toEqual(s.ts.lines);
|
||||
} else {
|
||||
expect(s.opened.lines).toEqual(s.ts.lines);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The BODY of a capsule written with I_PAYLOAD fixed: its area, its
|
||||
// SECURITY_CBOR and its head, read back from PAYLOAD_AGE.
|
||||
async function bodyOf(dkc: Uint8Array, payloadId: Uint8Array): Promise<{ security: Uint8Array; head: Uint8Array; areaLen: number; length: number }> {
|
||||
const plain = new Uint8Array(await new Response(await decrypt(new Blob([split(dkc).payload as Uint8Array<ArrayBuffer>]).stream(), payloadIdentity(payloadId), 'PAYLOAD_AGE')).arrayBuffer());
|
||||
const o = await open(dkc, opening({ sink: new MemorySink() }));
|
||||
const length = o.payloadLength!;
|
||||
const frame = parseBodyFrame(plain.subarray(0, 12), length);
|
||||
return {
|
||||
security: plain.slice(12, 12 + frame.securityLen),
|
||||
head: plain.slice(12 + frame.areaLen, 12 + frame.areaLen + frame.headLen),
|
||||
areaLen: frame.areaLen,
|
||||
length,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the hooks', () => {
|
||||
const from = new Date('2020-01-01T00:00:00Z');
|
||||
const to = new Date('2040-01-01T00:00:00Z');
|
||||
const signedAt = new Date(GENESIS.seconds * 1000);
|
||||
const files = [source('a.txt', 'uno'), source('b/c.txt', 'dos')];
|
||||
const certHash = async (s: b.Signer): Promise<Uint8Array> => new Uint8Array(await crypto.subtle.digest('SHA-256', s.cert as Uint8Array<ArrayBuffer>));
|
||||
|
||||
it('sign and seal before anything is written, and the output gets the capsule only once it is complete', async () => {
|
||||
const events: string[] = [];
|
||||
const key = AuthorKey.generate();
|
||||
const output = new WritableStream<Uint8Array>({
|
||||
write: () => void events.push('write'),
|
||||
close: () => void events.push('close'),
|
||||
});
|
||||
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
|
||||
const w = await encryptFiles(files, {
|
||||
...options(),
|
||||
output,
|
||||
authorKey: {
|
||||
publicKey: () => key.publicKey(),
|
||||
sign: async (m) => {
|
||||
events.push('sign');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
return key.sign(m);
|
||||
},
|
||||
},
|
||||
sealer: {
|
||||
seal: async (s) => {
|
||||
events.push('seal');
|
||||
return b.token(s, signedAt, {}, tsa);
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(events.slice(0, 3)).toEqual(['sign', 'seal', 'write']);
|
||||
expect(events.at(-1)).toBe('close');
|
||||
expect(w.dkc).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sign AUTHOR_MESSAGE of the control and of the head that the reader holds, and seal SEAL_SUBJECT over the signature', async () => {
|
||||
const key = AuthorKey.generate();
|
||||
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
|
||||
let message: Uint8Array = new Uint8Array(0);
|
||||
let subject: Uint8Array = new Uint8Array(0);
|
||||
const payloadId = crypto.getRandomValues(new Uint8Array(32));
|
||||
const w = await encryptFilesWith(
|
||||
files,
|
||||
options({
|
||||
authorKey: { publicKey: () => key.publicKey(), sign: (m) => ((message = m), key.sign(m)) },
|
||||
sealer: { seal: (s) => ((subject = s), b.token(s, signedAt, {}, tsa)) },
|
||||
}),
|
||||
{ payloadIdentity: payloadId },
|
||||
);
|
||||
const o = await openedOf(w.dkc!);
|
||||
expect([o.verdicts, o.author_key, o.area_len]).toEqual([['F4', 'S4'], hx(key.publicKey()), AREA_LEN]);
|
||||
expect((await openedOf(w.dkc!, { [key.publicString()]: 'mía' })).lines![0]).toBe('Firmado con la clave que guardaste como mía.');
|
||||
// The control from the capsule: its binding, I_PAYLOAD and L, whatever L is.
|
||||
const p = split(w.dkc!);
|
||||
const body = await bodyOf(w.dkc!, payloadId);
|
||||
const control: Control = { headerBinding: await headerBinding(p.prelude, p.header), payloadIdentity: payloadId, critical: [], noncritical: [], payloadLength: body.length, padding: REFORZADO };
|
||||
const cc = controlCommit(control, FORMAT_3);
|
||||
expect(hx(message)).toBe(hx(authorMessage(cc, headDigest(body.head), signersDigest(ALG_ED25519))));
|
||||
// The seal seals the signature: key 2 of the area as it is.
|
||||
const signature = evaluateSecurity(body.security, { controlCommit: cc, headDigest: headDigest(body.head) });
|
||||
expect(signature.signature).toBe('F4');
|
||||
expect(hx(subject).length).toBe(64);
|
||||
expect(hx(subject)).not.toBe(hx(sealSubject(cc, headDigest(body.head), undefined)));
|
||||
});
|
||||
|
||||
it('take a CMS signature with certificates, F6, and widen the area only when it is asked and needed', async () => {
|
||||
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
|
||||
const ana = await b.newECDSA('Ana', 'P-256', from, to);
|
||||
const hash = await certHash(ana);
|
||||
const signer = (junk: number): CmsSigner => ({
|
||||
signers: () => [hash],
|
||||
sign: (m) => b.signature(m, { junk, token: (sig) => b.token(sig, signedAt, { accuracy: b.accuracyOf(1) }, tsa) }, ana),
|
||||
});
|
||||
const small = await encryptFiles(files, options({ cmsSigner: signer(0), largeArea: true }));
|
||||
expect((await openedOf(small.dkc!)).area_len).toBe(AREA_LEN);
|
||||
const large = await encryptFiles(files, options({ cmsSigner: signer(40000), largeArea: true }));
|
||||
const o = await openedOf(large.dkc!);
|
||||
expect([o.verdicts, o.area_len]).toEqual([['F6', 'S0'], LARGE_AREA_LEN]);
|
||||
expect(large.length).toBe(small.length + LARGE_AREA_LEN - AREA_LEN);
|
||||
await expect(encryptFiles(files, options({ cmsSigner: signer(40000) }))).rejects.toThrow(/does not fit in the area of 32768 bytes: LargeArea lets the writer widen it to 65536$/);
|
||||
});
|
||||
|
||||
it('write SIGNERS in ascending order of bytes, whatever the order that the signer gives', async () => {
|
||||
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
|
||||
const ana = await b.newECDSA('Ana', 'P-256', from, to);
|
||||
const luis = await b.newECDSA('Luis', 'P-256', from, to);
|
||||
const hashes = [await certHash(ana), await certHash(luis)].sort((x, y) => (hx(x) < hx(y) ? 1 : -1));
|
||||
const o = { token: (sig: Uint8Array) => b.token(sig, signedAt, { accuracy: b.accuracyOf(1) }, tsa) };
|
||||
const w = await encryptFiles(files, options({ cmsSigner: { signers: () => hashes, sign: (m) => b.signature(m, o, ana, luis) } }));
|
||||
expect((await openedOf(w.dkc!)).verdicts).toEqual(['F6', 'S0']);
|
||||
expect(hx(encodeSigners(hashes))).toBe(hx(encodeSigners([...hashes].reverse())));
|
||||
expect(() => encodeSigners([])).toThrow('capsule: SIGNERS holds from 1 to 16 certificates');
|
||||
expect(() => encodeSigners(Array.from({ length: 17 }, (_, i) => new Uint8Array(32).fill(i)))).toThrow('capsule: SIGNERS holds from 1 to 16 certificates');
|
||||
expect(() => encodeSigners([hashes[0]!, hashes[1]!, hashes[0]!])).toThrow('capsule: SIGNERS names a certificate twice');
|
||||
});
|
||||
|
||||
// A first reading of 1 GiB: a few seconds, but minutes under the coverage of
|
||||
// v8, so it runs with DATEKEYS_LARGE=1 (it passed on 2026-10-06).
|
||||
it.skipIf(process.env.DATEKEYS_LARGE !== '1')('count the large area in the limit of a capsule in memory', async () => {
|
||||
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
|
||||
const ana = await b.newECDSA('Ana', 'P-256', from, to);
|
||||
const hash = await certHash(ana);
|
||||
// A file that fits in memory with the area of 32 KiB, and not with that of 64 KiB.
|
||||
const profileId = resolveDateKey(quicknet(), at(1000)).profileId;
|
||||
const total = (length: number): number => capsuleLength({ profileId, round: 1000, policy: TIME_ONLY, length, padding: BLOQUE256 });
|
||||
const body = (size: number): number => bodyLength({ files: [{ path: 'grande', size }] });
|
||||
let lo = 0;
|
||||
let hi = 1 << 30;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (total(body(mid) + LARGE_AREA_LEN - AREA_LEN) > MAX_MEMORY_DKC) hi = mid;
|
||||
else lo = mid + 1;
|
||||
}
|
||||
const size = lo;
|
||||
expect(total(body(size))).toBeLessThanOrEqual(MAX_MEMORY_DKC);
|
||||
let readings = 0;
|
||||
const huge: FileSource = {
|
||||
path: 'grande',
|
||||
size,
|
||||
open: () => {
|
||||
readings++;
|
||||
let left = size;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(c) {
|
||||
const n = Math.min(left, 4 << 20);
|
||||
if (n === 0) c.close();
|
||||
else c.enqueue(new Uint8Array(n));
|
||||
left -= n;
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const cms: CmsSigner = { signers: () => [hash], sign: (m) => b.signature(m, { junk: 40000, token: (sig) => b.token(sig, signedAt, {}, tsa) }, ana) };
|
||||
const err = (await encryptFiles([huge], options({ cmsSigner: cms, largeArea: true, padding: BLOQUE256 })).catch((e: Error) => e)) as Error;
|
||||
expect(err).toBeInstanceOf(TypeError);
|
||||
expect(err.message).toBe(`encrypt: a capsule of ${total(body(size) + LARGE_AREA_LEN - AREA_LEN)} bytes needs EncryptOptions.output; in memory the limit is ${MAX_MEMORY_DKC}`);
|
||||
// Only the first reading: nothing was written.
|
||||
expect(readings).toBe(1);
|
||||
}, 120_000);
|
||||
|
||||
it('may be synchronous or asynchronous', async () => {
|
||||
const key = AuthorKey.generate();
|
||||
const sync = await encryptFiles(files, options({ authorKey: { publicKey: () => key.publicKey(), sign: (m) => key.sign(m) } }));
|
||||
const later = await encryptFiles(files, options({ authorKey: { publicKey: () => key.publicKey(), sign: async (m) => key.sign(m) } }));
|
||||
expect((await openedOf(sync.dkc!)).verdicts).toEqual(['F4', 'S0']);
|
||||
expect((await openedOf(later.dkc!)).verdicts).toEqual(['F4', 'S0']);
|
||||
});
|
||||
|
||||
it('copy what they return: a hook that changes its bytes afterwards changes nothing', async () => {
|
||||
const key = AuthorKey.generate();
|
||||
const w = await encryptFiles(
|
||||
files,
|
||||
options({
|
||||
authorKey: {
|
||||
publicKey: () => key.publicKey(),
|
||||
sign: (m) => {
|
||||
const sig = key.sign(m);
|
||||
setTimeout(() => sig.fill(0), 0);
|
||||
return sig;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect((await openedOf(w.dkc!)).verdicts).toEqual(['F4', 'S0']);
|
||||
});
|
||||
|
||||
it('are checked as types before anything else, and an output is aborted', async () => {
|
||||
const key = AuthorKey.generate();
|
||||
const cases: [Partial<EncryptOptions>, string][] = [
|
||||
[{ authorKey: null as unknown as AuthorSigner }, 'encrypt: EncryptOptions.authorKey has no publicKey and sign methods: leave it undefined for none'],
|
||||
[{ authorKey: { publicKey: () => key.publicKey() } as unknown as AuthorSigner }, 'encrypt: EncryptOptions.authorKey has no publicKey and sign methods: leave it undefined for none'],
|
||||
[{ cmsSigner: {} as CmsSigner }, 'encrypt: EncryptOptions.cmsSigner has no signers and sign methods: leave it undefined for none'],
|
||||
[{ sealer: 'tsa' as unknown as Sealer }, 'encrypt: EncryptOptions.sealer has no seal methods: leave it undefined for none'],
|
||||
[{ largeArea: 1 as unknown as boolean }, 'encrypt: EncryptOptions.largeArea is not a boolean'],
|
||||
];
|
||||
for (const [extra, msg] of cases) {
|
||||
const aborted: unknown[] = [];
|
||||
const output = new WritableStream<Uint8Array>({ abort: (r) => void aborted.push(r) });
|
||||
const err = (await encryptFiles(files, options({ ...extra, output })).catch((e: Error) => e)) as Error;
|
||||
expect([err.constructor, err.message]).toEqual([TypeError, msg]);
|
||||
expect(aborted).toEqual([err]);
|
||||
}
|
||||
});
|
||||
|
||||
it('must return bytes', async () => {
|
||||
const key = AuthorKey.generate();
|
||||
const cases: [Partial<EncryptOptions>, string][] = [
|
||||
[{ authorKey: { publicKey: () => 'key' as unknown as Uint8Array, sign: (m) => key.sign(m) } }, 'encrypt: AuthorSigner.publicKey() did not return a Uint8Array'],
|
||||
[{ authorKey: { publicKey: () => key.publicKey(), sign: async () => [1, 2] as unknown as Uint8Array } }, 'encrypt: AuthorSigner.sign() did not return a Uint8Array'],
|
||||
[{ cmsSigner: { signers: () => 'x' as unknown as Uint8Array[], sign: () => new Uint8Array(1) } }, 'encrypt: CmsSigner.signers() did not return an array of SHA-256 values of 32 bytes'],
|
||||
[{ cmsSigner: { signers: () => [new Uint8Array(31)], sign: () => new Uint8Array(1) } }, 'encrypt: CmsSigner.signers() did not return an array of SHA-256 values of 32 bytes'],
|
||||
[{ cmsSigner: { signers: () => [new Uint8Array(32)], sign: () => 'der' as unknown as Uint8Array } }, 'capsule: signing: encrypt: CmsSigner.sign() did not return a Uint8Array'],
|
||||
[{ sealer: { seal: () => null as unknown as Uint8Array } }, 'capsule: sealing: encrypt: Sealer.seal() did not return a Uint8Array'],
|
||||
];
|
||||
for (const [extra, msg] of cases) {
|
||||
const err = (await encryptFiles(files, options(extra)).catch((e: Error) => e)) as Error;
|
||||
expect(err.message).toBe(msg);
|
||||
}
|
||||
});
|
||||
|
||||
it('fail with what they throw, after their prefix, which keeps it as its cause', async () => {
|
||||
const thrown = 'not an Error';
|
||||
const err = (await encryptFiles(files, options({ sealer: { seal: () => Promise.reject(thrown) } })).catch((e: Error) => e)) as Error;
|
||||
expect([err.message, err.cause]).toEqual(['capsule: sealing: not an Error', thrown]);
|
||||
const cause = new RangeError('cancelled');
|
||||
const err2 = (await encryptFiles(files, options({ cmsSigner: { signers: () => [new Uint8Array(32)], sign: () => Promise.reject(cause) } })).catch((e: Error) => e)) as Error;
|
||||
expect([err2.message, err2.cause]).toEqual(['capsule: signing: cancelled', cause]);
|
||||
// What the author key throws reaches the caller as it is: in Go, Sign cannot fail.
|
||||
const own = new Error('the key is locked');
|
||||
await expect(encryptFiles(files, options({ authorKey: { publicKey: () => new Uint8Array(32).fill(1), sign: () => Promise.reject(own) } }))).rejects.toBe(own);
|
||||
});
|
||||
|
||||
it('are refused by the writer of format 2, with the text of Go', async () => {
|
||||
const key = AuthorKey.generate();
|
||||
const hooks: Partial<EncryptOptions>[] = [
|
||||
{ authorKey: key },
|
||||
{ cmsSigner: { signers: () => [], sign: () => new Uint8Array(0) } },
|
||||
{ sealer: { seal: () => new Uint8Array(0) } },
|
||||
{ largeArea: true },
|
||||
];
|
||||
for (const extra of hooks) {
|
||||
await expect(encryptVectors(new Uint8Array(1), options(extra))).rejects.toThrow(
|
||||
'capsule: format 2 has no security area or public note: AuthorKey, CMSSigner, Sealer, LargeArea and PublicNote are for EncryptFiles',
|
||||
);
|
||||
}
|
||||
await expect(encrypt(new Uint8Array(1), options({ authorKey: key }))).rejects.toThrow(/only a generator of test vectors/);
|
||||
});
|
||||
|
||||
it('write the area of a generator of test vectors when it is asked', async () => {
|
||||
const key = AuthorKey.generate();
|
||||
const w = await encryptFilesWith(files, options({ authorKey: key, largeArea: false }), {}, { areaLen: AREA_UNIT });
|
||||
expect((await openedOf(w.dkc!)).area_len).toBe(AREA_UNIT);
|
||||
await expect(encryptFilesWith(files, options({ authorKey: key, largeArea: true }), {}, { areaLen: 2 * AREA_UNIT })).rejects.toThrow(
|
||||
'capsule: a test area of 1024 bytes: a multiple of 512 up to 65536, without LargeArea',
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue