mirror of
https://github.com/FiloSottile/age.git
synced 2026-01-08 21:03:05 +00:00
125
cmd/age-inspect/inspect.go
Normal file
125
cmd/age-inspect/inspect.go
Normal 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
102
doc/age-inspect.1.ronn
Normal 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>
|
||||
@@ -62,7 +62,7 @@ Convert an identity to a recipient:
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
age(1)
|
||||
age(1), age-inspect(1)
|
||||
|
||||
## AUTHORS
|
||||
|
||||
|
||||
@@ -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
123
internal/inspect/inspect.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user