cmd/age-inspect: new command

Fixes #56
Closes #501
This commit is contained in:
Filippo Valsorda
2025-12-23 21:39:26 +01:00
parent ca8a69b1b6
commit d36e4ce2c7
6 changed files with 377 additions and 2 deletions

125
cmd/age-inspect/inspect.go Normal file
View File

@@ -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 = "<stdin>"
}
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)
}

102
doc/age-inspect.1.ronn Normal file
View File

@@ -0,0 +1,102 @@
age-inspect(1) -- inspect age(1) encrypted files
====================================================
## SYNOPSIS
`age-inspect` [`--json`] [<INPUT>]
## DESCRIPTION
`age-inspect` reads an age(1) encrypted file from <INPUT> (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 <age@filippo.io>

View File

@@ -62,7 +62,7 @@ Convert an identity to a recipient:
## SEE ALSO
age(1)
age(1), age-inspect(1)
## AUTHORS

View File

@@ -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

123
internal/inspect/inspect.go Normal file
View File

@@ -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
}

View File

@@ -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