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/format2_test.go

562 lines
22 KiB

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
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.
Format 3, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// Spec §22, §23, §70: a reader opens every format and reports which; any
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
// 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")
Format 3, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
for _, v := range []byte{0, 4, 0x80, 0xff} {
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
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)
}
}
Format 3, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// 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)
}
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 §22, §76 change 1: VERSION is public and anyone can edit it. A capsule
Format 3, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// relabeled to another format fails at step 12 or 14, after the release,
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
// 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, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// 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},
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
} {
Format 3, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// The Sink plays no part in formats 1 and 2.
o := capsule.OpenOptions{Identities: tc.ids, Sink: testkit.DiscardSink{}}
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
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<<i) == 0; i-- {
n++
}
return n
}
func bitlen(x uint64) uint64 { return uint64(64 - clz(x)) }
// Spec §29.1, §56, §63 step 17: the reader checks the length and the padding
// of the plaintext, whatever the moment it finds a fault, writes the content
// only and never the padding.
func TestPaddingChecksAtStep17(t *testing.T) {
f, err := testkit.LoadFixture(fixtureDir, "format2_time_only_extensions")
if err != nil {
t.Fatal(err)
}
fx := loadFixture(t, "format2_time_only_extensions")
l, p := int(f.PayloadLength), int(f.PaddedLength)
padded := func() []byte { return append(bytes.Clone(fx.plaintext), make([]byte, p-l)...) }
set := func(i int, v byte) []byte { b := padded(); b[i] = v; return b }
for _, tc := range []struct {
name string
plaintext []byte
ok bool
}{
{"the content and its padding, sealed again", padded(), true},
{"first padding byte not zero", set(l, 0x01), false},
{"a padding byte not zero", set(100, 0x80), false},
{"last padding byte not zero", set(p-1, 0x01), false},
{"P - 1 bytes", padded()[:p-1], false},
{"P + 1 bytes", append(padded(), 0), false},
{"2P bytes", append(padded(), make([]byte, p)...), false},
{"L bytes, without padding", padded()[:l], false},
{"no plaintext", nil, false},
} {
in, err := f.WithPayloadPlaintext(tc.plaintext)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(in.DKC), fx.openOptions(t))
if tc.ok {
if err != nil || !bytes.Equal(out.Bytes(), fx.plaintext) || opened.PayloadLength != uint64(l) {
t.Errorf("%s: %v", tc.name, err)
}
continue
}
expectStep(t, tc.name, failedStep(t, opened.Inspection.Checks, err), err, datekeys.ErrIntegrity, 17)
if out.Len() > 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)
}
}
}
}

Powered by TurnKey Linux.