diff --git a/cmd/age-inspect/inspect.go b/cmd/age-inspect/inspect.go new file mode 100644 index 0000000..aa48b4f --- /dev/null +++ b/cmd/age-inspect/inspect.go @@ -0,0 +1,125 @@ +// Copyright 2025 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + "runtime/debug" + + "filippo.io/age/internal/inspect" +) + +const usage = `Usage: + age-inspect [--json] [INPUT] + +Options: + --json Output machine-readable JSON. + +INPUT defaults to standard input. "-" may be used as INPUT to explicitly +read from standard input.` + +func main() { + flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } + + var ( + versionFlag bool + jsonFlag bool + ) + + flag.BoolVar(&versionFlag, "version", false, "print the version") + flag.BoolVar(&jsonFlag, "json", false, "output machine-readable JSON") + flag.Parse() + + if versionFlag { + if buildInfo, ok := debug.ReadBuildInfo(); ok { + fmt.Println(buildInfo.Main.Version) + return + } + fmt.Println("(unknown)") + return + } + + if flag.NArg() > 1 { + flag.Usage() + os.Exit(1) + } + + in := os.Stdin + var fileSize int64 = -1 + if name := flag.Arg(0); name != "" && name != "-" { + f, err := os.Open(name) + if err != nil { + errorf("failed to open input file %q: %v", name, err) + } + defer f.Close() + in = f + if stat, err := f.Stat(); err == nil && stat.Mode().IsRegular() { + fileSize = stat.Size() + } + } + + data, err := inspect.Inspect(in, fileSize) + if err != nil { + errorf("inspection failed: %v", err) + } + + if jsonFlag { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(data); err != nil { + errorf("failed to encode JSON output: %v", err) + } + } else { + name := flag.Arg(0) + if name == "" { + name = "" + } + fmt.Printf("%s is an age file, version %q.\n", name, data.Version) + fmt.Printf("\n") + if data.Armor { + fmt.Printf("This file is ASCII-armored.\n") + fmt.Printf("\n") + } + fmt.Printf("This file is encrypted to the following recipient types:\n") + for _, t := range data.StanzaTypes { + fmt.Printf(" - %q\n", t) + } + fmt.Printf("\n") + switch data.Postquantum { + case "yes": + fmt.Printf("This file uses post-quantum encryption.\n") + fmt.Printf("\n") + case "no": + fmt.Printf("This file does NOT use post-quantum encryption.\n") + fmt.Printf("\n") + } + fmt.Printf("Size breakdown (assuming it decrypts successfully):\n") + fmt.Printf("\n") + fmt.Printf(" Header % 12d bytes\n", data.Sizes.Header) + if data.Armor { + fmt.Printf(" Armor overhead % 12d bytes\n", data.Sizes.Armor) + } + fmt.Printf(" Encryption overhead % 12d bytes\n", data.Sizes.Overhead) + fmt.Printf(" Payload % 12d bytes\n", data.Sizes.MinPayload) + fmt.Printf(" -------------------\n") + total := data.Sizes.Header + data.Sizes.Overhead + data.Sizes.MinPayload + data.Sizes.Armor + fmt.Printf(" Total % 12d bytes\n", total) + fmt.Printf("\n") + fmt.Printf("Tip: for machine-readable output, use --json.\n") + } +} + +// l is a logger with no prefixes. +var l = log.New(os.Stderr, "", 0) + +func errorf(format string, v ...interface{}) { + l.Printf("age-inspect: error: "+format, v...) + l.Printf("age-inspect: report unexpected or unhelpful errors at https://filippo.io/age/report") + os.Exit(1) +} diff --git a/doc/age-inspect.1.ronn b/doc/age-inspect.1.ronn new file mode 100644 index 0000000..0135bcb --- /dev/null +++ b/doc/age-inspect.1.ronn @@ -0,0 +1,102 @@ +age-inspect(1) -- inspect age(1) encrypted files +==================================================== + +## SYNOPSIS + +`age-inspect` [`--json`] [] + +## DESCRIPTION + +`age-inspect` reads an age(1) encrypted file from (or standard input) +and displays metadata about it without decrypting. + +This includes the recipient types, whether it uses post-quantum encryption, +and a size breakdown of the file components. + +## OPTIONS + +* `--json`: + Output machine-readable JSON instead of human-readable text. + +* `--version`: + Print the version and exit. + +## JSON FORMAT + +When `--json` is specified, the output is a JSON object with these fields: + +* `version`: + The age format version (e.g., `"age-encryption.org/v1"`). + +* `postquantum`: + Whether the file uses post-quantum encryption: `"yes"`, `"no"`, or + `"unknown"`. + +* `armor`: + Boolean indicating whether the file is ASCII-armored. + +* `stanza_types`: + Array of recipient stanza type strings (e.g., `["X25519"]` or + `["mlkem768x25519"]`). + +* `sizes`: + Object containing size information in bytes: + + * `header`: Size of the age header. + * `armor`: Armor encoding overhead (0 if not armored). + * `overhead`: Stream encryption overhead. + * `min_payload`, `max_payload`: Payload size bounds (currently always matching). + * `min_padding`, `max_padding`: Padding size bounds (currently always 0). + + The fields add up to the total size of the file. + +## EXAMPLES + +Inspect an encrypted file: + + $ age-inspect secrets.age + secrets.age is an age file, version "age-encryption.org/v1". + + This file is encrypted to the following recipient types: + - "mlkem768x25519" + + This file uses post-quantum encryption. + + Size breakdown (assuming it decrypts successfully): + + Header 1627 bytes + Encryption overhead 32 bytes + Payload 42 bytes + ------------------- + Total 1701 bytes + + Tip: for machine-readable output, use --json. + +Get JSON output for scripting: + + $ age-inspect --json secrets.age + { + "version": "age-encryption.org/v1", + "postquantum": "yes", + "armor": false, + "stanza_types": [ + "mlkem768x25519" + ], + "sizes": { + "header": 1627, + "armor": 0, + "overhead": 32, + "min_payload": 42, + "max_payload": 42, + "min_padding": 0, + "max_padding": 0 + } + } + +## SEE ALSO + +age(1), age-keygen(1) + +## AUTHORS + +Filippo Valsorda diff --git a/doc/age-keygen.1.ronn b/doc/age-keygen.1.ronn index 6bef7b6..da1b63c 100644 --- a/doc/age-keygen.1.ronn +++ b/doc/age-keygen.1.ronn @@ -62,7 +62,7 @@ Convert an identity to a recipient: ## SEE ALSO -age(1) +age(1), age-inspect(1) ## AUTHORS diff --git a/doc/age.1.ronn b/doc/age.1.ronn index ef76785..e2662f2 100644 --- a/doc/age.1.ronn +++ b/doc/age.1.ronn @@ -335,7 +335,7 @@ Encrypt to the SSH keys of a GitHub user: ## SEE ALSO -age-keygen(1) +age-keygen(1), age-inspect(1) ## AUTHORS diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go new file mode 100644 index 0000000..9e63447 --- /dev/null +++ b/internal/inspect/inspect.go @@ -0,0 +1,123 @@ +package inspect + +import ( + "bufio" + "bytes" + "fmt" + "io" + + "filippo.io/age/armor" + "filippo.io/age/internal/format" + "filippo.io/age/internal/stream" + "golang.org/x/crypto/chacha20poly1305" +) + +type Metadata struct { + Version string `json:"version"` + Postquantum string `json:"postquantum"` // "yes" or "no" or "unknown" + Armor bool `json:"armor"` + StanzaTypes []string `json:"stanza_types"` + Sizes struct { + Header int64 `json:"header"` + Armor int64 `json:"armor"` + Overhead int64 `json:"overhead"` + // Currently, we don't do any padding, not MinPayload == MaxPayload and + // MinPadding == MaxPadding == 0, but that might change in the future. + MinPayload int64 `json:"min_payload"` + MaxPayload int64 `json:"max_payload"` + MinPadding int64 `json:"min_padding"` + MaxPadding int64 `json:"max_padding"` + } `json:"sizes"` +} + +func Inspect(r io.Reader, fileSize int64) (*Metadata, error) { + data := &Metadata{ + Version: "age-encryption.org/v1", + Postquantum: "unknown", + } + + tr := &trackReader{r: r} + br := bufio.NewReader(tr) + if start, _ := br.Peek(len(armor.Header)); string(start) == armor.Header { + r = armor.NewReader(br) + data.Armor = true + } else { + r = br + } + + hdr, rest, err := format.Parse(r) + if err != nil { + return nil, fmt.Errorf("failed to read header: %w", err) + } + + buf := &bytes.Buffer{} + if err := hdr.Marshal(buf); err != nil { + return nil, fmt.Errorf("failed to re-serialize header: %w", err) + } + data.Sizes.Header = int64(buf.Len()) + + for _, s := range hdr.Recipients { + data.StanzaTypes = append(data.StanzaTypes, s.Type) + switch s.Type { + case "X25519", "ssh-rsa", "ssh-ed25519", "age-encryption.org/p256tag", "piv-p256": + data.Postquantum = "no" + case "mlkem768x25519", "scrypt", "age-encryption.org/mlkem768p256tag": + if data.Postquantum != "no" { + data.Postquantum = "yes" + } + } + } + + // If fileSize is not provided, or if it's the size of the armored file + // (which can have LF or CRLF line endings, varying its size), read to + // the end to determine it. + if fileSize == -1 || data.Armor { + n, err := io.Copy(io.Discard, rest) + if err != nil { + return nil, fmt.Errorf("failed to read rest of file: %w", err) + } + fileSize = data.Sizes.Header + n + if !tr.done { + panic("trackReader not done after io.Copy") + } + if tr.count != fileSize && !data.Armor { + panic("trackReader count mismatch") + } + data.Sizes.Armor = tr.count - fileSize + } + data.Sizes.Overhead = streamOverhead(fileSize - data.Sizes.Header) + if data.Sizes.Overhead > fileSize-data.Sizes.Header { + return nil, fmt.Errorf("payload too small to be a valid age file") + } + data.Sizes.MinPayload = fileSize - data.Sizes.Header - data.Sizes.Overhead + data.Sizes.MaxPayload = data.Sizes.MinPayload + return data, nil +} + +type trackReader struct { + r io.Reader + count int64 + done bool +} + +func (tr *trackReader) Read(p []byte) (int, error) { + n, err := tr.r.Read(p) + tr.count += int64(n) + if err == io.EOF { + tr.done = true + } else if tr.done { + panic("non-EOF read after EOF") + } + return n, err +} + +func streamOverhead(payloadSize int64) int64 { + const streamNonceSize = 16 + const encChunkSize = stream.ChunkSize + chacha20poly1305.Overhead + payloadSize -= streamNonceSize + if payloadSize <= 0 { + return streamNonceSize + } + chunks := (payloadSize + encChunkSize - 1) / encChunkSize + return streamNonceSize + chunks*chacha20poly1305.Overhead +} diff --git a/testkit_test.go b/testkit_test.go index 8c29b24..03cf497 100644 --- a/testkit_test.go +++ b/testkit_test.go @@ -21,6 +21,7 @@ import ( "filippo.io/age" "filippo.io/age/armor" "filippo.io/age/internal/format" + "filippo.io/age/internal/inspect" "filippo.io/age/internal/stream" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/hkdf" @@ -197,6 +198,30 @@ func testVector(t *testing.T, v *vector) { if sha256.Sum256(out) != *v.payloadHash { t.Error("payload hash mismatch") } + for _, fileSize := range []int64{int64(len(v.file)), -1} { + metadata, err := inspect.Inspect(bytes.NewReader(v.file), fileSize) + if err != nil { + t.Fatalf("inspect failed: %v", err) + } + if metadata.Armor != v.armored { + t.Errorf("unexpected armor: %v", metadata.Armor) + } + if metadata.Armor && metadata.Sizes.Armor == 0 { + t.Errorf("expected non-zero armor size") + } + if metadata.Sizes.Armor+metadata.Sizes.Header+metadata.Sizes.Overhead+metadata.Sizes.MinPayload != int64(len(v.file)) { + t.Errorf("size breakdown does not add up to file size") + } + if metadata.Sizes.MinPayload != int64(len(out)) { + t.Errorf("unexpected payload size: got %d, want %d", metadata.Sizes.MinPayload, len(out)) + } + if metadata.Sizes.MaxPayload != metadata.Sizes.MinPayload { + t.Errorf("unexpected max payload size: got %d, want %d", metadata.Sizes.MaxPayload, metadata.Sizes.MinPayload) + } + if metadata.Sizes.MinPadding != 0 || metadata.Sizes.MaxPadding != 0 { + t.Errorf("unexpected padding sizes: got min %d max %d, want 0", metadata.Sizes.MinPadding, metadata.Sizes.MaxPadding) + } + } } // TestVectorsRoundTrip checks that any (valid) armor, header, and/or STREAM