ageio.ts reads and writes age files as filippo.io/age v1.3.2, with its texts and its order of random draws, which Go's locator copies; it uses only the noble modules that x25519.ts already uses. envelope.ts is Open, Info.OpenLocator, OpenEnvelope, Seal and NewEnvelope of package locator at spec-v0.12, with an injectable random source read in the order of Go. - locator-seal.json (scripts/locator-seal-go-vectors.go): with the same seed, seal and newEnvelope write the bytes of Go; - locator-interop.json (scripts/locator-ts-samples.mjs and locator-go-verdicts.go): Go opens what this library writes, up to a .dkc of 16 MiB and one byte; - vectors.test.ts runs all of testdata/vectors/locator.json with the texts of Go, instead of its spec field only. Shared files: dependencies.test.ts lets ageio.ts import noble and keeps the four locator modules out of index.ts; check-build.mjs fails when a page loads the locator with its first load; vitest.config.ts holds them at 100 % coverage; ibe.ts updates the comment of encryptOnG2WithSigma; README and CHANGELOG describe the port. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
0d895bf362
commit
e2c51bca3a
@ -0,0 +1,174 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// Opens with Go what the locator of datekeys-ts writes, and prints
|
||||||
|
// src/lib/dkc/testing/locator-interop.json: each recipe of
|
||||||
|
// src/lib/dkc/testing/locator-interop.ts, written by
|
||||||
|
// scripts/locator-ts-samples.mjs, with the SHA-256 of each of its files and
|
||||||
|
// the verdict of Go:
|
||||||
|
//
|
||||||
|
// - locator.Open of the sealed locator with Quicknet, its round and the
|
||||||
|
// published release of the round, from the fixtures;
|
||||||
|
// - Marshal of the locator that Open gives, which must be the plaintext
|
||||||
|
// that TypeScript sealed, and Usable, which must keep every address;
|
||||||
|
// - OpenEnvelope of the rest, which must give the .dkc;
|
||||||
|
// - RestIn of the rest hidden after a host with Hide, at its offset,
|
||||||
|
// which must give the rest again.
|
||||||
|
//
|
||||||
|
// A verdict is "ok" or the text of the first thing that failed.
|
||||||
|
//
|
||||||
|
// It imports only public packages of the reference implementation, and runs
|
||||||
|
// in a module of it without changing anything there: an export of the tag
|
||||||
|
// spec-v0.12, so that no change in progress in datekeys-go is read.
|
||||||
|
//
|
||||||
|
// node scripts/locator-ts-samples.mjs /tmp/samples
|
||||||
|
// git -C ../datekeys-go archive spec-v0.12 | tar -x -C /tmp/dkgo
|
||||||
|
// (cd /tmp/dkgo && go run .../datekeys-ts/scripts/locator-go-verdicts.go \
|
||||||
|
// -source spec-v0.12 -testdata .../datekeys-ts/testdata -samples /tmp/samples > .../locator-interop.json)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"g.activething.com/go/DateKeys/locator"
|
||||||
|
"g.activething.com/go/DateKeys/profile"
|
||||||
|
"g.activething.com/go/DateKeys/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type obj = map[string]any
|
||||||
|
|
||||||
|
func h(b []byte) string { return hex.EncodeToString(b) }
|
||||||
|
|
||||||
|
func check(err error) {
|
||||||
|
if err != nil {
|
||||||
|
_, file, line, _ := runtime.Caller(1)
|
||||||
|
log.Fatalf("%s:%d: %v", filepath.Base(file), line, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pattern(n int) []byte {
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(31*i + 7)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func releases(testdata string) map[uint64]provider.Release {
|
||||||
|
out := map[uint64]provider.Release{}
|
||||||
|
files, err := filepath.Glob(filepath.Join(testdata, "fixtures", "*.json"))
|
||||||
|
check(err)
|
||||||
|
sort.Strings(files)
|
||||||
|
for _, f := range files {
|
||||||
|
raw, err := os.ReadFile(f)
|
||||||
|
check(err)
|
||||||
|
var v struct {
|
||||||
|
Release *struct {
|
||||||
|
Round uint64 `json:"round"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
} `json:"release"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil || v.Release == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sig, err := hex.DecodeString(v.Release.Signature)
|
||||||
|
check(err)
|
||||||
|
out[v.Release.Round] = provider.Release{Round: v.Release.Round, Signature: sig}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func verdict(r obj, files map[string][]byte, rel map[uint64]provider.Release) error {
|
||||||
|
round := uint64(r["round"].(float64))
|
||||||
|
release, ok := rel[round]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no release of round %d", round)
|
||||||
|
}
|
||||||
|
loc, err := locator.Open(profile.Quicknet(), round, release, files["sealed"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pt, err := loc.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !bytes.Equal(pt, files["plaintext"]) {
|
||||||
|
return errors.New("another plaintext")
|
||||||
|
}
|
||||||
|
if len(loc.Usable()) != len(r["addresses"].([]any)) || len(loc.Addresses) != len(loc.Usable()) {
|
||||||
|
return errors.New("an address that a reader does not use")
|
||||||
|
}
|
||||||
|
dkc, err := loc.OpenEnvelope(files["rest"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !bytes.Equal(dkc, files["dkc"]) || !bytes.Equal(dkc, pattern(int(r["dkc_length"].(float64)))) {
|
||||||
|
return errors.New("another .dkc")
|
||||||
|
}
|
||||||
|
file, offset := locator.Hide([]byte("GIF89a, a host of some kind"), files["rest"])
|
||||||
|
rest, err := loc.RestIn(append(file, "and more"...), offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rest, files["rest"]) {
|
||||||
|
return errors.New("another rest in the host")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
samples := flag.String("samples", "", "the directory of the samples")
|
||||||
|
src := flag.String("source", "", "the commit of datekeys-go")
|
||||||
|
testdata := flag.String("testdata", "", "the testdata of this repository")
|
||||||
|
flag.Parse()
|
||||||
|
if *samples == "" || *src == "" || *testdata == "" {
|
||||||
|
log.Fatal("usage: -source <commit> -testdata <dir> -samples <dir>")
|
||||||
|
}
|
||||||
|
rel := releases(*testdata)
|
||||||
|
raw, err := os.ReadFile(filepath.Join(*samples, "samples.json"))
|
||||||
|
check(err)
|
||||||
|
var doc struct {
|
||||||
|
Samples []obj `json:"samples"`
|
||||||
|
}
|
||||||
|
check(json.Unmarshal(raw, &doc))
|
||||||
|
result := []obj{}
|
||||||
|
for i, r := range doc.Samples {
|
||||||
|
names, err := filepath.Glob(filepath.Join(*samples, fmt.Sprintf("%d.*", i)))
|
||||||
|
check(err)
|
||||||
|
files := map[string][]byte{}
|
||||||
|
digests := obj{}
|
||||||
|
for _, n := range names {
|
||||||
|
b, err := os.ReadFile(n)
|
||||||
|
check(err)
|
||||||
|
suffix := strings.TrimPrefix(filepath.Ext(n), ".")
|
||||||
|
files[suffix] = b
|
||||||
|
s := sha256.Sum256(b)
|
||||||
|
digests[suffix] = h(s[:])
|
||||||
|
}
|
||||||
|
v := "ok"
|
||||||
|
if err := verdict(r, files, rel); err != nil {
|
||||||
|
v = err.Error()
|
||||||
|
log.Printf("%s: %s", r["name"], v)
|
||||||
|
}
|
||||||
|
r["files"] = digests
|
||||||
|
r["verdict"] = v
|
||||||
|
result = append(result, r)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
e := json.NewEncoder(&buf)
|
||||||
|
e.SetEscapeHTML(false)
|
||||||
|
e.SetIndent("", " ")
|
||||||
|
check(e.Encode(obj{"source": *src, "go": runtime.Version(), "description": "What Go makes of the sealed locators and envelopes that datekeys-ts writes from a seed, from the recipes of src/lib/dkc/testing/locator-interop.ts, by scripts/locator-go-verdicts.go: the SHA-256 of each file and the verdict of Go, ok or the text of what failed.", "samples": result}))
|
||||||
|
os.Stdout.Write(buf.Bytes())
|
||||||
|
}
|
||||||
@ -0,0 +1,388 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// Prints src/lib/dkc/testing/locator-seal.json, the vectors of the sealing
|
||||||
|
// of the locator and of the envelope of datekeys-ts (envelope.ts): Seal and
|
||||||
|
// NewEnvelope of package locator of datekeys-go at the tag spec-v0.12, while
|
||||||
|
// crypto/rand reads the keystream of a seed (ChaCha20 under SHA-256(seed),
|
||||||
|
// zero nonce, as seededFill of src/lib/dkc/testing/seeded.ts), with each
|
||||||
|
// draw, so that seal and newEnvelope, with the same seed, must draw the same
|
||||||
|
// values in the same order and write the same bytes:
|
||||||
|
//
|
||||||
|
// - seal: Seal of locators of 1 to 8 addresses, with offsets and headers
|
||||||
|
// of 1 to 1024 bytes, whose plaintext takes one, two or three blocks of
|
||||||
|
// 4096 bytes, for rounds from 1 to the last one of Quicknet. Go opens
|
||||||
|
// each with Open and the published release of its round when the
|
||||||
|
// fixtures have it, 1000, 1001, 1004 or 2000, and gets the locator
|
||||||
|
// back. A small file is stored whole; every file with its length and
|
||||||
|
// SHA-256;
|
||||||
|
// - seal_errors: what Seal refuses, with its text: locators that Marshal
|
||||||
|
// refuses, rounds out of the range of Quicknet, and both at once,
|
||||||
|
// where Marshal goes first;
|
||||||
|
// - envelope: NewEnvelope of .dkc of 0 bytes to 1 MiB: the
|
||||||
|
// addresses, and the rest, whole when
|
||||||
|
// small, with its length and SHA-256. Go opens each with OpenEnvelope;
|
||||||
|
// - flow: NewEnvelope, the addresses, Seal, Open and OpenEnvelope in one
|
||||||
|
// seed, as a writer and a reader do.
|
||||||
|
//
|
||||||
|
// The plaintext of a .dkc of n bytes has (31·i + 7) mod 256 as byte i. It is
|
||||||
|
// the generator of the stage 7b of datekeys-dart, with the seeds of this
|
||||||
|
// repository.
|
||||||
|
//
|
||||||
|
// It imports only public packages of the reference implementation, and runs
|
||||||
|
// in a module of it without changing anything there: an export of the tag
|
||||||
|
// spec-v0.12, so that no change in progress in datekeys-go is read.
|
||||||
|
//
|
||||||
|
// git -C ../datekeys-go archive spec-v0.12 | tar -x -C /tmp/dkgo
|
||||||
|
// (cd /tmp/dkgo && go run .../datekeys-ts/scripts/locator-seal-go-vectors.go \
|
||||||
|
// -source spec-v0.12 -testdata .../datekeys-ts/testdata -out .../datekeys-ts/src/lib/dkc/testing)
|
||||||
|
//
|
||||||
|
// The output is the same on every run.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/chacha20"
|
||||||
|
|
||||||
|
"g.activething.com/go/DateKeys/locator"
|
||||||
|
"g.activething.com/go/DateKeys/profile"
|
||||||
|
"g.activething.com/go/DateKeys/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 check(err error) {
|
||||||
|
if err != nil {
|
||||||
|
_, file, line, _ := runtime.Caller(1)
|
||||||
|
log.Fatalf("%s:%d: %v", filepath.Base(file), line, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustHex(s string) []byte {
|
||||||
|
b, err := hex.DecodeString(s)
|
||||||
|
check(err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func label(s string) []byte {
|
||||||
|
b := sha256.Sum256([]byte("datekeys-ts locator seal: " + s))
|
||||||
|
return b[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func pattern(n int) []byte {
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(31*i + 7)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// crypto/rand from a seed
|
||||||
|
|
||||||
|
type seeded struct {
|
||||||
|
c *chacha20.Cipher
|
||||||
|
draws [][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func with(seed string, f func()) [][]byte {
|
||||||
|
key := sha256.Sum256([]byte(seed))
|
||||||
|
c, err := chacha20.NewUnauthenticatedCipher(key[:], make([]byte, chacha20.NonceSize))
|
||||||
|
check(err)
|
||||||
|
s := &seeded{c: c}
|
||||||
|
old := cryptorand.Reader
|
||||||
|
cryptorand.Reader = s
|
||||||
|
defer func() { cryptorand.Reader = old }()
|
||||||
|
f()
|
||||||
|
return s.draws
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawsOf(d [][]byte) []obj {
|
||||||
|
out := []obj{}
|
||||||
|
for _, b := range d {
|
||||||
|
out = append(out, obj{"n": len(b), "hex": h(b)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// small is the largest file stored whole.
|
||||||
|
const small = 16 << 10
|
||||||
|
|
||||||
|
func fileObj(b []byte) obj {
|
||||||
|
o := obj{"length": len(b), "sha256": sum(b)}
|
||||||
|
if len(b) <= small {
|
||||||
|
o["hex"] = h(b)
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func locObj(l *locator.Locator) obj {
|
||||||
|
addrs := []obj{}
|
||||||
|
for _, a := range l.Addresses {
|
||||||
|
addrs = append(addrs, obj{"uri": a.URI, "offset": a.Offset})
|
||||||
|
}
|
||||||
|
return obj{"addresses": addrs, "envelope_key": h(l.EnvelopeKey[:]), "envelope_header": h(l.EnvelopeHeader), "rest_digest": h(l.RestDigest[:]), "rest_size": l.RestSize, "capsule_digest": h(l.CapsuleDigest[:])}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLoc is a locator of n addresses, the i-th of uri length about
|
||||||
|
// uriLen, with offsets, and a header of headerLen bytes.
|
||||||
|
func newLoc(name string, n, uriLen, headerLen int, offsets bool) *locator.Locator {
|
||||||
|
l := &locator.Locator{EnvelopeHeader: label(name + " header")}
|
||||||
|
for len(l.EnvelopeHeader) < headerLen {
|
||||||
|
l.EnvelopeHeader = append(l.EnvelopeHeader, label(fmt.Sprintf("%s header %d", name, len(l.EnvelopeHeader)))...)
|
||||||
|
}
|
||||||
|
l.EnvelopeHeader = l.EnvelopeHeader[:headerLen]
|
||||||
|
copy(l.EnvelopeKey[:], label(name+" key"))
|
||||||
|
copy(l.RestDigest[:], label(name+" rest"))
|
||||||
|
copy(l.CapsuleDigest[:], label(name+" capsule"))
|
||||||
|
l.RestSize = uint64(len(name)) * 1000003
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
uri := fmt.Sprintf("https://example.com/%s/%d/", strings.ReplaceAll(name, " ", "-"), i)
|
||||||
|
for len(uri) < uriLen {
|
||||||
|
uri += "a"
|
||||||
|
}
|
||||||
|
var off uint64
|
||||||
|
if offsets && i%2 == 1 {
|
||||||
|
off = uint64(i) * 4099
|
||||||
|
}
|
||||||
|
l.Addresses = append(l.Addresses, locator.Address{URI: uri, Offset: off})
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var releases map[uint64]provider.Release
|
||||||
|
|
||||||
|
// readReleases reads the releases of the fixtures, public data of drand.
|
||||||
|
func readReleases(testdata string) map[uint64]provider.Release {
|
||||||
|
out := map[uint64]provider.Release{}
|
||||||
|
files, err := filepath.Glob(filepath.Join(testdata, "fixtures", "*.json"))
|
||||||
|
check(err)
|
||||||
|
slices.Sort(files)
|
||||||
|
for _, f := range files {
|
||||||
|
raw, err := os.ReadFile(f)
|
||||||
|
check(err)
|
||||||
|
var v struct {
|
||||||
|
Release *struct {
|
||||||
|
Round uint64 `json:"round"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
} `json:"release"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil || v.Release == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[v.Release.Round] = provider.Release{Round: v.Release.Round, Signature: mustHex(v.Release.Signature)}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// opens reports whether Go opens sealed for round and gets l back; null
|
||||||
|
// when no release of the round is known.
|
||||||
|
func opens(p *profile.Profile, round uint64, l *locator.Locator, sealed []byte) any {
|
||||||
|
rel, ok := releases[round]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
got, err := locator.Open(p, round, rel, sealed)
|
||||||
|
check(err)
|
||||||
|
a, err := l.Marshal()
|
||||||
|
check(err)
|
||||||
|
b, err := got.Marshal()
|
||||||
|
check(err)
|
||||||
|
if !bytes.Equal(a, b) {
|
||||||
|
log.Fatalf("round %d: Open gives another locator", round)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sealSection() []obj {
|
||||||
|
p := profile.Quicknet()
|
||||||
|
type c struct {
|
||||||
|
name string
|
||||||
|
round uint64
|
||||||
|
loc *locator.Locator
|
||||||
|
}
|
||||||
|
cases := []c{
|
||||||
|
{"one address", 1000, newLoc("one address", 1, 30, 200, false)},
|
||||||
|
{"two addresses with offsets", 1001, newLoc("two addresses", 2, 60, 300, true)},
|
||||||
|
{"eight addresses, two blocks", 2000, newLoc("eight addresses", 8, 500, 1024, true)},
|
||||||
|
{"eight long addresses, three blocks", 1004, newLoc("eight long", 8, 1024, 1024, true)},
|
||||||
|
{"a header of one byte", 1000, newLoc("one byte", 1, 20, 1, false)},
|
||||||
|
{"round 1", 1, newLoc("round 1", 3, 100, 500, true)},
|
||||||
|
{"the last round of Quicknet", p.MaxRound(), newLoc("last round", 1, 40, 900, false)},
|
||||||
|
{"round 12345678901", 12345678901, newLoc("eleven digits", 4, 400, 700, true)},
|
||||||
|
}
|
||||||
|
// The plaintext of one block exactly, and of one byte more before key 6.
|
||||||
|
for _, cut := range []int{1, 0} {
|
||||||
|
l := newLoc("edge", 4, 750, 100, false)
|
||||||
|
for {
|
||||||
|
pt, err := l.Marshal()
|
||||||
|
check(err)
|
||||||
|
if len(pt) > 4096 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
l.EnvelopeHeader = append(l.EnvelopeHeader, 'h')
|
||||||
|
}
|
||||||
|
l.EnvelopeHeader = l.EnvelopeHeader[:len(l.EnvelopeHeader)-cut]
|
||||||
|
cases = append(cases, c{[]string{"a block and one byte", "one block exactly"}[cut], 1000, l})
|
||||||
|
}
|
||||||
|
out := []obj{}
|
||||||
|
for _, k := range cases {
|
||||||
|
seed := "datekeys-ts locator seal " + k.name
|
||||||
|
var sealed []byte
|
||||||
|
d := with(seed, func() {
|
||||||
|
var err error
|
||||||
|
sealed, err = locator.Seal(p, k.round, k.loc)
|
||||||
|
check(err)
|
||||||
|
})
|
||||||
|
pt, err := k.loc.Marshal()
|
||||||
|
check(err)
|
||||||
|
o := obj{"name": k.name, "seed": seed, "round": k.round, "locator": locObj(k.loc), "plaintext_length": len(pt), "draws": drawsOf(d), "sealed": fileObj(sealed), "opens": opens(p, k.round, k.loc, sealed)}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sealErrorsSection() []obj {
|
||||||
|
p := profile.Quicknet()
|
||||||
|
out := []obj{}
|
||||||
|
add := func(name string, round uint64, l *locator.Locator) {
|
||||||
|
var err error
|
||||||
|
d := with("datekeys-ts locator seal error "+name, func() { _, err = locator.Seal(p, round, l) })
|
||||||
|
if err == nil {
|
||||||
|
log.Fatalf("%s: no error", name)
|
||||||
|
}
|
||||||
|
out = append(out, obj{"name": name, "round": round, "locator": locObj(l), "error": err.Error(), "draws": len(d)})
|
||||||
|
}
|
||||||
|
good := newLoc("good", 1, 30, 100, false)
|
||||||
|
add("round 0", 0, good)
|
||||||
|
add("a round after the last one", p.MaxRound()+1, good)
|
||||||
|
none := newLoc("none", 0, 0, 100, false)
|
||||||
|
add("no address", 1000, none)
|
||||||
|
add("no address and round 0", 0, none)
|
||||||
|
add("nine addresses", 1000, newLoc("nine", 9, 30, 100, false))
|
||||||
|
add("an address of 1025 bytes", 1000, newLoc("long uri", 1, 1025, 100, false))
|
||||||
|
add("an empty header", 1000, newLoc("empty header", 1, 30, 0, false))
|
||||||
|
add("a header of 1025 bytes", 1000, newLoc("long header", 1, 30, 1025, false))
|
||||||
|
bad := newLoc("bad", 2, 30, 100, false)
|
||||||
|
bad.Addresses[1].URI = "http://example.com/"
|
||||||
|
add("an address of http", 1000, bad)
|
||||||
|
bad2 := newLoc("bad2", 1, 30, 100, false)
|
||||||
|
bad2.Addresses[0].URI = "https://192.168.1.1/x"
|
||||||
|
add("a private address", 1000, bad2)
|
||||||
|
add("a private address and round 0", 0, bad2)
|
||||||
|
big := newLoc("big", 1, 30, 100, false)
|
||||||
|
big.RestSize = 1 << 53
|
||||||
|
add("a rest of 2^53 bytes", 1000, big)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func envelopeSection() []obj {
|
||||||
|
out := []obj{}
|
||||||
|
for _, n := range []int{0, 1, 1000, 65535, 65536, 65537, 200000, 1 << 20} {
|
||||||
|
seed := fmt.Sprintf("datekeys-ts locator envelope %d", n)
|
||||||
|
dkc := pattern(n)
|
||||||
|
var loc *locator.Locator
|
||||||
|
var rest []byte
|
||||||
|
d := with(seed, func() {
|
||||||
|
var err error
|
||||||
|
loc, rest, err = locator.NewEnvelope(dkc)
|
||||||
|
check(err)
|
||||||
|
})
|
||||||
|
back, err := loc.OpenEnvelope(rest)
|
||||||
|
check(err)
|
||||||
|
if !bytes.Equal(back, dkc) {
|
||||||
|
log.Fatal("OpenEnvelope")
|
||||||
|
}
|
||||||
|
out = append(out, obj{"seed": seed, "dkc_length": n, "locator": locObj(loc), "rest": fileObj(rest), "draws": drawsOf(d)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func flowSection() obj {
|
||||||
|
p := profile.Quicknet()
|
||||||
|
seed := "datekeys-ts locator flow"
|
||||||
|
dkc := pattern(5000)
|
||||||
|
var sealed, rest, file []byte
|
||||||
|
var offset uint64
|
||||||
|
var loc *locator.Locator
|
||||||
|
d := with(seed, func() {
|
||||||
|
var err error
|
||||||
|
loc, rest, err = locator.NewEnvelope(dkc)
|
||||||
|
check(err)
|
||||||
|
file, offset = locator.Hide([]byte("GIF89a, a host of some kind"), rest)
|
||||||
|
loc.Addresses = []locator.Address{{URI: "https://example.com/capsule"}, {URI: "https://example.org/host.gif", Offset: offset}}
|
||||||
|
sealed, err = locator.Seal(p, 1000, loc)
|
||||||
|
check(err)
|
||||||
|
})
|
||||||
|
got, err := locator.Open(p, 1000, releases[1000], sealed)
|
||||||
|
check(err)
|
||||||
|
r, err := got.RestIn(file, got.Addresses[1].Offset)
|
||||||
|
check(err)
|
||||||
|
back, err := got.OpenEnvelope(r)
|
||||||
|
check(err)
|
||||||
|
if !bytes.Equal(back, dkc) {
|
||||||
|
log.Fatal("the flow")
|
||||||
|
}
|
||||||
|
return obj{"seed": seed, "dkc_length": len(dkc), "host": h([]byte("GIF89a, a host of some kind")), "round": 1000, "draws": drawsOf(d), "locator": locObj(loc), "rest": fileObj(rest), "sealed": fileObj(sealed)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
out := flag.String("out", "", "where the vectors go")
|
||||||
|
src := flag.String("source", "", "the commit of datekeys-go")
|
||||||
|
testdata := flag.String("testdata", "", "the testdata of this repository")
|
||||||
|
flag.Parse()
|
||||||
|
if *out == "" || *src == "" || *testdata == "" {
|
||||||
|
log.Fatal("usage: -source <commit> -testdata <dir> -out <dir>")
|
||||||
|
}
|
||||||
|
releases = readReleases(*testdata)
|
||||||
|
rel := obj{}
|
||||||
|
for r, v := range releases {
|
||||||
|
rel[fmt.Sprint(r)] = h(v.Signature)
|
||||||
|
}
|
||||||
|
doc := obj{
|
||||||
|
"source": *src,
|
||||||
|
"go": runtime.Version(),
|
||||||
|
"description": "Seal and NewEnvelope of package locator while crypto/rand reads the keystream of SeededRandomSource (ChaCha20 under SHA-256(seed), zero nonce), as seededFill of src/lib/dkc/testing/seeded.ts, by scripts/locator-seal-go-vectors.go. A file is {length, sha256} and its hex when it is small. The .dkc of n bytes has (31·i + 7) mod 256 as byte i. opens is true when Go opened the sealed locator with the release of its round, which releases holds, and null when no release is known. draws lists every value that crypto/rand gave, in order.",
|
||||||
|
"releases": rel,
|
||||||
|
"seal": sealSection(),
|
||||||
|
"seal_errors": sealErrorsSection(),
|
||||||
|
"envelope": envelopeSection(),
|
||||||
|
"flow": flowSection(),
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
e := json.NewEncoder(&buf)
|
||||||
|
e.SetEscapeHTML(false)
|
||||||
|
e.SetIndent("", " ")
|
||||||
|
check(e.Encode(doc))
|
||||||
|
path := filepath.Join(*out, "locator-seal.json")
|
||||||
|
check(os.WriteFile(path, buf.Bytes(), 0o644))
|
||||||
|
fmt.Printf("wrote %s, %d bytes\n", path, buf.Len())
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Writes the samples of the interoperability of the locator, TypeScript to
|
||||||
|
// Go: the files of the recipes of src/lib/dkc/testing/locator-interop.ts,
|
||||||
|
// sealed locators with their envelopes, which this library writes from a
|
||||||
|
// seed, and samples.json, the recipes, into a directory.
|
||||||
|
// scripts/locator-go-verdicts.go then opens them with Go and writes
|
||||||
|
// src/lib/dkc/testing/locator-interop.json; locator.interop.test.ts writes
|
||||||
|
// the files again from the recipes and compares them with what Go read.
|
||||||
|
// Node runs the TypeScript sources directly (type stripping, Node 22.6+):
|
||||||
|
//
|
||||||
|
// node scripts/locator-ts-samples.mjs <directory>
|
||||||
|
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { interopFiles, interopRecipes } from '../src/lib/dkc/testing/locator-interop.ts';
|
||||||
|
|
||||||
|
const dir = process.argv[2];
|
||||||
|
if (dir === undefined) {
|
||||||
|
console.error('usage: node scripts/locator-ts-samples.mjs <directory>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const recipes = interopRecipes();
|
||||||
|
for (const [i, r] of recipes.entries()) {
|
||||||
|
const files = interopFiles(r);
|
||||||
|
for (const [suffix, b] of Object.entries(files)) writeFileSync(join(dir, `${i}.${suffix}`), b);
|
||||||
|
console.log(`${r.name}: ${Object.keys(files).join(', ')}`);
|
||||||
|
}
|
||||||
|
writeFileSync(join(dir, 'samples.json'), `${JSON.stringify({ samples: recipes }, null, 1)}\n`);
|
||||||
|
console.log(`wrote ${recipes.length} samples to ${dir}`);
|
||||||
@ -0,0 +1,412 @@
|
|||||||
|
// age files read and written as filippo.io/age v1.3.2 reads and writes them,
|
||||||
|
// with its texts, its order of checks and its order of random draws, for
|
||||||
|
// the locator of datekeys.capsule (locator.ts, envelope.ts):
|
||||||
|
//
|
||||||
|
// - ageDecrypt is age.Decrypt followed by io.ReadAll, optionally through
|
||||||
|
// io.LimitReader: the header parsed by age.ts, the file key unwrapped by
|
||||||
|
// an identity, the header MAC, the nonce and the STREAM, each failure with
|
||||||
|
// the text of age's error;
|
||||||
|
// - ageEncrypt is age.Encrypt for one recipient: the file key, the stanzas
|
||||||
|
// of the recipient, the header MAC, the nonce and the STREAM, with every
|
||||||
|
// random value taken from the source given, in the order of Go, so that
|
||||||
|
// with the same values it writes the bytes of Go.
|
||||||
|
//
|
||||||
|
// The capsule (open.ts, writer.ts) reads and writes its own age files with
|
||||||
|
// age-encryption instead, whose texts the reference never copies (spec §69).
|
||||||
|
// The locator has no normative code, and Go's locator.Open and OpenEnvelope
|
||||||
|
// copy the texts of age, so this module reproduces them.
|
||||||
|
//
|
||||||
|
// Internal: index.ts does not re-export it.
|
||||||
|
|
||||||
|
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
|
||||||
|
import { x25519 } from '@noble/curves/ed25519.js';
|
||||||
|
import { extract, hkdf } from '@noble/hashes/hkdf.js';
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { parseAgeHeader, type Stanza } from './age.ts';
|
||||||
|
import { goBase64 } from './datekey.ts';
|
||||||
|
|
||||||
|
/** The size of an age file key. */
|
||||||
|
export const FILE_KEY_LEN = 16;
|
||||||
|
const NONCE_LEN = 16;
|
||||||
|
const CHUNK = 64 * 1024;
|
||||||
|
const TAG = 16;
|
||||||
|
const ENC_CHUNK = CHUNK + TAG;
|
||||||
|
const X25519_LABEL = new TextEncoder().encode('age-encryption.org/v1/X25519');
|
||||||
|
const HEADER_INFO = new TextEncoder().encode('header');
|
||||||
|
const PAYLOAD_INFO = new TextEncoder().encode('payload');
|
||||||
|
const INTRO = 'age-encryption.org/v1\n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failure of age, with the text of the error of Go's filippo.io/age, such
|
||||||
|
* as "bad header MAC".
|
||||||
|
*/
|
||||||
|
export class AgeError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AgeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text of age's ErrIncorrectIdentity. */
|
||||||
|
export const INCORRECT_IDENTITY = 'incorrect identity for recipient block';
|
||||||
|
|
||||||
|
/** age's ErrIncorrectIdentity: the identity is not a recipient of the stanzas. */
|
||||||
|
export class IncorrectIdentity extends AgeError {
|
||||||
|
constructor() {
|
||||||
|
super(INCORRECT_IDENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An age identity, as Go's age.Identity: it returns the file key of the
|
||||||
|
* stanzas, or throws IncorrectIdentity, an AgeError with the text of age, or
|
||||||
|
* the error of its own checks, which age.Decrypt returns as it is.
|
||||||
|
*/
|
||||||
|
export interface AgeIdentity {
|
||||||
|
unwrap(stanzas: readonly Stanza[]): Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A source of random bytes: it fills `b`, as crypto.getRandomValues does. */
|
||||||
|
export type RandomFill = (b: Uint8Array) => void;
|
||||||
|
|
||||||
|
/** The random bytes of crypto.getRandomValues. */
|
||||||
|
export const cryptoFill: RandomFill = (b) => {
|
||||||
|
crypto.getRandomValues(b);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `n` bytes drawn from `random`. */
|
||||||
|
export function draw(random: RandomFill, n: number): Uint8Array {
|
||||||
|
const b = new Uint8Array(n);
|
||||||
|
random(b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Base64 of age: standard alphabet, no padding
|
||||||
|
|
||||||
|
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
|
||||||
|
/** age's format.EncodeToString: standard Base64 without padding. */
|
||||||
|
export function b64Encode(b: Uint8Array): string {
|
||||||
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
for (; i + 3 <= b.length; i += 3) {
|
||||||
|
const v = (b[i]! << 16) | (b[i + 1]! << 8) | b[i + 2]!;
|
||||||
|
out += B64[v >> 18]! + B64[(v >> 12) & 63]! + B64[(v >> 6) & 63]! + B64[v & 63]!;
|
||||||
|
}
|
||||||
|
if (b.length - i === 1) {
|
||||||
|
const v = b[i]! << 16;
|
||||||
|
out += B64[v >> 18]! + B64[(v >> 12) & 63]!;
|
||||||
|
} else if (b.length - i === 2) {
|
||||||
|
const v = (b[i]! << 16) | (b[i + 1]! << 8);
|
||||||
|
out += B64[v >> 18]! + B64[(v >> 12) & 63]! + B64[(v >> 6) & 63]!;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// age's format.DecodeString on an argument of a stanza: canonical unpadded
|
||||||
|
// standard Base64. Returns the bytes or Go's error text. The parser of age.ts
|
||||||
|
// has already refused a line feed or a carriage return in an argument.
|
||||||
|
function b64Decode(s: string): Uint8Array | string {
|
||||||
|
const r = goBase64(new TextEncoder().encode(s), false, false, true);
|
||||||
|
return typeof r === 'number' ? `illegal base64 data at input byte ${r}` : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The header
|
||||||
|
|
||||||
|
// age's format.Stanza.Marshal: "->", the type and the arguments, then the
|
||||||
|
// body in lines of 64 columns ended by a shorter line, possibly empty.
|
||||||
|
function marshalStanza(s: Stanza): string {
|
||||||
|
let out = `-> ${[s.type, ...s.args].join(' ')}\n`;
|
||||||
|
const body = b64Encode(s.body);
|
||||||
|
for (let i = 0; i < body.length; i += 64) out += `${body.slice(i, i + 64)}\n`;
|
||||||
|
if (body.length % 64 === 0) out += '\n';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header as format.Header.MarshalWithoutMAC writes it: up to "---".
|
||||||
|
function headerWithoutMac(stanzas: readonly Stanza[]): Uint8Array {
|
||||||
|
return new TextEncoder().encode(`${INTRO}${stanzas.map(marshalStanza).join('')}---`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// age's headerMAC: HMAC-SHA-256 of the header up to "---", keyed with
|
||||||
|
// HKDF-SHA-256 of the file key, no salt, info "header". HKDF-Extract with a
|
||||||
|
// salt is HMAC keyed with the salt.
|
||||||
|
function headerMac(fileKey: Uint8Array, stanzas: readonly Stanza[]): Uint8Array {
|
||||||
|
const key = hkdf(sha256, fileKey, undefined, HEADER_INFO, 32);
|
||||||
|
const mac = extract(sha256, headerWithoutMac(stanzas), key);
|
||||||
|
key.fill(0);
|
||||||
|
return mac;
|
||||||
|
}
|
||||||
|
|
||||||
|
// age's streamKey: HKDF-SHA-256 of the file key, salted with the nonce, info "payload".
|
||||||
|
const streamKey = (fileKey: Uint8Array, nonce: Uint8Array): Uint8Array => hkdf(sha256, fileKey, nonce, PAYLOAD_INFO, 32);
|
||||||
|
|
||||||
|
// Two MACs of 32 bytes, compared in time that does not depend on where they
|
||||||
|
// differ.
|
||||||
|
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
let acc = a.length ^ b.length;
|
||||||
|
for (let i = 0; i < a.length && i < b.length; i++) acc |= a[i]! ^ b[i]!;
|
||||||
|
return acc === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// X25519
|
||||||
|
|
||||||
|
/**
|
||||||
|
* age's X25519Identity for the raw 32-byte identity `secret`, which it
|
||||||
|
* copies: each X25519 stanza in turn, the first that opens gives the file
|
||||||
|
* key; a stanza of another type, or one that does not authenticate, is for
|
||||||
|
* another identity; a malformed one stops the unwrap with the text of age.
|
||||||
|
*/
|
||||||
|
export function x25519Identity(secret: Uint8Array): AgeIdentity & { wipe(): void } {
|
||||||
|
if (secret.length !== 32) throw new RangeError('ageio: an X25519 identity is 32 bytes');
|
||||||
|
const key = secret.slice();
|
||||||
|
const ours = x25519.getPublicKey(key);
|
||||||
|
const unwrapOne = (s: Stanza): Uint8Array => {
|
||||||
|
if (s.type !== 'X25519') throw new IncorrectIdentity();
|
||||||
|
if (s.args.length !== 1) throw new AgeError('invalid X25519 recipient block');
|
||||||
|
const share = b64Decode(s.args[0]!);
|
||||||
|
if (typeof share === 'string') throw new AgeError(`failed to parse X25519 recipient: ${share}`);
|
||||||
|
if (share.length !== 32) throw new AgeError('invalid X25519 recipient block');
|
||||||
|
let shared: Uint8Array;
|
||||||
|
try {
|
||||||
|
shared = x25519.getSharedSecret(key, share);
|
||||||
|
} catch {
|
||||||
|
// noble refuses exactly the shares of low order, whose secret is zero.
|
||||||
|
throw new AgeError('invalid X25519 recipient: crypto/ecdh: bad X25519 remote ECDH input: low order point');
|
||||||
|
}
|
||||||
|
const salt = new Uint8Array(64);
|
||||||
|
salt.set(share);
|
||||||
|
salt.set(ours, 32);
|
||||||
|
const wrapping = hkdf(sha256, shared, salt, X25519_LABEL, 32);
|
||||||
|
shared.fill(0);
|
||||||
|
try {
|
||||||
|
if (s.body.length !== FILE_KEY_LEN + TAG) throw new AgeError('invalid X25519 recipient block: incorrect file key size');
|
||||||
|
try {
|
||||||
|
return chacha20poly1305(wrapping, new Uint8Array(12)).decrypt(s.body);
|
||||||
|
} catch {
|
||||||
|
throw new IncorrectIdentity();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wrapping.fill(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
unwrap(stanzas: readonly Stanza[]): Uint8Array {
|
||||||
|
// age's multiUnwrap.
|
||||||
|
for (const s of stanzas) {
|
||||||
|
try {
|
||||||
|
return unwrapOne(s);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof IncorrectIdentity) continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IncorrectIdentity();
|
||||||
|
},
|
||||||
|
wipe(): void {
|
||||||
|
key.fill(0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The X25519 public key, the recipient, of the raw identity `secret`, as age's X25519Identity.Recipient. */
|
||||||
|
export const x25519Recipient = (secret: Uint8Array): Uint8Array => x25519.getPublicKey(secret);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stanza that age's X25519Recipient.Wrap writes for `fileKey` to the
|
||||||
|
* public key `recipient`: an ephemeral scalar of 32 bytes drawn from
|
||||||
|
* `random`, its share, and the file key wrapped with the key that HKDF
|
||||||
|
* derives from the shared secret.
|
||||||
|
*/
|
||||||
|
export function wrapX25519(recipient: Uint8Array, fileKey: Uint8Array, random: RandomFill): Stanza {
|
||||||
|
const ephemeral = draw(random, 32);
|
||||||
|
try {
|
||||||
|
const share = x25519.getPublicKey(ephemeral);
|
||||||
|
const shared = x25519.getSharedSecret(ephemeral, recipient);
|
||||||
|
const salt = new Uint8Array(64);
|
||||||
|
salt.set(share);
|
||||||
|
salt.set(recipient, 32);
|
||||||
|
const wrapping = hkdf(sha256, shared, salt, X25519_LABEL, 32);
|
||||||
|
shared.fill(0);
|
||||||
|
const body = chacha20poly1305(wrapping, new Uint8Array(12)).encrypt(fileKey);
|
||||||
|
wrapping.fill(0);
|
||||||
|
return { type: 'X25519', args: [b64Encode(share)], body };
|
||||||
|
} finally {
|
||||||
|
ephemeral.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decryption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plaintext of the age file `file` for `identity`, as age.Decrypt and
|
||||||
|
* io.ReadAll of its reader, through io.LimitReader when `limit` is given:
|
||||||
|
*
|
||||||
|
* - the header, as age's format.Parse: "failed to read header: " and the
|
||||||
|
* reason of the parser;
|
||||||
|
* - the file key of the identity, whose own error is returned as it is, or,
|
||||||
|
* when it is not a recipient, "identity did not match any of the
|
||||||
|
* recipients: incorrect identity for recipient block";
|
||||||
|
* - "bad header MAC";
|
||||||
|
* - the nonce: "failed to read nonce: EOF" or "...: unexpected EOF";
|
||||||
|
* - the STREAM, as age's DecryptReader: "unexpected EOF" without a last
|
||||||
|
* chunk, "failed to decrypt and authenticate payload chunk, file may be
|
||||||
|
* corrupted or tampered with", "last chunk is empty, try age v1.0.0, and
|
||||||
|
* please consider reporting this", and "trailing data after end of
|
||||||
|
* encrypted file".
|
||||||
|
*
|
||||||
|
* As io.ReadAll through io.LimitReader, it decrypts a chunk only while it has
|
||||||
|
* read fewer than `limit` bytes of plaintext, and a failure that the reader
|
||||||
|
* finds after the last chunk it needed, such as trailing data, is reported
|
||||||
|
* only if it reads again. The plaintext is cut at `limit`. Throws an
|
||||||
|
* AgeError, or the error of the identity.
|
||||||
|
*/
|
||||||
|
export function ageDecrypt(file: Uint8Array, identity: AgeIdentity, limit = Number.POSITIVE_INFINITY): Uint8Array {
|
||||||
|
let header: ReturnType<typeof parseAgeHeader>;
|
||||||
|
try {
|
||||||
|
header = parseAgeHeader(file);
|
||||||
|
} catch (err) {
|
||||||
|
// parseAgeHeader keeps the text of age.Decrypt in its cause.
|
||||||
|
throw new AgeError(((err as Error).cause as Error).message);
|
||||||
|
}
|
||||||
|
let fileKey: Uint8Array;
|
||||||
|
try {
|
||||||
|
fileKey = identity.unwrap(header.stanzas);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof IncorrectIdentity) throw new AgeError(`identity did not match any of the recipients: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!constantTimeEqual(headerMac(fileKey, header.stanzas), header.mac)) throw new AgeError('bad header MAC');
|
||||||
|
const rest = file.length - header.length;
|
||||||
|
if (rest < NONCE_LEN) throw new AgeError(`failed to read nonce: ${rest === 0 ? 'EOF' : 'unexpected EOF'}`);
|
||||||
|
const key = streamKey(fileKey, file.subarray(header.length, header.length + NONCE_LEN));
|
||||||
|
try {
|
||||||
|
return readStream(key, file.subarray(header.length + NONCE_LEN), limit);
|
||||||
|
} finally {
|
||||||
|
key.fill(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fileKey.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reading of age's DecryptReader by io.ReadAll through io.LimitReader:
|
||||||
|
// a chunk is read only once the plaintext of the previous one is consumed
|
||||||
|
// and fewer than limit bytes were read; the check after the last chunk,
|
||||||
|
// trailing data or the end, is made with it and reported at the next read.
|
||||||
|
function readStream(key: Uint8Array, data: Uint8Array, limit: number): Uint8Array {
|
||||||
|
const nonce = new Uint8Array(12);
|
||||||
|
const parts: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let pos = 0;
|
||||||
|
let after: 'eof' | 'trailing' | undefined;
|
||||||
|
try {
|
||||||
|
while (total < limit) {
|
||||||
|
if (after === 'eof') break;
|
||||||
|
if (after === 'trailing') throw new AgeError('trailing data after end of encrypted file');
|
||||||
|
// readChunk.
|
||||||
|
const rem = data.length - pos;
|
||||||
|
if (rem === 0) throw new AgeError('unexpected EOF');
|
||||||
|
let last = false;
|
||||||
|
let n = ENC_CHUNK;
|
||||||
|
if (rem < ENC_CHUNK) {
|
||||||
|
// The last chunk can be short, but not empty unless it is the first
|
||||||
|
// and only one.
|
||||||
|
if (!nonce.every((b) => b === 0) && rem === TAG) {
|
||||||
|
throw new AgeError('last chunk is empty, try age v1.0.0, and please consider reporting this');
|
||||||
|
}
|
||||||
|
n = rem;
|
||||||
|
last = true;
|
||||||
|
nonce[11] = 1;
|
||||||
|
}
|
||||||
|
const input = data.subarray(pos, pos + n);
|
||||||
|
pos += n;
|
||||||
|
let out = open(key, nonce, input);
|
||||||
|
if (out === undefined && !last) {
|
||||||
|
// A full-length last chunk.
|
||||||
|
last = true;
|
||||||
|
nonce[11] = 1;
|
||||||
|
out = open(key, nonce, input);
|
||||||
|
}
|
||||||
|
if (out === undefined) throw new AgeError('failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with');
|
||||||
|
incNonce(nonce);
|
||||||
|
const take = Math.min(out.length, limit - total);
|
||||||
|
parts.push(take === out.length ? out : out.slice(0, take));
|
||||||
|
if (take !== out.length) out.fill(0);
|
||||||
|
total += take;
|
||||||
|
if (last) after = pos < data.length ? 'trailing' : 'eof';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
for (const p of parts) p.fill(0);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const plain = new Uint8Array(total);
|
||||||
|
let at = 0;
|
||||||
|
for (const p of parts) {
|
||||||
|
plain.set(p, at);
|
||||||
|
at += p.length;
|
||||||
|
p.fill(0);
|
||||||
|
}
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(key: Uint8Array, nonce: Uint8Array, input: Uint8Array): Uint8Array | undefined {
|
||||||
|
try {
|
||||||
|
return chacha20poly1305(key, nonce).decrypt(input);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 11-byte big-endian counter of the nonce, plus one. A file of 2^88
|
||||||
|
// chunks does not exist.
|
||||||
|
function incNonce(nonce: Uint8Array): void {
|
||||||
|
for (let i = 10; i >= 0; i--) {
|
||||||
|
nonce[i] = (nonce[i]! + 1) & 0xff;
|
||||||
|
if (nonce[i] !== 0) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Encryption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* age.Encrypt of `plaintext` for one recipient, `wrap`, which returns its
|
||||||
|
* stanzas for a file key: the file key, 16 bytes drawn from `random`; the
|
||||||
|
* stanzas, which draw what the recipient draws; the header MAC; the nonce,
|
||||||
|
* 16 more bytes; and the STREAM, in chunks of 64 KiB, the last one marked,
|
||||||
|
* an empty one only for an empty plaintext. With the same values, it writes
|
||||||
|
* the bytes of Go.
|
||||||
|
*/
|
||||||
|
export function ageEncrypt(plaintext: Uint8Array, wrap: (fileKey: Uint8Array) => readonly Stanza[], random: RandomFill): Uint8Array {
|
||||||
|
const fileKey = draw(random, FILE_KEY_LEN);
|
||||||
|
try {
|
||||||
|
const stanzas = wrap(fileKey);
|
||||||
|
const mac = headerMac(fileKey, stanzas);
|
||||||
|
const head = new TextEncoder().encode(`${new TextDecoder().decode(headerWithoutMac(stanzas))} ${b64Encode(mac)}\n`);
|
||||||
|
const nonce = draw(random, NONCE_LEN);
|
||||||
|
const key = streamKey(fileKey, nonce);
|
||||||
|
const chunks = Math.max(1, Math.ceil(plaintext.length / CHUNK));
|
||||||
|
const out = new Uint8Array(head.length + NONCE_LEN + plaintext.length + chunks * TAG);
|
||||||
|
out.set(head);
|
||||||
|
out.set(nonce, head.length);
|
||||||
|
let at = head.length + NONCE_LEN;
|
||||||
|
const counter = new Uint8Array(12);
|
||||||
|
for (let i = 0; i < chunks; i++) {
|
||||||
|
if (i === chunks - 1) counter[11] = 1;
|
||||||
|
const ct = chacha20poly1305(key, counter).encrypt(plaintext.subarray(i * CHUNK, Math.min((i + 1) * CHUNK, plaintext.length)));
|
||||||
|
out.set(ct, at);
|
||||||
|
at += ct.length;
|
||||||
|
incNonce(counter);
|
||||||
|
}
|
||||||
|
key.fill(0);
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
fileKey.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,391 @@
|
|||||||
|
// The cryptography of the locator against Go (spec §44.1):
|
||||||
|
//
|
||||||
|
// - testing/locator-seal.json, from scripts/locator-seal-go-vectors.go,
|
||||||
|
// holds what Seal and NewEnvelope of Go write while crypto/rand reads the
|
||||||
|
// keystream of a seed. With the same seed, seal and newEnvelope draw the
|
||||||
|
// same values in the same order and write the same bytes; the sealed
|
||||||
|
// locators open with the release of their round, and the envelopes with
|
||||||
|
// their locator. seal refuses what Go refuses, with its text, before
|
||||||
|
// drawing anything.
|
||||||
|
// - testing/locator-vectors.json, from scripts/locator-go-vectors.go: Open
|
||||||
|
// on sealed locators of four rounds, with other releases and profiles and
|
||||||
|
// edited files; Open of files whose plaintext passes 1 MiB, which Go reads
|
||||||
|
// through io.LimitReader; OpenEnvelope on envelopes and rests, valid and
|
||||||
|
// edited; the split of NewEnvelope; Info.OpenLocator. Each with the text
|
||||||
|
// of Go.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
|
||||||
|
import { hkdf } from '@noble/hashes/hkdf.js';
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { cryptoFill, ageDecrypt, ageEncrypt, b64Encode, wrapX25519, x25519Identity, x25519Recipient } from './ageio.ts';
|
||||||
|
import { fromHex, toHex } from './bytes.ts';
|
||||||
|
import { DateKeysError } from './errors.ts';
|
||||||
|
import { headerEnd, newEnvelope, openEnvelope, openInfoLocator, openSealed, seal } from './envelope.ts';
|
||||||
|
import { hide, type Locator, LocatorError, marshalLocator, restIn, unmarshalLocator, wipeLocator } from './locator.ts';
|
||||||
|
import { defaultRegistry, quicknet } from './profile.ts';
|
||||||
|
import {
|
||||||
|
applyEdits,
|
||||||
|
errorText,
|
||||||
|
expandSummary,
|
||||||
|
type Json,
|
||||||
|
openRelease,
|
||||||
|
pattern,
|
||||||
|
profileOf,
|
||||||
|
readVectors,
|
||||||
|
releaseOf,
|
||||||
|
rows,
|
||||||
|
sha256Hex,
|
||||||
|
summaryOf,
|
||||||
|
textOf,
|
||||||
|
} from './testing/locator.ts';
|
||||||
|
import { seededFill } from './testing/seeded.ts';
|
||||||
|
|
||||||
|
const sealVectors = readVectors('locator-seal.json');
|
||||||
|
const v = readVectors('locator-vectors.json');
|
||||||
|
const q = quicknet();
|
||||||
|
|
||||||
|
const list = (x: unknown): Json[] => x as Json[];
|
||||||
|
|
||||||
|
function locatorOf(j: Json): Locator {
|
||||||
|
return {
|
||||||
|
addresses: list(j.addresses).map((a) => ({ uri: a.uri as string, offset: a.offset as number })),
|
||||||
|
envelopeKey: fromHex(j.envelope_key as string),
|
||||||
|
envelopeHeader: fromHex(j.envelope_header as string),
|
||||||
|
restDigest: fromHex(j.rest_digest as string),
|
||||||
|
restSize: j.rest_size as number,
|
||||||
|
capsuleDigest: fromHex(j.capsule_digest as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonOf = (l: Locator): Json => ({
|
||||||
|
addresses: l.addresses.map((a) => ({ uri: a.uri, offset: a.offset })),
|
||||||
|
envelope_key: toHex(l.envelopeKey),
|
||||||
|
envelope_header: toHex(l.envelopeHeader),
|
||||||
|
rest_digest: toHex(l.restDigest),
|
||||||
|
rest_size: l.restSize,
|
||||||
|
capsule_digest: toHex(l.capsuleDigest),
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectFile(b: Uint8Array, want: Json): void {
|
||||||
|
expect(b.length).toBe(want.length);
|
||||||
|
expect(sha256Hex(b)).toBe(want.sha256);
|
||||||
|
if (want.hex !== undefined) expect(toHex(b)).toBe(want.hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawsOf = (draws: readonly Uint8Array[]): Json[] => draws.map((d) => ({ n: d.length, hex: toHex(d) }));
|
||||||
|
const sealRelease = (round: number) => releaseOf(sealVectors, round);
|
||||||
|
|
||||||
|
describe('seal and newEnvelope write the bytes of Go (locator-seal.json)', () => {
|
||||||
|
it('a seed gives the keystream of ChaCha20 that Go reads', () => {
|
||||||
|
// RFC 8439, 2.4.2: the keystream of a known key, nonce and counter is
|
||||||
|
// checked by the vectors themselves; here, two draws continue one
|
||||||
|
// stream.
|
||||||
|
const a = seededFill('x');
|
||||||
|
const one = new Uint8Array(100);
|
||||||
|
a(one);
|
||||||
|
const b = seededFill('x');
|
||||||
|
const x = new Uint8Array(37);
|
||||||
|
const y = new Uint8Array(63);
|
||||||
|
b(x);
|
||||||
|
b(y);
|
||||||
|
expect(toHex(one)).toBe(toHex(x) + toHex(y));
|
||||||
|
expect(b.draws.map((d) => d.length)).toEqual([37, 63]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const c of list(sealVectors.seal)) {
|
||||||
|
it(`Seal: ${c.name as string}`, () => {
|
||||||
|
const round = c.round as number;
|
||||||
|
const loc = locatorOf(c.locator as Json);
|
||||||
|
expect(marshalLocator(loc).length).toBe(c.plaintext_length);
|
||||||
|
const r = seededFill(c.seed as string);
|
||||||
|
const sealed = seal(q, round, loc, r);
|
||||||
|
expectFile(sealed, c.sealed as Json);
|
||||||
|
expect(drawsOf(r.draws)).toEqual(c.draws);
|
||||||
|
if (c.opens === true) {
|
||||||
|
expect(jsonOf(openSealed(q, round, sealRelease(round), sealed))).toEqual(c.locator);
|
||||||
|
// Another round does not open it.
|
||||||
|
const other = round === 1000 ? 1001 : 1000;
|
||||||
|
expect(() => openSealed(q, other, sealRelease(other), sealed)).toThrow(LocatorError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Seal refuses what Go refuses, with its text and its code, before drawing anything', () => {
|
||||||
|
const cases = list(sealVectors.seal_errors);
|
||||||
|
expect(cases.length).toBe(12);
|
||||||
|
for (const c of cases) {
|
||||||
|
const r = seededFill('unused');
|
||||||
|
let err: unknown;
|
||||||
|
try {
|
||||||
|
seal(q, c.round as number, locatorOf(c.locator as Json), r);
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
expect((err as Error).message, c.name as string).toBe(c.error);
|
||||||
|
// A round out of range is the error of NewTimeRecipient, with its
|
||||||
|
// code; a locator that Marshal refuses has none.
|
||||||
|
expect(err instanceof DateKeysError, c.name as string).toBe((c.error as string).startsWith('agewrap: '));
|
||||||
|
expect(r.draws.length).toBe(c.draws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const c of list(sealVectors.envelope)) {
|
||||||
|
it(`NewEnvelope of ${c.dkc_length as number} bytes`, () => {
|
||||||
|
const dkc = pattern(c.dkc_length as number);
|
||||||
|
const r = seededFill(c.seed as string);
|
||||||
|
const e = newEnvelope(dkc, r);
|
||||||
|
expect(jsonOf(e.locator)).toEqual(c.locator);
|
||||||
|
expectFile(e.rest, c.rest as Json);
|
||||||
|
expect(drawsOf(r.draws)).toEqual(c.draws);
|
||||||
|
expect(openEnvelope(e.locator, e.rest)).toEqual(dkc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a writer and a reader, as Go: envelope, addresses, seal, open', () => {
|
||||||
|
const c = sealVectors.flow as Json;
|
||||||
|
const dkc = pattern(c.dkc_length as number);
|
||||||
|
const r = seededFill(c.seed as string);
|
||||||
|
const e = newEnvelope(dkc, r);
|
||||||
|
const hidden = hide(fromHex(c.host as string), e.rest);
|
||||||
|
const loc: Locator = {
|
||||||
|
...e.locator,
|
||||||
|
addresses: [{ uri: 'https://example.com/capsule', offset: 0 }, { uri: 'https://example.org/host.gif', offset: hidden.offset }],
|
||||||
|
};
|
||||||
|
const round = c.round as number;
|
||||||
|
const sealed = seal(q, round, loc, r);
|
||||||
|
expect(drawsOf(r.draws)).toEqual(c.draws);
|
||||||
|
expect(jsonOf(loc)).toEqual(c.locator);
|
||||||
|
expectFile(e.rest, c.rest as Json);
|
||||||
|
expectFile(sealed, c.sealed as Json);
|
||||||
|
const got = openSealed(q, round, sealRelease(round), sealed);
|
||||||
|
expect(openEnvelope(got, restIn(got, hidden.file, got.addresses[1]!.offset))).toEqual(dkc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sealing leaves the locator as it was, and wipes nothing of it', () => {
|
||||||
|
const loc = locatorOf(list(sealVectors.seal)[0]!.locator as Json);
|
||||||
|
const before = marshalLocator(loc);
|
||||||
|
seal(q, 1000, loc, seededFill('as it was'));
|
||||||
|
expect(marshalLocator(loc)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the default source is crypto.getRandomValues: two seals differ and both open', () => {
|
||||||
|
const loc = locatorOf(list(sealVectors.seal)[0]!.locator as Json);
|
||||||
|
const a = seal(q, 1000, loc);
|
||||||
|
const b = seal(q, 1000, loc);
|
||||||
|
expect(toHex(a)).not.toBe(toHex(b));
|
||||||
|
expect(jsonOf(openSealed(q, 1000, sealRelease(1000), b))).toEqual(jsonOf(loc));
|
||||||
|
const e = newEnvelope(pattern(10));
|
||||||
|
expect(openEnvelope(e.locator, e.rest)).toEqual(pattern(10));
|
||||||
|
const f = new Uint8Array(8);
|
||||||
|
cryptoFill(f);
|
||||||
|
expect(f.some((x) => x !== 0) || f.every((x) => x === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the opening against Go (locator-vectors.json)', () => {
|
||||||
|
const envelope = v.envelope as Json;
|
||||||
|
const plainBases = (v.plaintext_bases as string[]).map(fromHex);
|
||||||
|
const sealedBases = list(v.sealed_bases).map((s) => fromHex(s.sealed as string));
|
||||||
|
|
||||||
|
function base(over: Partial<Locator> = {}): Locator {
|
||||||
|
return {
|
||||||
|
addresses: [{ uri: 'https://ejemplo.org/foto.jpg', offset: 3000 }],
|
||||||
|
envelopeKey: fromHex(envelope.key as string),
|
||||||
|
envelopeHeader: fromHex(envelope.header as string),
|
||||||
|
restDigest: fromHex(envelope.rest_digest as string),
|
||||||
|
restSize: envelope.rest_size as number,
|
||||||
|
capsuleDigest: fromHex(envelope.capsule_digest as string),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('openSealed opens a sealed locator with the release of its round, as Open', () => {
|
||||||
|
const cases = rows(v, 'open');
|
||||||
|
expect(cases.length).toBe(118);
|
||||||
|
for (const c of cases) {
|
||||||
|
const round = c[1] as number;
|
||||||
|
const b = applyEdits(sealedBases[c[0] as number]!, c[4]);
|
||||||
|
let l: Locator | undefined;
|
||||||
|
expect(errorText(() => (l = openSealed(profileOf(c[3] as string), round, openRelease(v, round, c[2]), b))), JSON.stringify(c.slice(0, 4))).toBe(
|
||||||
|
textOf(v, c[5]),
|
||||||
|
);
|
||||||
|
if (c[6] !== null) expect(summaryOf(l!)).toEqual(expandSummary(c[6]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openSealed reads at most 1 MiB of plaintext, as Go through io.LimitReader: what follows is neither decrypted nor checked', () => {
|
||||||
|
const lim = v.limit as Json;
|
||||||
|
const header = fromHex(lim.header as string);
|
||||||
|
const nonce = fromHex(lim.nonce as string);
|
||||||
|
const streamKey = hkdf(sha256, fromHex(lim.file_key as string), nonce, new TextEncoder().encode('payload'), 32);
|
||||||
|
expect(toHex(streamKey)).toBe(lim.stream_key);
|
||||||
|
const plain = plainBases[0]!;
|
||||||
|
const release = releaseOf(v, lim.round as number);
|
||||||
|
const cases = list(lim.cases);
|
||||||
|
expect(cases.length).toBe(14);
|
||||||
|
for (const c of cases) {
|
||||||
|
const chunks = c.chunks as [number, boolean, boolean][];
|
||||||
|
const total = chunks.reduce((n, ch) => n + ch[0], 0);
|
||||||
|
const content = new Uint8Array(total);
|
||||||
|
content.set(plain.subarray(0, Math.min(plain.length, total)));
|
||||||
|
const parts: Uint8Array[] = [header, nonce];
|
||||||
|
let at = 0;
|
||||||
|
for (const [i, [n, last, corrupt]] of chunks.entries()) {
|
||||||
|
const chunkNonce = new Uint8Array(12);
|
||||||
|
new DataView(chunkNonce.buffer).setUint32(7, i);
|
||||||
|
chunkNonce[11] = last ? 1 : 0;
|
||||||
|
const ct = chacha20poly1305(streamKey, chunkNonce).encrypt(content.subarray(at, at + n));
|
||||||
|
if (corrupt) ct[0]! ^= 1;
|
||||||
|
parts.push(ct);
|
||||||
|
at += n;
|
||||||
|
}
|
||||||
|
parts.push(new Uint8Array(c.trailing as number));
|
||||||
|
const file = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
||||||
|
let o = 0;
|
||||||
|
for (const p of parts) {
|
||||||
|
file.set(p, o);
|
||||||
|
o += p.length;
|
||||||
|
}
|
||||||
|
expect([file.length, sha256Hex(file)]).toEqual([c.length, c.sha256]);
|
||||||
|
expect(errorText(() => openSealed(q, 1000, release, file)), c.name as string).toBe(textOf(v, c.text));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openInfoLocator opens the locator of an extension with the default registry, as Info.OpenLocator', async () => {
|
||||||
|
const reg = await defaultRegistry();
|
||||||
|
const cases = rows(v, 'open_info');
|
||||||
|
expect(cases.length).toBe(7);
|
||||||
|
for (const c of cases) {
|
||||||
|
const s = c[2];
|
||||||
|
const info = { note: '', dateKey: { profileId: c[0] as string, round: c[1] as number }, sealed: s === null ? undefined : sealedBases[s as number] };
|
||||||
|
let l: Locator | undefined;
|
||||||
|
expect(errorText(() => (l = openInfoLocator(info, reg, releaseOf(v, c[3] as number)))), JSON.stringify(c.slice(0, 4))).toBe(textOf(v, c[4]));
|
||||||
|
if (c[5] !== null) expect(summaryOf(l!)).toEqual(expandSummary(c[5]));
|
||||||
|
}
|
||||||
|
// Go's nil registry.
|
||||||
|
const info = { note: '', dateKey: { profileId: 'datekeys:quicknet:v1', round: 1000 }, sealed: sealedBases[0] };
|
||||||
|
expect(errorText(() => openInfoLocator(info, undefined, releaseOf(v, 1000)))).toBe('locator: no registry of pinned profiles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openEnvelope checks the rest and the .dkc as OpenEnvelope', () => {
|
||||||
|
const rest = fromHex(envelope.rest as string);
|
||||||
|
const header = fromHex(envelope.header as string);
|
||||||
|
const cases = rows(v, 'envelopes');
|
||||||
|
expect(cases.length).toBe(34);
|
||||||
|
for (const c of cases) {
|
||||||
|
const l = base({
|
||||||
|
...(c[1] === '' ? {} : { envelopeKey: fromHex(c[1] as string) }),
|
||||||
|
envelopeHeader: applyEdits(header, c[2]),
|
||||||
|
...(c[3] === '' ? {} : { restDigest: fromHex(c[3] as string) }),
|
||||||
|
restSize: c[4] as number,
|
||||||
|
...(c[5] === '' ? {} : { capsuleDigest: fromHex(c[5] as string) }),
|
||||||
|
});
|
||||||
|
let dkc: Uint8Array | undefined;
|
||||||
|
const r = applyEdits(rest, c[6]);
|
||||||
|
expect(errorText(() => (dkc = openEnvelope(l, r))), c[0] as string).toBe(textOf(v, c[7]));
|
||||||
|
if (dkc !== undefined) {
|
||||||
|
expect(sha256Hex(dkc)).toBe(c[8]);
|
||||||
|
expect(toHex(dkc)).toBe(envelope.dkc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('headerEnd splits an age file where NewEnvelope does', () => {
|
||||||
|
const dkc = fromHex(envelope.dkc as string);
|
||||||
|
const cases = rows(v, 'split');
|
||||||
|
expect(cases.length).toBe(11);
|
||||||
|
for (const c of cases) {
|
||||||
|
const file = fromHex(c[0] as string);
|
||||||
|
let end: number | undefined;
|
||||||
|
expect(errorText(() => (end = headerEnd(file)))).toBe(textOf(v, c[1]));
|
||||||
|
if (end !== undefined) expect(end).toBe(c[2]);
|
||||||
|
}
|
||||||
|
// The envelope of NewEnvelope: its header and rest are those of the vectors.
|
||||||
|
const file = fromHex(cases[0]![0] as string);
|
||||||
|
const end = headerEnd(file);
|
||||||
|
expect(toHex(file.subarray(0, end))).toBe(envelope.header);
|
||||||
|
expect(toHex(file.subarray(end))).toBe(envelope.rest);
|
||||||
|
expect(openEnvelope(base(), file.slice(end))).toEqual(dkc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a locator without addresses, from newEnvelope, does not marshal, and wipeLocator clears its key', () => {
|
||||||
|
const e = newEnvelope(pattern(3), seededFill('wipe'));
|
||||||
|
expect(errorText(() => marshalLocator(e.locator))).toBe('locator: 0 addresses, not 1 to 8');
|
||||||
|
wipeLocator(e.locator);
|
||||||
|
expect(e.locator.envelopeKey.every((b) => b === 0)).toBe(true);
|
||||||
|
expect(errorText(() => openEnvelope(e.locator, e.rest))).toMatch(/^locator: the envelope: identity did not match any of the recipients/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openSealed fails with the text of a profile of another scheme, which this library does not verify', () => {
|
||||||
|
const p = { ...q, scheme: 'pedersen-bls-chained' };
|
||||||
|
expect(errorText(() => openSealed(p, 1000, releaseOf(v, 1000), sealedBases[0]!))).toBe(
|
||||||
|
'locator: agewrap: profile datekeys:quicknet:v1 uses scheme pedersen-bls-chained; only bls-unchained-g1-rfc9380 is supported here: ERR_UNKNOWN_PROFILE',
|
||||||
|
);
|
||||||
|
const loc = unmarshalLocator(plainBases[0]!);
|
||||||
|
expect(() => seal(p, 1000, loc, seededFill('s'))).toThrow(DateKeysError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the age files of the locator (ageio.ts)', () => {
|
||||||
|
it('ageEncrypt and ageDecrypt for an X25519 identity, of every length around a chunk', () => {
|
||||||
|
const r = seededFill('ageio');
|
||||||
|
const secret = new Uint8Array(32);
|
||||||
|
r(secret);
|
||||||
|
const pub = x25519Recipient(secret);
|
||||||
|
for (const n of [0, 1, 65535, 65536, 65537, 131072]) {
|
||||||
|
const plain = pattern(n);
|
||||||
|
const file = ageEncrypt(plain, (fk) => [wrapX25519(pub, fk, r)], r);
|
||||||
|
const id = x25519Identity(secret);
|
||||||
|
expect(ageDecrypt(file, id)).toEqual(plain);
|
||||||
|
// Cut at the limit, as io.LimitReader.
|
||||||
|
expect(ageDecrypt(file, id, 5)).toEqual(plain.subarray(0, Math.min(5, n)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the stanzas of X25519: another type and another recipient are for another identity, a malformed one fails with the text of age', () => {
|
||||||
|
const r = seededFill('stanzas');
|
||||||
|
const secret = new Uint8Array(32);
|
||||||
|
r(secret);
|
||||||
|
const pub = x25519Recipient(secret);
|
||||||
|
const plain = pattern(10);
|
||||||
|
// Stanzas of another type, with bodies whose Base64 ends in a short
|
||||||
|
// line, in a full one and in an empty one.
|
||||||
|
const others = [3, 5, 48, 0].map((n) => ({ type: 'other', args: ['a'], body: new Uint8Array(n).fill(n) }));
|
||||||
|
const file = ageEncrypt(plain, (fk) => [...others, wrapX25519(pub, fk, r)], r);
|
||||||
|
const head = new TextDecoder().decode(file.subarray(0, headerEnd(file)));
|
||||||
|
expect(head).toContain('-> other a\nAwMD\n-> other a\nBQUFBQU\n-> other a\n' + 'MDAw'.repeat(16) + '\n\n-> other a\n\n-> X25519 ');
|
||||||
|
expect(ageDecrypt(file, x25519Identity(secret))).toEqual(plain);
|
||||||
|
const text = (stanza: { type: string; args: string[]; body: Uint8Array }): string =>
|
||||||
|
errorText(() => ageDecrypt(ageEncrypt(plain, () => [stanza], r), x25519Identity(secret)));
|
||||||
|
const share = b64Encode(x25519Recipient(new Uint8Array(32).fill(7)));
|
||||||
|
expect(text({ type: 'X25519', args: [], body: new Uint8Array(32) })).toBe('invalid X25519 recipient block');
|
||||||
|
expect(text({ type: 'X25519', args: ['A*'], body: new Uint8Array(32) })).toBe('failed to parse X25519 recipient: illegal base64 data at input byte 1');
|
||||||
|
expect(text({ type: 'X25519', args: ['AAAA'], body: new Uint8Array(32) })).toBe('invalid X25519 recipient block');
|
||||||
|
expect(text({ type: 'X25519', args: [b64Encode(new Uint8Array(32))], body: new Uint8Array(32) })).toBe(
|
||||||
|
'invalid X25519 recipient: crypto/ecdh: bad X25519 remote ECDH input: low order point',
|
||||||
|
);
|
||||||
|
expect(text({ type: 'X25519', args: [share], body: new Uint8Array(31) })).toBe('invalid X25519 recipient block: incorrect file key size');
|
||||||
|
expect(text({ type: 'X25519', args: [share], body: new Uint8Array(32) })).toBe(
|
||||||
|
'identity did not match any of the recipients: incorrect identity for recipient block',
|
||||||
|
);
|
||||||
|
expect(() => x25519Identity(new Uint8Array(31))).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a header that is not one, a bad MAC and a short nonce, with the texts of age', () => {
|
||||||
|
const r = seededFill('header');
|
||||||
|
const secret = new Uint8Array(32);
|
||||||
|
r(secret);
|
||||||
|
const pub = x25519Recipient(secret);
|
||||||
|
const file = ageEncrypt(pattern(3), (fk) => [wrapX25519(pub, fk, r)], r);
|
||||||
|
const id = x25519Identity(secret);
|
||||||
|
expect(errorText(() => ageDecrypt(new Uint8Array(0), id))).toBe('failed to read header: parsing age header: file is empty');
|
||||||
|
const end = headerEnd(file);
|
||||||
|
const bad = file.slice();
|
||||||
|
bad[end - 3]! ^= 1;
|
||||||
|
expect(errorText(() => ageDecrypt(bad, id))).toBe('bad header MAC');
|
||||||
|
expect(errorText(() => ageDecrypt(file.subarray(0, end), id))).toBe('failed to read nonce: EOF');
|
||||||
|
expect(errorText(() => ageDecrypt(file.subarray(0, end + 3), id))).toBe('failed to read nonce: unexpected EOF');
|
||||||
|
expect(errorText(() => ageDecrypt(file.subarray(0, end + 16), id))).toBe('unexpected EOF');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,267 @@
|
|||||||
|
// The cryptography of the locator of datekeys.capsule (spec §44.1), as Open,
|
||||||
|
// Info.OpenLocator, OpenEnvelope, Seal and NewEnvelope of package locator of
|
||||||
|
// datekeys-go at the tag spec-v0.12, with the same checks in the same order
|
||||||
|
// and the same texts:
|
||||||
|
//
|
||||||
|
// - openSealed opens a sealed locator with the release of its round, and
|
||||||
|
// openInfoLocator the one of an extension, in the profile its DateKey
|
||||||
|
// names;
|
||||||
|
// - openEnvelope joins the header of a locator and the rest that a reader
|
||||||
|
// fetched, checks both digests and decrypts the .dkc;
|
||||||
|
// - seal seals a locator with tlock for a round, and newEnvelope encrypts a
|
||||||
|
// .dkc for a new X25519 identity, I_SOBRE, and splits the age file into
|
||||||
|
// the header, which the locator carries, and the rest.
|
||||||
|
//
|
||||||
|
// The age files are read and written by ageio.ts, with the texts of age.
|
||||||
|
// Every random value of seal and newEnvelope comes from the RandomFill
|
||||||
|
// given, crypto.getRandomValues by default, in the order of Go: with the
|
||||||
|
// same values, they write the bytes of Go. The tlock encryption is not
|
||||||
|
// constant time (ibe.ts).
|
||||||
|
//
|
||||||
|
// Internal: index.ts does not re-export it.
|
||||||
|
|
||||||
|
import { checkTimeStanzas, type Stanza } from './age.ts';
|
||||||
|
import { AgeError, ageDecrypt, ageEncrypt, type AgeIdentity, cryptoFill, draw, type RandomFill, wrapX25519, x25519Identity, x25519Recipient } from './ageio.ts';
|
||||||
|
import { checkCompressedPoint } from './bls12381.ts';
|
||||||
|
import { equalBytes } from './bytes.ts';
|
||||||
|
import { sha256Hasher } from './digest.ts';
|
||||||
|
import { DateKeysError } from './errors.ts';
|
||||||
|
import { ciphertextFromBody, ciphertextToBody, decryptOnG2, encryptOnG2WithSigma, IbeError, roundIdentity, TLOCK_BODY_LEN } from './ibe.ts';
|
||||||
|
import { type Info, type Locator, LocatorError, MAX_SEALED, marshalLocator, unmarshalLocator } from './locator.ts';
|
||||||
|
import { chainHashHex, maxRound, type Profile, type ProfileRegistry, QUICKNET_SCHEME } from './profile.ts';
|
||||||
|
import { type Release, verifyRelease } from './release.ts';
|
||||||
|
|
||||||
|
export { cryptoFill, type RandomFill } from './ageio.ts';
|
||||||
|
|
||||||
|
const fail = (detail: string): LocatorError => new LocatorError(`locator: ${detail}`);
|
||||||
|
|
||||||
|
const sha256 = (b: Uint8Array): Uint8Array => {
|
||||||
|
const h = sha256Hasher();
|
||||||
|
h.update(b);
|
||||||
|
return h.digest();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Go's pinned of agewrap for the scheme of Quicknet, the only one this
|
||||||
|
// library verifies (decision 3 of the plan of phase 2), with the texts of
|
||||||
|
// tlock.ts: the profile, then its public key.
|
||||||
|
function pinned(p: Profile): void {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go's agewrap.TimeIdentity once pinned(p) passed: the complete stanza set
|
||||||
|
// and its arguments, the release, the length of the body, U and then the
|
||||||
|
// IBE, each failure with its normative code and its text.
|
||||||
|
function timeIdentity(p: Profile, round: number, release: Release): AgeIdentity {
|
||||||
|
return {
|
||||||
|
unwrap(stanzas: readonly Stanza[]): Uint8Array {
|
||||||
|
checkTimeStanzas(stanzas, p, round);
|
||||||
|
verifyRelease(p, round, release);
|
||||||
|
const body = stanzas[0]!.body;
|
||||||
|
if (body.length !== TLOCK_BODY_LEN) {
|
||||||
|
throw new DateKeysError('ERR_INTEGRITY', `agewrap: tlock stanza body of ${body.length} bytes, want ${TLOCK_BODY_LEN}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decryptOnG2(release.signature, ciphertextFromBody(body));
|
||||||
|
} catch (err) {
|
||||||
|
/* v8 ignore next -- @preserve: every failure of the IBE is an IbeError */
|
||||||
|
if (!(err instanceof IbeError)) throw err;
|
||||||
|
if (err.reason === 'encoding') {
|
||||||
|
throw new DateKeysError('ERR_INTEGRITY', 'agewrap: U of the tlock stanza is not the canonical encoding of a point of the key group');
|
||||||
|
}
|
||||||
|
if (err.reason === 'identity') throw new DateKeysError('ERR_INTEGRITY', 'agewrap: U of the tlock stanza is the point at infinity');
|
||||||
|
throw new DateKeysError('ERR_INTEGRITY', 'agewrap: the tlock stanza body does not decrypt under the verified release (IBE check r·G == U)');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The text of a failure of age or of an identity, which Go's locator copies
|
||||||
|
// with %v; anything else is a bug and propagates.
|
||||||
|
function textOf(err: unknown): string {
|
||||||
|
/* v8 ignore next -- @preserve: age and the identities throw nothing else */
|
||||||
|
if (!(err instanceof AgeError || err instanceof DateKeysError)) throw err;
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the sealed locator `sealed` with `release`, the release of its
|
||||||
|
* `round` in the pinned profile `p`, and reads its plaintext, as Open of Go.
|
||||||
|
* A locator for another round or another chain does not open: it is
|
||||||
|
* unusable (spec §44.1). Its errors carry no normative code: a LocatorError,
|
||||||
|
* whose text is that of Go, the texts of the checks of the profile, the
|
||||||
|
* stanza, the release and age included.
|
||||||
|
*
|
||||||
|
* As Go, it reads at most 1 MiB of plaintext, through io.LimitReader: what
|
||||||
|
* follows is neither decrypted nor checked, and the plaintext read is then
|
||||||
|
* not the length of a locator.
|
||||||
|
*/
|
||||||
|
export function openSealed(p: Profile, round: number, release: Release, sealed: Uint8Array): Locator {
|
||||||
|
try {
|
||||||
|
pinned(p);
|
||||||
|
} catch (err) {
|
||||||
|
throw fail(textOf(err));
|
||||||
|
}
|
||||||
|
let plain: Uint8Array;
|
||||||
|
try {
|
||||||
|
plain = ageDecrypt(sealed, timeIdentity(p, round, release), MAX_SEALED);
|
||||||
|
} catch (err) {
|
||||||
|
throw fail(textOf(err));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return unmarshalLocator(plain);
|
||||||
|
} finally {
|
||||||
|
plain.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locator of the extension `info`, opened with `release`, the release of
|
||||||
|
* the round of its own DateKey, in the profile of `reg` that the DateKey
|
||||||
|
* names, as Info.OpenLocator of Go: a locator for another round or another
|
||||||
|
* chain does not open, and is unusable (spec §44.1). Throws a LocatorError,
|
||||||
|
* also when the extension has no locator or there is no registry.
|
||||||
|
*/
|
||||||
|
export function openInfoLocator(info: Info, reg: ProfileRegistry | undefined, release: Release): Locator {
|
||||||
|
if (info.sealed === undefined) throw fail('the extension has no locator');
|
||||||
|
if (reg === undefined) throw fail('no registry of pinned profiles');
|
||||||
|
const p = reg.lookup(info.dateKey.profileId);
|
||||||
|
if (p === undefined) throw fail('the profile of the DateKey is not pinned');
|
||||||
|
return openSealed(p, info.dateKey.round, release, info.sealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the header of the locator `l` and `rest`, which a reader got from an
|
||||||
|
* address, and decrypts the .dkc, as OpenEnvelope of Go. It checks the size
|
||||||
|
* and the SHA-256 of the rest, and the SHA-256 of the .dkc, before the
|
||||||
|
* caller uses it (spec §44.1): they protect against whoever stores the rest,
|
||||||
|
* not against whoever wrote the .dkk. Throws a LocatorError.
|
||||||
|
*/
|
||||||
|
export function openEnvelope(l: Locator, rest: Uint8Array): Uint8Array {
|
||||||
|
if (rest.length !== l.restSize) throw fail(`the rest is ${rest.length} bytes, not ${l.restSize}`);
|
||||||
|
if (!equalBytes(sha256(rest), l.restDigest)) throw fail('the SHA-256 of the rest is not the one of the locator');
|
||||||
|
const id = x25519Identity(l.envelopeKey);
|
||||||
|
const file = new Uint8Array(l.envelopeHeader.length + rest.length);
|
||||||
|
file.set(l.envelopeHeader);
|
||||||
|
file.set(rest, l.envelopeHeader.length);
|
||||||
|
let dkc: Uint8Array;
|
||||||
|
try {
|
||||||
|
dkc = ageDecrypt(file, id);
|
||||||
|
} catch (err) {
|
||||||
|
throw fail(`the envelope: ${textOf(err)}`);
|
||||||
|
} finally {
|
||||||
|
id.wipe();
|
||||||
|
}
|
||||||
|
if (!equalBytes(sha256(dkc), l.capsuleDigest)) {
|
||||||
|
dkc.fill(0);
|
||||||
|
throw fail('the SHA-256 of the .dkc is not the capsule_digest of the locator');
|
||||||
|
}
|
||||||
|
return dkc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go's NewTimeRecipient: the profile, then the range of the round, with
|
||||||
|
// their codes.
|
||||||
|
function checkTimeRecipient(p: Profile, round: number): void {
|
||||||
|
pinned(p);
|
||||||
|
if (!Number.isSafeInteger(round) || round < 1 || round > maxRound(p)) {
|
||||||
|
throw new DateKeysError('ERR_DATEKEY_INVALID', `agewrap: round ${round} outside the range of ${p.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go's TimeRecipient.WrapWithLabels: tlock.TimeLock with a sigma of the
|
||||||
|
// length of the file key, then the random label of 16 bytes that makes age
|
||||||
|
// refuse another recipient in the file; age-encryption writes no labels, and
|
||||||
|
// this writer has one recipient, so the label is drawn and dropped.
|
||||||
|
function wrapTlock(p: Profile, round: number, fileKey: Uint8Array, random: RandomFill): Stanza {
|
||||||
|
const sigma = draw(random, fileKey.length);
|
||||||
|
let body: Uint8Array;
|
||||||
|
try {
|
||||||
|
body = ciphertextToBody(encryptOnG2WithSigma(p.publicKey, roundIdentity(round), fileKey, sigma));
|
||||||
|
} finally {
|
||||||
|
sigma.fill(0);
|
||||||
|
}
|
||||||
|
draw(random, 16).fill(0);
|
||||||
|
return { type: 'tlock', args: [String(round), chainHashHex(p)], body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locator `l` as an age file with a single tlock stanza for `round` of
|
||||||
|
* the pinned profile `p`, the round of the DateKey (spec §44.1): nobody
|
||||||
|
* reads it before the date, the holder of the key included. As Seal of Go,
|
||||||
|
* it marshals the locator first, a LocatorError when it breaks the rules of
|
||||||
|
* marshalLocator, then makes the tlock recipient of `round`, a
|
||||||
|
* DateKeysError as NewTimeRecipient (ERR_UNKNOWN_PROFILE or
|
||||||
|
* ERR_DATEKEY_INVALID), and encrypts, drawing the file key, sigma, the label
|
||||||
|
* and the nonce from `random`, in that order. A failure comes before any
|
||||||
|
* draw. The plaintext, which holds I_SOBRE, is wiped.
|
||||||
|
*/
|
||||||
|
export function seal(p: Profile, round: number, l: Locator, random: RandomFill = cryptoFill): Uint8Array {
|
||||||
|
const plain = marshalLocator(l);
|
||||||
|
try {
|
||||||
|
checkTimeRecipient(p, round);
|
||||||
|
return ageEncrypt(plain, (fileKey) => [wrapTlock(p, round, fileKey, random)], random);
|
||||||
|
} finally {
|
||||||
|
plain.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The envelope of the .dkc `dkc`, as NewEnvelope of Go: `dkc` encrypted with
|
||||||
|
* age for a new X25519 identity, I_SOBRE, the first 32 bytes drawn from
|
||||||
|
* `random`; then what age draws, the file key, the ephemeral scalar of the
|
||||||
|
* stanza and the nonce. The age file is split after the line feed of its MAC
|
||||||
|
* line: the locator it returns has the key, the header, the SHA-256 and the
|
||||||
|
* length of the rest and the SHA-256 of the .dkc, and no address yet; the
|
||||||
|
* rest, with no mark, is what the person keeps outside. A caller adds the
|
||||||
|
* addresses where it stored the rest, alone or inside another file (hide),
|
||||||
|
* and then seals the locator (seal). The locator holds I_SOBRE: the caller
|
||||||
|
* wipes it (wipeLocator) once sealed.
|
||||||
|
*/
|
||||||
|
export function newEnvelope(dkc: Uint8Array, random: RandomFill = cryptoFill): { locator: Locator; rest: Uint8Array } {
|
||||||
|
const secret = draw(random, 32);
|
||||||
|
try {
|
||||||
|
const recipient = x25519Recipient(secret);
|
||||||
|
const file = ageEncrypt(dkc, (fileKey) => [wrapX25519(recipient, fileKey, random)], random);
|
||||||
|
const end = headerEnd(file);
|
||||||
|
const rest = file.slice(end);
|
||||||
|
const locator: Locator = {
|
||||||
|
addresses: [],
|
||||||
|
envelopeKey: secret.slice(),
|
||||||
|
envelopeHeader: file.slice(0, end),
|
||||||
|
restDigest: sha256(rest),
|
||||||
|
restSize: rest.length,
|
||||||
|
capsuleDigest: sha256(dkc),
|
||||||
|
};
|
||||||
|
file.fill(0, 0, end);
|
||||||
|
return { locator, rest };
|
||||||
|
} finally {
|
||||||
|
secret.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The length of the age header of `file`, up to and including the line feed
|
||||||
|
* after the MAC line: the line that starts with "--- ", as headerEnd of Go's
|
||||||
|
* package locator. No line of the header before it starts so, and the lines
|
||||||
|
* of the body of a stanza are Base64, which has no '-'. Throws a LocatorError
|
||||||
|
* with the text of Go.
|
||||||
|
*/
|
||||||
|
export function headerEnd(file: Uint8Array): number {
|
||||||
|
let i = -1;
|
||||||
|
for (let at = 0; at + 5 <= file.length; at++) {
|
||||||
|
if (file[at] === 0x0a && file[at + 1] === 0x2d && file[at + 2] === 0x2d && file[at + 3] === 0x2d && file[at + 4] === 0x20) {
|
||||||
|
i = at;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < 0) throw fail('the age file has no MAC line');
|
||||||
|
const j = file.indexOf(0x0a, i + 1);
|
||||||
|
if (j < 0) throw fail('the MAC line of the age file does not end');
|
||||||
|
return j + 1;
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
// The locator against Go in the other direction: what this library writes,
|
||||||
|
// opened by Go. testing/locator-interop.json holds the recipes of
|
||||||
|
// testing/locator-interop.ts, the SHA-256 of each file that this library
|
||||||
|
// wrote from them with a seed, and the verdict of Go
|
||||||
|
// (scripts/locator-go-verdicts.go): the sealed locators open with the
|
||||||
|
// release of their round to the plaintext that was sealed, their envelopes
|
||||||
|
// to the .dkc, and their rests are found in a host. Here each file is
|
||||||
|
// written again and must be the one that Go read, and this library opens it
|
||||||
|
// too.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { equalBytes } from './bytes.ts';
|
||||||
|
import { openEnvelope, openSealed } from './envelope.ts';
|
||||||
|
import { marshalLocator } from './locator.ts';
|
||||||
|
import { quicknet } from './profile.ts';
|
||||||
|
import { pattern, readVectors, releaseOf } from './testing/locator.ts';
|
||||||
|
import { interopDigests, interopFiles, interopRecipes, type Recipe } from './testing/locator-interop.ts';
|
||||||
|
|
||||||
|
const vectors = readVectors('locator-interop.json');
|
||||||
|
const samples = vectors.samples as (Recipe & { files: Record<string, string>; verdict: string })[];
|
||||||
|
const releases = readVectors('locator-seal.json');
|
||||||
|
|
||||||
|
describe('Go opens what this library writes (locator-interop.json)', () => {
|
||||||
|
it('the recipes are those that the script writes', () => {
|
||||||
|
expect(samples.map(({ files: _f, verdict: _v, ...r }) => r)).toEqual(JSON.parse(JSON.stringify(interopRecipes())));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const s of samples) {
|
||||||
|
it(`Go reads it: ${s.name}`, () => {
|
||||||
|
expect(s.verdict).toBe('ok');
|
||||||
|
const files = interopFiles(s);
|
||||||
|
expect(interopDigests(files)).toEqual(s.files);
|
||||||
|
const loc = openSealed(quicknet(), s.round, releaseOf(releases, s.round), files.sealed!);
|
||||||
|
expect(marshalLocator(loc)).toEqual(files.plaintext);
|
||||||
|
// Compared by bytes: a diff of 16 MiB does not fit in the heap of a test.
|
||||||
|
expect(equalBytes(openEnvelope(loc, files.rest!), pattern(s.dkc_length))).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
{
|
||||||
|
"description": "What Go makes of the sealed locators and envelopes that datekeys-ts writes from a seed, from the recipes of src/lib/dkc/testing/locator-interop.ts, by scripts/locator-go-verdicts.go: the SHA-256 of each file and the verdict of Go, ok or the text of what failed.",
|
||||||
|
"go": "go1.26.8",
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "https://example.com/1000/0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dkc_length": 0,
|
||||||
|
"files": {
|
||||||
|
"dkc": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"plaintext": "1aec3d3e8e9b6b6749c44fec8905ec894097b678c917d66f03a9339921dc9f86",
|
||||||
|
"rest": "d8cda1ea53f68b1e8922be5938a14a08f21467260673b571f3c742de7a3d6469",
|
||||||
|
"sealed": "b9268c4f27d923d143972167b69b7b59723a9a24b0e8d13e95726eb422abb7ac"
|
||||||
|
},
|
||||||
|
"name": "a locator of round 1000, a .dkc of 0 bytes",
|
||||||
|
"round": 1000,
|
||||||
|
"seed": "datekeys-ts interop locator 1000 0",
|
||||||
|
"verdict": "ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "https://example.com/1001/0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dkc_length": 100,
|
||||||
|
"files": {
|
||||||
|
"dkc": "c22e490daa445fb2fba44278c022df135310fd278cabca4ad7919eddcccd1dce",
|
||||||
|
"plaintext": "65ca53f016dcd7d2db0f35fdc7c6ab2933e33f52a42b3c8e964449d7271a7d3b",
|
||||||
|
"rest": "81d78a3fadd75b0c84ff106e9bf256b92ee951788665b3a98328fe4b99e8b48c",
|
||||||
|
"sealed": "a7dc4b5a778b6c01283c32940d8f3e8887abe0ff2cd4bd07476868b34ea2c851"
|
||||||
|
},
|
||||||
|
"name": "a locator of round 1001, a .dkc of 100 bytes",
|
||||||
|
"round": 1001,
|
||||||
|
"seed": "datekeys-ts interop locator 1001 100",
|
||||||
|
"verdict": "ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "https://example.com/1004/0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 2000,
|
||||||
|
"uri": "https://example.com/1004/2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 4000,
|
||||||
|
"uri": "https://example.com/1004/4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 6000,
|
||||||
|
"uri": "https://example.com/1004/6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/7"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dkc_length": 70000,
|
||||||
|
"files": {
|
||||||
|
"dkc": "3500f58cfd1bd88e231edf56dca995542a702bd54525804e5a8604c8aa5cb52e",
|
||||||
|
"plaintext": "4500f9dc88860e14e421122d9086427bb267cc856b55ac431cae614e5e20ac06",
|
||||||
|
"rest": "4526806502068a3b07b09317a32e0b1e69803fb28f966cdbd72ff2e4ca22427a",
|
||||||
|
"sealed": "e0eb4c76dca3af03b842af85e0b3b4e321ca2b650b2c4a7a147050ee409de90d"
|
||||||
|
},
|
||||||
|
"name": "a locator of round 1004, a .dkc of 70000 bytes",
|
||||||
|
"round": 1004,
|
||||||
|
"seed": "datekeys-ts interop locator 1004 70000",
|
||||||
|
"verdict": "ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "https://example.com/2000/0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 2000,
|
||||||
|
"uri": "https://example.com/2000/2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dkc_length": 5000,
|
||||||
|
"files": {
|
||||||
|
"dkc": "1e92fd98f113aba0a78e0830ca06e2775912370feab112dfc57bf3258b810595",
|
||||||
|
"plaintext": "cd76882c64ba5e2553810a67a435c2e19396e25bd741f5c6d881ec5114532aa8",
|
||||||
|
"rest": "79d055a8733d870d1e9e44bbef4a34f2e8ae9738f9566728f1d4f95ed513ae7b",
|
||||||
|
"sealed": "03770a0f84b79b209faa553badce2a80c1a44ecb85e368768cf57b1d3daee3b5"
|
||||||
|
},
|
||||||
|
"name": "a locator of round 2000, a .dkc of 5000 bytes",
|
||||||
|
"round": 2000,
|
||||||
|
"seed": "datekeys-ts interop locator 2000 5000",
|
||||||
|
"verdict": "ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "https://example.com/1000/0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 2000,
|
||||||
|
"uri": "https://example.com/1000/2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 4000,
|
||||||
|
"uri": "https://example.com/1000/4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dkc_length": 65536,
|
||||||
|
"files": {
|
||||||
|
"dkc": "ef4636928161808e87035fa51983821677527ccd9661991c5d0126a778b2268a",
|
||||||
|
"plaintext": "c31b6e23910283a1a10df2d87ee7ef13b17f259671a44ef3883d6fe64da4840b",
|
||||||
|
"rest": "65bc4f31a49be1ff53c183df4c4100751c2d1592b8810aac00d330cca360b98e",
|
||||||
|
"sealed": "307248a7b2ab341024125d64d0ab5cdaf140bc1129be75825b576b130269d94d"
|
||||||
|
},
|
||||||
|
"name": "a locator of round 1000, a .dkc of 65536 bytes",
|
||||||
|
"round": 1000,
|
||||||
|
"seed": "datekeys-ts interop locator 1000 65536",
|
||||||
|
"verdict": "ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "https://example.com/1001/0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dkc_length": 1048576,
|
||||||
|
"files": {
|
||||||
|
"dkc": "06b7bbfb7824aa03382051691630eb26de85102d1b08a81e907ec0744cd8a286",
|
||||||
|
"plaintext": "367977042146dff7e7726d6a70c33763fcd5549ab09dddfcd99ef20e3d0ff08a",
|
||||||
|
"rest": "120165f0e2d2478058d36e72ecb098d16eed252e5ea0c7a4c19d881286179ce3",
|
||||||
|
"sealed": "e5c5c26f4a9bd91a509952147232c66fe28c4044ec77e399b4cc6bd3012fd04a"
|
||||||
|
},
|
||||||
|
"name": "a locator of round 1001, a .dkc of 1048576 bytes",
|
||||||
|
"round": 1001,
|
||||||
|
"seed": "datekeys-ts interop locator 1001 1048576",
|
||||||
|
"verdict": "ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "https://example.com/2000/0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dkc_length": 16777217,
|
||||||
|
"files": {
|
||||||
|
"dkc": "3e3fa12980a65996ef0ae93e16fd7dc9bc896d8e2dd8c0b0d68d286d0bb97291",
|
||||||
|
"plaintext": "336e38df6e71a6bc0e31956774d6470bcb02b775e9b4774a5f086bc1766f185f",
|
||||||
|
"rest": "5224c5d7891d7975c6901fed2e9cf146cc407def331376c0d77d9ec0c4c87b97",
|
||||||
|
"sealed": "d5880493571b21c36a5e52db74fdb0527c79ae32bce990dfb0c6439649c4358e"
|
||||||
|
},
|
||||||
|
"name": "a locator of round 2000, a .dkc of 16777217 bytes",
|
||||||
|
"round": 2000,
|
||||||
|
"seed": "datekeys-ts interop locator 2000 16777217",
|
||||||
|
"verdict": "ok"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": "spec-v0.12"
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
// The recipes of the interoperability of the locator, TypeScript to Go:
|
||||||
|
// sealed locators with their envelopes, which this library writes from a
|
||||||
|
// seed (seeded.ts). scripts/locator-ts-samples.mjs writes their files,
|
||||||
|
// scripts/locator-go-verdicts.go opens them with Go and writes
|
||||||
|
// testing/locator-interop.json, and locator.interop.test.ts writes them
|
||||||
|
// again and compares them with what Go read.
|
||||||
|
|
||||||
|
import { newEnvelope, seal } from '../envelope.ts';
|
||||||
|
import { type Locator, marshalLocator } from '../locator.ts';
|
||||||
|
import { quicknet } from '../profile.ts';
|
||||||
|
import { pattern, sha256Hex } from './locator.ts';
|
||||||
|
import { seededFill } from './seeded.ts';
|
||||||
|
|
||||||
|
/** A recipe: a .dkc of dkc_length bytes in an envelope whose locator, with its addresses, is sealed for round. */
|
||||||
|
export interface Recipe {
|
||||||
|
readonly name: string;
|
||||||
|
readonly seed: string;
|
||||||
|
readonly round: number;
|
||||||
|
readonly dkc_length: number;
|
||||||
|
readonly addresses: readonly { readonly uri: string; readonly offset: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recipes, in their order. */
|
||||||
|
export function interopRecipes(): Recipe[] {
|
||||||
|
const out: Recipe[] = [];
|
||||||
|
for (const [round, n, addresses] of [
|
||||||
|
[1000, 0, 1],
|
||||||
|
[1001, 100, 2],
|
||||||
|
[1004, 70000, 8],
|
||||||
|
[2000, 5000, 3],
|
||||||
|
[1000, 65536, 5],
|
||||||
|
[1001, 1 << 20, 1],
|
||||||
|
// More than 256 chunks: the counter of the nonce carries.
|
||||||
|
[2000, (1 << 24) + 1, 2],
|
||||||
|
] as const) {
|
||||||
|
out.push({
|
||||||
|
name: `a locator of round ${round}, a .dkc of ${n} bytes`,
|
||||||
|
seed: `datekeys-ts interop locator ${round} ${n}`,
|
||||||
|
round,
|
||||||
|
dkc_length: n,
|
||||||
|
addresses: Array.from({ length: addresses }, (_, i) =>
|
||||||
|
i % 2 === 0 ? { uri: `https://example.com/${round}/${i}`, offset: i * 1000 } : { uri: `ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/${i}`, offset: 0 },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The files of a recipe, by the suffix of their name, and what Go must find in them. */
|
||||||
|
export function interopFiles(r: Recipe): Record<string, Uint8Array> {
|
||||||
|
const random = seededFill(r.seed);
|
||||||
|
const dkc = pattern(r.dkc_length);
|
||||||
|
const e = newEnvelope(dkc, random);
|
||||||
|
const loc: Locator = { ...e.locator, addresses: r.addresses.map((a) => ({ ...a })) };
|
||||||
|
const sealed = seal(quicknet(), r.round, loc, random);
|
||||||
|
return { sealed, rest: e.rest, plaintext: marshalLocator(loc), dkc };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SHA-256 of each file, as the vectors write them. */
|
||||||
|
export function interopDigests(files: Record<string, Uint8Array>): Record<string, string> {
|
||||||
|
return Object.fromEntries(Object.entries(files).map(([k, b]) => [k, sha256Hex(b)]));
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,66 @@
|
|||||||
|
// A deterministic source of random bytes for the tests of the locator: the
|
||||||
|
// keystream of ChaCha20 (RFC 8439) under the SHA-256 of a seed, with a zero
|
||||||
|
// nonce and the counter from 0, read draw after draw. It is the source that
|
||||||
|
// scripts/locator-seal-go-vectors.go puts in crypto/rand.Reader of Go and
|
||||||
|
// SeededRandomSource of datekeys-dart: with the same seed, Go, Dart and this
|
||||||
|
// library draw the same values, so that the vectors compare the bytes they
|
||||||
|
// write. ChaCha20 is written here, so that testing/ names no package.
|
||||||
|
|
||||||
|
import type { RandomFill } from '../ageio.ts';
|
||||||
|
import { sha256Hasher } from '../digest.ts';
|
||||||
|
|
||||||
|
const rotl = (x: number, n: number): number => ((x << n) | (x >>> (32 - n))) >>> 0;
|
||||||
|
|
||||||
|
// The block of ChaCha20 for the key words k, the counter and a zero nonce.
|
||||||
|
function block(k: Uint32Array, counter: number): Uint8Array {
|
||||||
|
const s = new Uint32Array([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574, ...k, counter, 0, 0, 0]);
|
||||||
|
const x = s.slice();
|
||||||
|
const qr = (a: number, b: number, c: number, d: number): void => {
|
||||||
|
x[a] = (x[a]! + x[b]!) >>> 0;
|
||||||
|
x[d] = rotl(x[d]! ^ x[a]!, 16);
|
||||||
|
x[c] = (x[c]! + x[d]!) >>> 0;
|
||||||
|
x[b] = rotl(x[b]! ^ x[c]!, 12);
|
||||||
|
x[a] = (x[a]! + x[b]!) >>> 0;
|
||||||
|
x[d] = rotl(x[d]! ^ x[a]!, 8);
|
||||||
|
x[c] = (x[c]! + x[d]!) >>> 0;
|
||||||
|
x[b] = rotl(x[b]! ^ x[c]!, 7);
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
qr(0, 4, 8, 12);
|
||||||
|
qr(1, 5, 9, 13);
|
||||||
|
qr(2, 6, 10, 14);
|
||||||
|
qr(3, 7, 11, 15);
|
||||||
|
qr(0, 5, 10, 15);
|
||||||
|
qr(1, 6, 11, 12);
|
||||||
|
qr(2, 7, 8, 13);
|
||||||
|
qr(3, 4, 9, 14);
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(64);
|
||||||
|
const v = new DataView(out.buffer);
|
||||||
|
for (let i = 0; i < 16; i++) v.setUint32(4 * i, (x[i]! + s[i]!) >>> 0, true);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The RandomFill of `seed`, which records every draw it gives, in order. */
|
||||||
|
export function seededFill(seed: string): RandomFill & { readonly draws: Uint8Array[] } {
|
||||||
|
const h = sha256Hasher();
|
||||||
|
h.update(new TextEncoder().encode(seed));
|
||||||
|
const key = h.digest();
|
||||||
|
const k = new Uint32Array(8);
|
||||||
|
for (let i = 0; i < 8; i++) k[i] = new DataView(key.buffer, key.byteOffset).getUint32(4 * i, true);
|
||||||
|
let counter = 0;
|
||||||
|
let buf: Uint8Array = new Uint8Array(0);
|
||||||
|
let at = 0;
|
||||||
|
const draws: Uint8Array[] = [];
|
||||||
|
const fill = (b: Uint8Array): void => {
|
||||||
|
for (let i = 0; i < b.length; i++) {
|
||||||
|
if (at === buf.length) {
|
||||||
|
buf = block(k, counter++);
|
||||||
|
at = 0;
|
||||||
|
}
|
||||||
|
b[i] = buf[at++]!;
|
||||||
|
}
|
||||||
|
draws.push(b.slice());
|
||||||
|
};
|
||||||
|
return Object.assign(fill, { draws });
|
||||||
|
}
|
||||||
Loading…
Reference in new issue