You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DateKeys/capsule/internal_test.go

246 lines
7.6 KiB

package capsule
import (
"bytes"
"errors"
Implement capsule format 2 of spec v0.9 The reference moves to the DateKeys Protocol Specification v0.9, approved by its author on 29 September 2026. Encrypt writes capsule format 2 only; Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the verdict v0.8.2 gave it. Format 2 (spec §22, §29.1, §31, §39): - VERSION in the PRELUDE is the capsule format, capsule.Format; any other value is ERR_UNSUPPORTED_VERSION at step 2. - CONTROL_CBOR has the schema version of its format. Version 2 adds key 6, payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without extensions, whatever L. - The payload is the content padded with zeros to P = rule(L). Step 17 checks the length and the zeros, and Open writes only the first L bytes. - INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials, and a dummy in each slot left, in a uniformly random order. Writer rules (spec §62.1): EncryptOptions.Length is required and the source must deliver exactly that many bytes; recipients that are not canonical or of low order are rejected (agewrap.CheckX25519Recipient); self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE. The CLI measures its input, takes -padding and reports the format. Test data: seven format 2 fixtures, padding vectors checked against math/big, format 2 CBOR vectors, and the mutation corpus in both formats with the 22 cases of the third list of spec §64, built without randomness by sealing the fixtures again with their known keys and nonces. The format 1 fixtures are kept byte for byte and never regenerated; the differential corpus keeps its 1825 cases and adds a block per format 2 fixture. The spec copy loses its "to be implemented" markers, and the READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
"io"
"strings"
"testing"
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
Implement capsule format 2 of spec v0.9 The reference moves to the DateKeys Protocol Specification v0.9, approved by its author on 29 September 2026. Encrypt writes capsule format 2 only; Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the verdict v0.8.2 gave it. Format 2 (spec §22, §29.1, §31, §39): - VERSION in the PRELUDE is the capsule format, capsule.Format; any other value is ERR_UNSUPPORTED_VERSION at step 2. - CONTROL_CBOR has the schema version of its format. Version 2 adds key 6, payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without extensions, whatever L. - The payload is the content padded with zeros to P = rule(L). Step 17 checks the length and the zeros, and Open writes only the first L bytes. - INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials, and a dummy in each slot left, in a uniformly random order. Writer rules (spec §62.1): EncryptOptions.Length is required and the source must deliver exactly that many bytes; recipients that are not canonical or of low order are rejected (agewrap.CheckX25519Recipient); self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE. The CLI measures its input, takes -padding and reports the format. Test data: seven format 2 fixtures, padding vectors checked against math/big, format 2 CBOR vectors, and the mutation corpus in both formats with the 22 cases of the third list of spec §64, built without randomness by sealing the fixtures again with their known keys and nonces. The format 1 fixtures are kept byte for byte and never regenerated; the differential corpus keeps its 1825 cases and adds a block per format 2 fixture. The spec copy loses its "to be implemented" markers, and the READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
"g.activething.com/go/DateKeys/agewrap"
)
Implement capsule format 2 of spec v0.9 The reference moves to the DateKeys Protocol Specification v0.9, approved by its author on 29 September 2026. Encrypt writes capsule format 2 only; Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the verdict v0.8.2 gave it. Format 2 (spec §22, §29.1, §31, §39): - VERSION in the PRELUDE is the capsule format, capsule.Format; any other value is ERR_UNSUPPORTED_VERSION at step 2. - CONTROL_CBOR has the schema version of its format. Version 2 adds key 6, payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without extensions, whatever L. - The payload is the content padded with zeros to P = rule(L). Step 17 checks the length and the zeros, and Open writes only the first L bytes. - INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials, and a dummy in each slot left, in a uniformly random order. Writer rules (spec §62.1): EncryptOptions.Length is required and the source must deliver exactly that many bytes; recipients that are not canonical or of low order are rejected (agewrap.CheckX25519Recipient); self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE. The CLI measures its input, takes -padding and reports the format. Test data: seven format 2 fixtures, padding vectors checked against math/big, format 2 CBOR vectors, and the mutation corpus in both formats with the 22 cases of the third list of spec §64, built without randomness by sealing the fixtures again with their known keys and nonces. The format 1 fixtures are kept byte for byte and never regenerated; the differential corpus keeps its 1825 cases and adds a block per format 2 fixture. The spec copy loses its "to be implemented" markers, and the READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// Spec §39, §62 step 6, §76 change 3: the 16 recipients of INNER_ACCESS_AGE,
// the credentials and the dummies, are in a uniformly random order. The slot
// of the first element, and of the last, follows the uniform distribution
// over the 16: the chi-square statistic of 32 000 permutations, with 15
// degrees of freedom, stays below 60, which a uniform distribution passes
// with probability above 1 - 10^-6 and a slot fixed or much favoured does not.
// fillSlots adds 15 distinct dummies to one credential, and none to 16.
func TestStanzaOrderIsUniform(t *testing.T) {
const draws = 32000
var first, last [agewrap.AccessSlots]int
for range draws {
s := make([]int, agewrap.AccessSlots)
for i := range s {
s[i] = i
}
if err := permute(s); err != nil {
t.Fatal(err)
}
for i, v := range s {
switch v {
case 0:
first[i]++
case agewrap.AccessSlots - 1:
last[i]++
}
}
}
for _, count := range [][agewrap.AccessSlots]int{first, last} {
expected := float64(draws) / agewrap.AccessSlots
chi2 := 0.0
for _, c := range count {
d := float64(c) - expected
chi2 += d * d / expected
}
if chi2 > 60 {
t.Fatalf("slot counts %v, chi-square %.1f", count, chi2)
}
}
credential, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
slots, err := fillSlots([]age.Recipient{credential.Recipient()})
if err != nil {
t.Fatal(err)
}
distinct := map[string]bool{}
for _, r := range slots {
distinct[r.(*age.X25519Recipient).String()] = true
}
if len(slots) != agewrap.AccessSlots || len(distinct) != agewrap.AccessSlots || !distinct[credential.Recipient().String()] {
t.Fatalf("%d slots, %d distinct", len(slots), len(distinct))
}
var sixteen []age.Recipient
seen := map[string]bool{}
for range agewrap.AccessSlots {
id, _ := age.GenerateX25519Identity()
sixteen = append(sixteen, id.Recipient())
seen[id.Recipient().String()] = true
}
slots, err = fillSlots(sixteen)
if err != nil {
t.Fatal(err)
}
for _, r := range slots {
if !seen[r.(*age.X25519Recipient).String()] {
t.Fatal("a dummy among 16 credentials")
}
delete(seen, r.(*age.X25519Recipient).String())
}
}
// Spec §62.1 rule 11, §76 change 13: the self-checks of the writer reject
// what the reader would reject after the date.
func TestEncryptSelfCheck(t *testing.T) {
portable, _ := age.GenerateX25519Identity()
other, _ := age.GenerateX25519Identity()
control := []byte("control")
slots, err := fillSlots([]age.Recipient{portable.Recipient()})
if err != nil {
t.Fatal(err)
}
good, err := encryptAll(control, slots...)
if err != nil {
t.Fatal(err)
}
if err := selfCheckInner(good, control, portable); err != nil {
t.Fatalf("a valid INNER_ACCESS_AGE: %v", err)
}
// The portable key and 14 others: with one more, 15 stanzas; with the
// portable key again, 16 stanzas of which it opens two.
var others []age.Recipient
for range 14 {
id, _ := age.GenerateX25519Identity()
others = append(others, id.Recipient())
}
fifteen, _ := encryptAll(control, append([]age.Recipient{portable.Recipient()}, others...)...)
twice, _ := encryptAll(control, append([]age.Recipient{portable.Recipient(), portable.Recipient()}, others...)...)
for name, tc := range map[string]struct {
inner []byte
control []byte
id *age.X25519Identity
}{
"15 stanzas": {fifteen, control, portable},
"two stanzas for the portable key": {twice, control, portable},
"a portable key that is no recipient": {good, control, other},
"another control": {good, []byte("other"), portable},
} {
if err := selfCheckInner(tc.inner, tc.control, tc.id); err == nil {
t.Errorf("INNER_ACCESS_AGE with %s passed the self-check", name)
}
}
// PAYLOAD_AGE: the length P gives, and a header I_PAYLOAD opens.
payloadID, _ := age.GenerateX25519Identity()
raw, _ := agewrap.RawX25519Identity(payloadID)
write := func(plaintext []byte, to age.Recipient) *payloadWriter {
p := &payloadWriter{w: io.Discard}
aw, err := age.Encrypt(p, to)
if err != nil {
t.Fatal(err)
}
if _, err := aw.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := aw.Close(); err != nil {
t.Fatal(err)
}
return p
}
if err := selfCheckPayload(write(make([]byte, 256), payloadID.Recipient()), raw, 256); err != nil {
t.Fatalf("a valid PAYLOAD_AGE: %v", err)
}
if err := selfCheckPayload(write(make([]byte, 255), payloadID.Recipient()), raw, 256); err == nil {
t.Error("a PAYLOAD_AGE one byte short passed the self-check")
}
if err := selfCheckPayload(write(make([]byte, 256), other.Recipient()), raw, 256); err == nil {
t.Error("a PAYLOAD_AGE for another recipient passed the self-check")
}
}
// checkPadding reads the padding of a format 2 plaintext: exactly p - l zero
// bytes, found in any read pattern.
func TestCheckPadding(t *testing.T) {
for _, tc := range []struct {
name string
rest string
ok bool
}{
{"exact zeros", strings.Repeat("\x00", 200), true},
{"one byte short", strings.Repeat("\x00", 199), false},
{"one byte more", strings.Repeat("\x00", 201), false},
{"first byte not zero", "\x01" + strings.Repeat("\x00", 199), false},
{"last byte not zero", strings.Repeat("\x00", 199) + "\x80", false},
} {
for _, r := range []io.Reader{strings.NewReader(tc.rest), &oneByteReader{strings.NewReader(tc.rest)}} {
err := checkPadding(r, 56, 256)
if (err == nil) != tc.ok || (err != nil && !errors.Is(err, datekeys.ErrIntegrity)) {
t.Errorf("%s: %v", tc.name, err)
}
}
}
if err := checkPadding(&failingAfter{n: 10}, 56, 256); err == nil || datekeys.Code(err) != "" {
t.Errorf("a read error is not a padding error: %v", err)
}
}
type oneByteReader struct{ r io.Reader }
func (o *oneByteReader) Read(p []byte) (int, error) { return o.r.Read(p[:min(1, len(p))]) }
type failingAfter struct{ n int }
func (f *failingAfter) Read(p []byte) (int, error) {
if f.n == 0 {
return 0, errors.New("stream failure")
}
k := min(f.n, len(p))
clear(p[:k])
f.n -= k
return k, nil
}
// decryptAll reads the plaintext into one buffer of the ciphertext's size, so
// that no outgrown buffer keeps a copy of I_PAYLOAD, and still reports a
// truncated STREAM, which the age reader signals with io.ErrUnexpectedEOF.
func TestDecryptAll(t *testing.T) {
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
const chunk = 64 << 10
for _, size := range []int{0, 1, 511, 512, 513, chunk, chunk + 1, 2*chunk + 100} {
plaintext := bytes.Repeat([]byte{0xab}, size)
ct, err := encryptAll(plaintext, id.Recipient())
if err != nil {
t.Fatal(err)
}
out, err := decryptAll(ct, id)
if err != nil || !bytes.Equal(out, plaintext) {
t.Fatalf("%d bytes: %v", size, err)
}
if cap(out) != len(ct) {
t.Errorf("%d bytes: a buffer of %d bytes for a ciphertext of %d", size, cap(out), len(ct))
}
if size == 0 {
continue
}
// The last chunk removed: a truncation at a chunk boundary.
last := size % chunk
if last == 0 {
last = chunk
}
for _, cut := range []int{len(ct) - 1, len(ct) - (last + 16)} {
if _, err := decryptAll(ct[:cut], id); !errors.Is(err, datekeys.ErrIntegrity) {
t.Errorf("%d bytes cut to %d of %d: %v", size, cut, len(ct), err)
}
}
}
}
// Review: a panic of a parser of security fails only its own part. recovered
// tells a function that returns from one that panics.
func TestRecovered(t *testing.T) {
if !recovered(func() {}) || recovered(func() { panic("a parser") }) {
t.Error("recovered")
}
}

Powered by TurnKey Linux.