package capsule_test import ( "bytes" "context" "encoding/binary" "errors" "math/rand/v2" "runtime" "strings" "testing" "filippo.io/age" datekeys "g.activething.com/go/DateKeys" "g.activething.com/go/DateKeys/agewrap" "g.activething.com/go/DateKeys/capsule" "g.activething.com/go/DateKeys/extension" "g.activething.com/go/DateKeys/internal/testkit" "g.activething.com/go/DateKeys/profile" ) // The tests of this file cover format 2 (spec v0.9): the changes of spec §76 // "Cambios normativos de la v0.9" and the tests it names. // Spec §22, §23, §70: a reader opens every format and reports which; any // other VERSION is rejected at step 2, without a request. func TestFormatDispatch(t *testing.T) { for _, name := range fixtureNames { f := loadFixture(t, name) var out bytes.Buffer opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), f.openOptions(t)) if err != nil || opened.Format != f.format() || opened.Inspection.Prelude.Format != f.format() { t.Fatalf("%s: format %d, %v", name, opened.Format, err) } } f := loadFixture(t, "format2_time_only") for _, v := range []byte{0, 4, 0x80, 0xff} { dkc := bytes.Clone(f.dkc) dkc[4] = v step, calls, err := openStep(t, dkc, capsule.OpenOptions{Now: testkit.Fixed(f.unlock(t))}) expectStep(t, "VERSION "+string(rune('0'+v%10)), step, err, datekeys.ErrUnsupportedVersion, 2) if calls != 0 { t.Errorf("VERSION %d: %d release requests", v, calls) } } // VERSION 3 is format 3 since v0.10: it passes step 2. Without a Sink, // Open stops right there with ErrSinkRequired, a caller error with no code, // no failed step and no request; with one, the version 2 control fails at // step 14. dkc := bytes.Clone(f.dkc) dkc[4] = 3 src := testkit.NewSource(f.release) opened, err := capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc), capsule.OpenOptions{Registry: testkit.Registry(), Source: src, Now: testkit.Fixed(f.unlock(t))}) if !errors.Is(err, capsule.ErrSinkRequired) || datekeys.Code(err) != "" || src.Calls != 0 || opened.Format != capsule.Format3 { t.Errorf("VERSION 3 without a Sink: %v, code %q, %d requests, format %d", err, datekeys.Code(err), src.Calls, opened.Format) } if c := opened.Inspection.Checks; len(c) != 2 || !c[0].OK || !c[1].OK || c[1].Step != 2 { t.Errorf("VERSION 3 without a Sink: checks %+v", c) } step, calls, err := openStep(t, dkc, capsule.OpenOptions{Now: testkit.Fixed(f.unlock(t)), Sink: testkit.DiscardSink{}}) expectStep(t, "VERSION 3 with a Sink", step, err, datekeys.ErrUnsupportedVersion, 14) if calls != 1 { t.Errorf("VERSION 3 with a Sink: %d release requests", calls) } // A capsule of format 1 or 2 needs dst, whatever the Sink. src = testkit.NewSource(f.release) o := f.openOptions(t) o.Source, o.Sink = src, testkit.DiscardSink{} if _, err := capsule.Open(context.Background(), nil, bytes.NewReader(f.dkc), o); err == nil || datekeys.Code(err) != "" || src.Calls != 0 { t.Errorf("format 2 without dst: %v, %d requests", err, src.Calls) } } // Spec §22, §76 change 1: VERSION is public and anyone can edit it. A capsule // relabeled to another format fails at step 12 or 14, after the release, // and nothing is written. func TestFormatRelabel(t *testing.T) { relabel := func(dkc []byte, v byte) []byte { c := bytes.Clone(dkc) c[4] = v return c } identity := func(f *fixture) []age.Identity { return f.credentials(t)[:1] } to1, tk1 := loadFixture(t, "time_only"), loadFixture(t, "time_and_key_portable") to2, tk2 := loadFixture(t, "format2_time_only"), loadFixture(t, "format2_time_and_key_portable") // A format 1 capsule with exactly 16 stanzas, as only a generator of test // vectors writes it: the stanza count passes step 12 of format 2, and the // version of its control fails step 14. stranger := testkit.Stranger() sixteen := []age.Recipient{stranger.Recipient()} for range 15 { id, _ := age.GenerateX25519Identity() sixteen = append(sixteen, id.Recipient()) } b, err := testkit.Build{Format: capsule.Format1, Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey, AccessRecipients: sixteen, Plaintext: []byte("sixteen")}.Make() if err != nil { t.Fatal(err) } for _, tc := range []struct { name string dkc []byte ids []age.Identity now *fixture want error step int }{ {"format 2 time_only relabeled 1", relabel(to2.dkc, 1), nil, to2, datekeys.ErrUnsupportedVersion, 14}, {"format 2 time_and_key relabeled 1", relabel(tk2.dkc, 1), identity(tk2), tk2, datekeys.ErrUnsupportedVersion, 14}, {"format 1 time_only relabeled 2", relabel(to1.dkc, 2), nil, to1, datekeys.ErrUnsupportedVersion, 14}, {"format 1 time_and_key with one stanza relabeled 2", relabel(tk1.dkc, 2), identity(tk1), tk1, datekeys.ErrPolicyStructureMismatch, 12}, {"format 1 time_and_key with 16 stanzas relabeled 2", relabel(b.DKC, 2), []age.Identity{stranger}, nil, datekeys.ErrUnsupportedVersion, 14}, // Format 3 has the 16 stanzas and the padding of format 2 (spec §29.1). {"format 2 time_only relabeled 3", relabel(to2.dkc, 3), nil, to2, datekeys.ErrUnsupportedVersion, 14}, {"format 2 time_and_key relabeled 3", relabel(tk2.dkc, 3), identity(tk2), tk2, datekeys.ErrUnsupportedVersion, 14}, {"format 1 time_only relabeled 3", relabel(to1.dkc, 3), nil, to1, datekeys.ErrUnsupportedVersion, 14}, {"format 1 time_and_key with one stanza relabeled 3", relabel(tk1.dkc, 3), identity(tk1), tk1, datekeys.ErrPolicyStructureMismatch, 12}, {"format 1 time_and_key with 16 stanzas relabeled 3", relabel(b.DKC, 3), []age.Identity{stranger}, nil, datekeys.ErrUnsupportedVersion, 14}, } { // The Sink plays no part in formats 1 and 2. o := capsule.OpenOptions{Identities: tc.ids, Sink: testkit.DiscardSink{}} if tc.now != nil { o.Now = testkit.Fixed(tc.now.unlock(t)) } step, _, err := openStep(t, tc.dkc, o) expectStep(t, tc.name, step, err, tc.want, tc.step) } // Unrelabeled, the format 1 capsule with 16 stanzas opens. if got, err := open(t, b.DKC, capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(testkit.Release(1000)), Now: testkit.Fixed(testkit.Genesis().AddDate(1, 0, 0)), Identities: []age.Identity{stranger}}); err != nil || string(got) != "sixteen" { t.Fatalf("format 1 with 16 stanzas: %v", err) } } // Spec §33, §70: format 1 keeps the rules of v0.8.2, one or more stanzas and // no padding, and format 2 requires exactly 16 stanzas. func TestFormat1Compatibility(t *testing.T) { stranger := testkit.Stranger() recipients := []age.Recipient{stranger.Recipient()} for range 16 { id, _ := age.GenerateX25519Identity() recipients = append(recipients, id.Recipient()) } o := capsule.OpenOptions{Identities: []age.Identity{stranger}} for _, tc := range []struct { name string b testkit.Build want error step int output string }{ {"format 1 with 17 stanzas", testkit.Build{Format: capsule.Format1, Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey, AccessRecipients: recipients}, nil, 0, "malicious creator"}, {"format 1 with one stanza", testkit.Build{Format: capsule.Format1, Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey, AccessRecipients: recipients[:1]}, nil, 0, "malicious creator"}, {"format 2 with 17 stanzas", testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey, AccessRecipients: recipients, Slots: 17}, datekeys.ErrPolicyStructureMismatch, 12, ""}, {"format 2 with one stanza", testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey, AccessRecipients: recipients[:1], Slots: 1}, datekeys.ErrPolicyStructureMismatch, 12, ""}, {"format 2 with 16 stanzas", testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey, AccessRecipients: recipients[:1]}, nil, 0, "malicious creator"}, } { dkc, _ := build(t, tc.b) step, _, err := openStep(t, dkc, o) if tc.want == nil { if err != nil { t.Errorf("%s: %v", tc.name, err) } continue } expectStep(t, tc.name, step, err, tc.want, tc.step) } // The plaintext of a format 1 PAYLOAD_AGE is the content, without padding. b, err := testkit.Build{Format: capsule.Format1, Plaintext: []byte("x")}.Make() if err != nil { t.Fatal(err) } if len(b.Payload) != int(capsule.PayloadAgeLength(1)) { t.Fatalf("format 1 PAYLOAD_AGE of %d bytes", len(b.Payload)) } } // Spec §29.1: the two rules, exact on integers, their properties and their // limits, and the length of PAYLOAD_AGE they give. func TestPaddingRules(t *testing.T) { check := func(l uint64) { b, err1 := capsule.PaddedLength(l, capsule.Bloque256) r, err2 := capsule.PaddedLength(l, capsule.Reforzado) if err := errors.Join(err1, err2); err != nil { t.Fatalf("L = %d: %v", l, err) } for _, p := range []uint64{b, r} { if p%256 != 0 || p < 256 || p < l { t.Fatalf("L = %d: P = %d", l, p) } } if l > 256 && b-l >= 256 { t.Fatalf("L = %d: bloque256 adds %d bytes", l, b-l) } if l <= 8192 && b != r { t.Fatalf("L = %d: bloque256 %d, reforzado %d", l, b, r) } if r < b { t.Fatalf("L = %d: reforzado %d below bloque256 %d", l, r, b) } // Above 8192, reforzado adds less than L / 2^S. if e := uint64(63 - clz(l)); l > 8192 && r-l >= l>>bitlen(e) { t.Fatalf("L = %d: reforzado adds %d bytes", l, r-l) } } for l := range uint64(20000) { check(l) } rng := rand.New(rand.NewPCG(20260929, 1)) for range 20000 { check(rng.Uint64N(capsule.MaxPayloadLength + 1)) } check(capsule.MaxPayloadLength) if b, _ := capsule.PaddedLength(8193, capsule.Bloque256); b != 8448 { t.Fatalf("bloque256(8193) = %d", b) } if r, _ := capsule.PaddedLength(8193, capsule.Reforzado); r != 8704 { t.Fatalf("reforzado(8193) = %d", r) } for _, tc := range []struct { l uint64 p capsule.Padding }{{0, 0}, {0, 3}, {capsule.MaxPayloadLength + 1, capsule.Bloque256}, {capsule.MaxPayloadLength + 1, capsule.Reforzado}, {1 << 63, capsule.Reforzado}} { if _, err := capsule.PaddedLength(tc.l, tc.p); err == nil { t.Errorf("L = %d, code %d accepted", tc.l, tc.p) } } if capsule.Bloque256.String() != "bloque256" || capsule.Reforzado.String() != "reforzado" || capsule.Padding(9).String() != "padding(9)" { t.Fatal("padding names") } // PAYLOAD_AGE: 184 + P + 16·max(1, ⌈P / 65536⌉), checked against age. id, _ := age.GenerateX25519Identity() for _, p := range []uint64{0, 256, 65536, 65536 + 256, 2 * 65536, 79872} { file, _, err := testkit.Encrypt(make([]byte, p), id.Recipient()) if err != nil { t.Fatal(err) } if uint64(len(file)) != capsule.PayloadAgeLength(p) { t.Fatalf("P = %d: age writes %d bytes, the formula gives %d", p, len(file), capsule.PayloadAgeLength(p)) } } } func clz(x uint64) int { n := 0 for i := 63; i >= 0 && x&(1< l || !bytes.HasPrefix(fx.plaintext, out.Bytes()) { t.Errorf("%s: %d bytes written, not a prefix of the content", tc.name, out.Len()) } } } // Spec §67: a padding that spans several STREAM chunks, generated at run // time rather than frozen in a 5 MB fixture: L = 5 000 000 with code 2 gives // P = 5 111 808, more than a chunk of zeros after the content. func TestPaddingAcrossChunks(t *testing.T) { content := bytes.Repeat([]byte("0123456789"), 500000) b, err := testkit.Build{Plaintext: content, Padding: capsule.Reforzado}.Make() if err != nil { t.Fatal(err) } o := capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(testkit.Release(1000)), Now: testkit.Fixed(testkit.Genesis().AddDate(1, 0, 0))} var out bytes.Buffer opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(b.DKC), o) if err != nil || !bytes.Equal(out.Bytes(), content) || opened.PaddedLength != 5111808 { t.Fatalf("P = %d: %v", opened.PaddedLength, err) } // The last byte of the padding, in the last chunk, not zero. id, err := agewrap.NewPayloadIdentity(b.PayloadIdentity) if err != nil { t.Fatal(err) } stanzas, err := agewrap.Stanzas(bytes.NewReader(b.Payload)) if err != nil { t.Fatal(err) } fk, err := id.Unwrap(stanzas) if err != nil { t.Fatal(err) } plaintext := append(bytes.Clone(content), make([]byte, 5111808-len(content))...) plaintext[len(plaintext)-1] = 1 payload, err := testkit.ResealAge(b.Payload, fk, plaintext) if err != nil { t.Fatal(err) } out.Reset() opened, err = capsule.Open(context.Background(), &out, bytes.NewReader(testkit.Join(b.Prelude[:], b.PublicHeader, b.Sealed, payload)), o) expectStep(t, "last padding byte not zero, 17 chunks away", failedStep(t, opened.Inspection.Checks, err), err, datekeys.ErrIntegrity, 17) if !bytes.Equal(out.Bytes(), content) { t.Fatalf("%d bytes written, want the content only", out.Len()) } } // Spec §31, §57, §76 change 6: L and P are not frame lengths. A time_only // control that anyone seals can declare L = L_MAX over a PAYLOAD_AGE of 456 // bytes; the reader reserves nothing according to it and fails at step 17. func TestDeclaredLengthIsNotAllocated(t *testing.T) { f, err := testkit.LoadFixture(fixtureDir, "format2_empty_payload") if err != nil { t.Fatal(err) } in, err := f.WithControl(func(m map[uint64]any) { m[6] = payloadLength(capsule.MaxPayloadLength) }) if err != nil { t.Fatal(err) } fx := loadFixture(t, "format2_empty_payload") var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before) var out bytes.Buffer opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(in.DKC), fx.openOptions(t)) runtime.ReadMemStats(&after) expectStep(t, "L = L_MAX over 456 bytes", failedStep(t, opened.Inspection.Checks, err), err, datekeys.ErrIntegrity, 17) if grown := after.TotalAlloc - before.TotalAlloc; grown > 16<<20 { t.Fatalf("%d bytes allocated", grown) } } // Spec §62.1 rule 7 and its note, §76 change 8: SEALED_CONTROL_LEN is the // exact length of SEALED_CONTROL, and follows the formulas of the note. func TestSealedControlLength(t *testing.T) { c := func(n int) int { return max(1, (n+65535)/65536) } ext, _ := extension.New("org.example.note", 2, bytes.Repeat([]byte{7}, 100)) for _, tc := range []struct { name string setup func(o *capsule.EncryptOptions) want func(control int) int }{ {"time_only", func(*capsule.EncryptOptions) {}, func(n int) int { return 335 + 4 + n + 16*c(n) }}, {"time_and_key", func(o *capsule.EncryptOptions) { o.Policy, o.NewPortableKey = capsule.TimeAndKey, true }, func(n int) int { inner := 86 + 98*16 + n + 16*c(n) return 335 + 4 + inner + 16*c(inner) }}, } { for _, exts := range [][]extension.Extension{nil, {ext}} { opts := past(t, 1000) opts.ControlNoncritical = exts tc.setup(&opts) var dkc bytes.Buffer if _, err := encrypt(t, &dkc, "length", opts); err != nil { t.Fatal(err) } control, err := capsule.EncodeControl(&capsule.Control{Noncritical: exts, Padding: capsule.Reforzado}, capsule.Format2) if err != nil { t.Fatal(err) } got := int(binary.BigEndian.Uint32(dkc.Bytes()[12:16])) p, err := testkit.Split(dkc.Bytes()) if err != nil { t.Fatal(err) } if got != tc.want(len(control)) || got != len(p.Sealed) { t.Errorf("%s, %d control extensions: SEALED_CONTROL_LEN %d, formula %d", tc.name, len(exts), got, tc.want(len(control))) } if len(exts) == 0 && ((tc.name == "time_only" && got != 458) || (tc.name == "time_and_key" && got != 2128)) { t.Errorf("%s: %d, spec §62.1 gives 458 and 2128", tc.name, got) } } } } // Spec §29, §62.1 rule 5, §76 change 9: I_PAYLOAD is new for every capsule, // so that opening one capsule opens no other payload. func TestPayloadIdentityReuse(t *testing.T) { p := profile.Quicknet() var controls [2]*capsule.Control var parts [2]testkit.Parts for i := range 2 { var dkc bytes.Buffer if _, err := encrypt(t, &dkc, "same content", past(t, 1000)); err != nil { t.Fatal(err) } var err error if parts[i], err = testkit.Split(dkc.Bytes()); err != nil { t.Fatal(err) } id, _ := agewrap.NewTimeIdentity(p, 1000, testkit.Release(1000)) if controls[i], err = capsule.DecodeControl(decryptAge(t, parts[i].Sealed, id), capsule.Format2); err != nil { t.Fatal(err) } } if controls[0].PayloadIdentity == controls[1].PayloadIdentity { t.Fatal("two capsules share I_PAYLOAD") } a, b := parts[0], parts[1] step, _, err := openStep(t, testkit.Join(a.Prelude, a.Header, a.Sealed, b.Payload), capsule.OpenOptions{}) expectStep(t, "SEALED_CONTROL_A + PAYLOAD_AGE_B", step, err, datekeys.ErrIntegrity, 17) } // Spec §39, §76 change 2: INNER_ACCESS_AGE holds exactly 16 X25519 stanzas // with distinct shares, whatever the number of credentials; each credential // opens exactly one, and no credential opens the others. func TestInnerHasSixteenStanzas(t *testing.T) { for _, n := range []int{0, 2, 15} { opts := past(t, 1000) opts.Policy, opts.NewPortableKey = capsule.TimeAndKey, true var ids []age.Identity for range n { id, _ := age.GenerateX25519Identity() opts.Recipients = append(opts.Recipients, id.Recipient()) ids = append(ids, id) } var dkc bytes.Buffer res, err := encrypt(t, &dkc, "slots", opts) if err != nil { t.Fatal(err) } k, _ := res.PortableKey.Identity() ids = append(ids, k) p, _ := testkit.Split(dkc.Bytes()) timeID, _ := agewrap.NewTimeIdentity(profile.Quicknet(), 1000, testkit.Release(1000)) st, err := agewrap.Stanzas(bytes.NewReader(decryptAge(t, p.Sealed, timeID))) if err != nil { t.Fatal(err) } if err := agewrap.CheckAccessStanzas(st, agewrap.AccessSlots); err != nil { t.Fatalf("%d credentials: %v", len(ids), err) } opened := map[int]bool{} for _, id := range ids { for i, s := range st { if _, err := id.Unwrap([]*age.Stanza{s}); err == nil { if opened[i] { t.Fatalf("stanza %d opened twice", i) } opened[i] = true } } } if len(opened) != len(ids) { t.Fatalf("%d credentials open %d stanzas", len(ids), len(opened)) } } } // Spec §39: a dummy wraps FK_ACCESS like a credential, so whoever kept the // private key of a dummy would open the capsule: the writer MUST NOT keep it. // Encrypt returns no key but the portable one, and its dummies are fresh: no // ephemeral share repeats across capsules for the same credentials. func TestDummyRecipients(t *testing.T) { kept, _ := age.GenerateX25519Identity() b, err := testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey, AccessRecipients: []age.Recipient{testkit.Stranger().Recipient(), kept.Recipient()}}.Make() if err != nil { t.Fatal(err) } o := capsule.OpenOptions{Identities: []age.Identity{kept}} if step, _, err := openStep(t, b.DKC, o); err != nil { t.Fatalf("the kept key does not open the capsule: step %d, %v", step, err) } holder, _ := age.GenerateX25519Identity() shares := map[string]bool{} for range 2 { opts := past(t, 1000) opts.Policy, opts.Recipients = capsule.TimeAndKey, []age.Recipient{holder.Recipient()} var dkc bytes.Buffer res, err := encrypt(t, &dkc, "dummies", opts) if err != nil || res.PortableKey != nil { t.Fatalf("result %+v, %v", res, err) } p, _ := testkit.Split(dkc.Bytes()) timeID, _ := agewrap.NewTimeIdentity(profile.Quicknet(), 1000, testkit.Release(1000)) st, _ := agewrap.Stanzas(bytes.NewReader(decryptAge(t, p.Sealed, timeID))) for _, s := range st { if shares[s.Args[0]] { t.Fatal("an ephemeral share repeats across capsules") } shares[s.Args[0]] = true } } if len(shares) != 2*agewrap.AccessSlots { t.Fatalf("%d shares", len(shares)) } } // The writer checks the format 2 rules it must follow through the reader: // every capsule it writes opens with the rules of the reader, for both codes // and both policies, whatever the content length. func TestEncryptWritesFormat2(t *testing.T) { for _, l := range []int{0, 1, 255, 256, 257, 8193, 65536, 65537} { for _, code := range []capsule.Padding{capsule.Bloque256, capsule.Reforzado} { content := strings.Repeat("d", l) opts := past(t, 1000) opts.Padding = code var dkc bytes.Buffer res, err := encrypt(t, &dkc, content, opts) if err != nil { t.Fatal(err) } want, _ := capsule.PaddedLength(uint64(l), code) if dkc.Bytes()[4] != 2 || res.PaddedLength != want { t.Fatalf("L = %d, %s: VERSION %d, P = %d", l, code, dkc.Bytes()[4], res.PaddedLength) } p, _ := testkit.Split(dkc.Bytes()) if uint64(len(p.Payload)) != capsule.PayloadAgeLength(want) { t.Fatalf("L = %d, %s: PAYLOAD_AGE of %d bytes", l, code, len(p.Payload)) } var out bytes.Buffer opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc.Bytes()), defaultOpen(1000)) if err != nil || out.String() != content || opened.Padding != code || opened.PaddedLength != want { t.Fatalf("L = %d, %s: %v", l, code, err) } } } }