Files
nexus/internal/leviathan/analysis/keys.go
T
2026-06-16 11:51:07 +01:00

368 lines
11 KiB
Go

package analysis
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"strings"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// KeyArtefact is a single key-ish object we've found in the raw file.
// Length is the span we recognised; Note carries any decoding detail.
type KeyArtefact struct {
Kind string `json:"kind"`
Format string `json:"format"` // "pem" | "der" | "ssh" | "p12"
Offset int64 `json:"offset"`
Length int64 `json:"length"`
Summary string `json:"summary"`
Note string `json:"note,omitempty"`
}
// ScanKeys walks the whole document looking for cryptographic artefacts:
// - PEM blocks with common key-related labels
// - OpenSSH keys (authorized_keys lines and the "openssh-key-v1" private-key envelope)
// - PKCS#12 magic (1.2.840.113549.1.12.1 appears in headers)
// - Loose DER SEQUENCEs that parse as RSA/EC/PKCS#8 keys
//
// We cap the scan at 64 MiB. Users with larger blobs can select a range
// and call this against that subset via a future "keys.scanRange" bridge.
func ScanKeys(d *document.Document) ([]KeyArtefact, error) {
size := d.Size()
if size > 64*1024*1024 {
return nil, fmt.Errorf("keys.scan: file > 64 MiB; use selection")
}
buf, err := d.Range(0, size)
if err != nil {
return nil, err
}
out := []KeyArtefact{}
out = append(out, scanPEMKeys(buf)...)
out = append(out, scanOpenSSHPubKeys(buf)...)
out = append(out, scanOpenSSHPrivKeys(buf)...)
out = append(out, scanPKCS12(buf)...)
// Loose DER pass — for raw PKCS#8 / PKCS#1 blobs that aren't wrapped in PEM.
out = append(out, scanLooseDERKeys(buf)...)
return out, nil
}
// Known PEM labels that indicate key-ish content.
var pemKeyLabels = map[string]string{
"RSA PRIVATE KEY": "rsa-private",
"RSA PUBLIC KEY": "rsa-public",
"EC PRIVATE KEY": "ec-private",
"EC PUBLIC KEY": "ec-public",
"DSA PRIVATE KEY": "dsa-private",
"PRIVATE KEY": "pkcs8-private",
"ENCRYPTED PRIVATE KEY": "pkcs8-encrypted",
"PUBLIC KEY": "pkix-public",
"OPENSSH PRIVATE KEY": "openssh-private",
"SSH2 PUBLIC KEY": "ssh2-public",
"PGP PRIVATE KEY BLOCK": "pgp-private",
"PGP PUBLIC KEY BLOCK": "pgp-public",
"CERTIFICATE REQUEST": "csr",
"NEW CERTIFICATE REQUEST": "csr",
"DH PARAMETERS": "dh-params",
"PARAMETERS": "params",
}
func scanPEMKeys(buf []byte) []KeyArtefact {
out := []KeyArtefact{}
rest := buf
origLen := int64(len(buf))
for {
block, next := pem.Decode(rest)
if block == nil {
break
}
beginIdx := bytes.Index(rest, []byte("-----BEGIN"))
if beginIdx < 0 {
break
}
start := origLen - int64(len(rest)) + int64(beginIdx)
end := origLen - int64(len(next))
kind, match := pemKeyLabels[block.Type]
if !match {
rest = next
continue
}
art := KeyArtefact{
Kind: kind, Format: "pem",
Offset: start, Length: end - start,
}
art.Summary = describePEMBlock(block)
out = append(out, art)
rest = next
}
return out
}
func describePEMBlock(b *pem.Block) string {
name := b.Type
switch b.Type {
case "RSA PRIVATE KEY":
if k, err := x509.ParsePKCS1PrivateKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — RSA %d-bit", name, k.N.BitLen())
}
case "RSA PUBLIC KEY":
if k, err := x509.ParsePKCS1PublicKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — RSA %d-bit", name, k.N.BitLen())
}
case "EC PRIVATE KEY":
if k, err := x509.ParseECPrivateKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — curve %s", name, k.Curve.Params().Name)
}
case "PRIVATE KEY":
if k, err := x509.ParsePKCS8PrivateKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — %s", name, describePrivateKey(k))
}
case "PUBLIC KEY":
if k, err := x509.ParsePKIXPublicKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — %s", name, describePublicKey(k))
}
case "OPENSSH PRIVATE KEY":
return fmt.Sprintf("%s (%d B payload)", name, len(b.Bytes))
case "ENCRYPTED PRIVATE KEY":
return fmt.Sprintf("%s — PKCS#8 encrypted (%d B)", name, len(b.Bytes))
}
return fmt.Sprintf("%s (%d B)", name, len(b.Bytes))
}
func describePrivateKey(k any) string {
switch key := k.(type) {
case *rsa.PrivateKey:
return fmt.Sprintf("RSA %d-bit", key.N.BitLen())
case *ecdsa.PrivateKey:
return fmt.Sprintf("ECDSA %s", key.Curve.Params().Name)
case ed25519.PrivateKey:
return "Ed25519"
}
return fmt.Sprintf("%T", k)
}
func describePublicKey(k any) string {
switch key := k.(type) {
case *rsa.PublicKey:
return fmt.Sprintf("RSA %d-bit", key.N.BitLen())
case *ecdsa.PublicKey:
return fmt.Sprintf("ECDSA %s", key.Curve.Params().Name)
case ed25519.PublicKey:
return "Ed25519"
}
return fmt.Sprintf("%T", k)
}
// OpenSSH authorized_keys / id_*.pub format is one line per key:
// "<algo> <base64> [comment]\n". We search for well-known algos and
// delimit by the next newline. False positives on text logs are possible,
// so we validate by base64-decoding.
var sshAlgos = []string{
"ssh-rsa", "ssh-dss", "ssh-ed25519",
"ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com",
}
func scanOpenSSHPubKeys(buf []byte) []KeyArtefact {
out := []KeyArtefact{}
for _, algo := range sshAlgos {
needle := []byte(algo + " ")
off := 0
for {
idx := bytes.Index(buf[off:], needle)
if idx < 0 {
break
}
absStart := off + idx
// Must be at start of line or after whitespace.
if absStart > 0 {
prev := buf[absStart-1]
if prev != '\n' && prev != '\r' && prev != ' ' && prev != '\t' {
off = absStart + 1
continue
}
}
// End of line.
endOfLine := absStart + len(needle)
for endOfLine < len(buf) && buf[endOfLine] != '\n' && buf[endOfLine] != '\r' {
endOfLine++
}
line := buf[absStart:endOfLine]
// Expect at least one space-separated payload that decodes.
parts := bytes.SplitN(line, []byte(" "), 3)
if len(parts) < 2 || len(parts[1]) < 16 || !isBase64Like(parts[1]) {
off = absStart + 1
continue
}
art := KeyArtefact{
Kind: "openssh-public", Format: "ssh",
Offset: int64(absStart), Length: int64(len(line)),
Summary: fmt.Sprintf("%s (%d-byte payload b64)", algo, len(parts[1])),
}
if len(parts) == 3 && len(parts[2]) > 0 {
art.Note = "comment: " + string(parts[2])
}
out = append(out, art)
off = endOfLine
}
}
return out
}
func scanOpenSSHPrivKeys(buf []byte) []KeyArtefact {
// Already caught by PEM scan (label "OPENSSH PRIVATE KEY"). We also
// look for the magic string inside the PEM body — "openssh-key-v1\0".
out := []KeyArtefact{}
needle := []byte("openssh-key-v1\x00")
off := 0
for {
idx := bytes.Index(buf[off:], needle)
if idx < 0 {
break
}
absStart := off + idx
out = append(out, KeyArtefact{
Kind: "openssh-private-magic", Format: "ssh",
Offset: int64(absStart), Length: int64(len(needle)),
Summary: "OpenSSH v1 private-key magic",
Note: "magic only; no envelope length inferred",
})
off = absStart + len(needle)
}
return out
}
// PKCS#12 (.p12 / .pfx) files begin with an ASN.1 SEQUENCE; the second
// element is an OID for the bag type. Its encoded form is reasonably
// distinctive: "\x06\x0b\x2a\x86\x48\x86\xf7\x0d\x01\x07\x01" (data).
// The simplest reliable magic for our purposes is the OID for
// pkcs-12 (1.2.840.113549.1.12.1): "\x06\x0a\x2a\x86\x48\x86\xf7\x0d\x01\x0c\x01".
func scanPKCS12(buf []byte) []KeyArtefact {
out := []KeyArtefact{}
needle := []byte{0x06, 0x0a, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x0c, 0x01}
off := 0
for {
idx := bytes.Index(buf[off:], needle)
if idx < 0 {
break
}
absStart := off + idx
out = append(out, KeyArtefact{
Kind: "pkcs12-oid", Format: "p12",
Offset: int64(absStart), Length: int64(len(needle)),
Summary: "pkcs-12 OID (1.2.840.113549.1.12.1)",
Note: "bag-type marker; may indicate a .p12 or .pfx container",
})
off = absStart + len(needle)
}
return out
}
// scanLooseDERKeys walks DER SEQUENCE headers and tries to parse each
// candidate as a known key format. False negatives are fine; we only
// emit an artefact on a positive parse.
func scanLooseDERKeys(buf []byte) []KeyArtefact {
out := []KeyArtefact{}
// Use the same SEQUENCE 0x30 0x82 xx yy (long-form, 2 length bytes)
// pattern as the cert scanner. Minimum useful key length ~200 bytes.
//
// Each candidate runs up to six x509 parse attempts over a blob up to
// 64 KiB, so an unfiltered scan of a buffer dense with "30 82" pairs is
// O(n) expensive parses and could freeze the UI. A cheap structural
// pre-filter (a DER key body begins with a SEQUENCE 0x30 or an INTEGER
// 0x02) rejects almost all false candidates without parsing, and a hard
// cap bounds adversarial input that defeats the pre-filter.
const maxDERAttempts = 1 << 16
derAttempts := 0
for i := 0; i+4 < len(buf); i++ {
if buf[i] != 0x30 || buf[i+1] != 0x82 {
continue
}
if c := buf[i+4]; c != 0x30 && c != 0x02 {
continue
}
total := int(buf[i+2])<<8 | int(buf[i+3])
if total < 80 || i+4+total > len(buf) {
continue
}
if derAttempts++; derAttempts > maxDERAttempts {
break
}
blob := buf[i : i+4+total]
// Skip anything that looks like a certificate (already caught by certs panel).
if _, err := x509.ParseCertificate(blob); err == nil {
i += 4 + total - 1
continue
}
if _, err := x509.ParsePKCS1PrivateKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "rsa-private", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "RSA private key (PKCS#1 DER)",
})
i += 4 + total - 1
continue
}
if _, err := x509.ParsePKCS1PublicKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "rsa-public", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "RSA public key (PKCS#1 DER)",
})
i += 4 + total - 1
continue
}
if k, err := x509.ParseECPrivateKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "ec-private", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "EC private key (SEC1 DER) curve=" + k.Curve.Params().Name,
})
i += 4 + total - 1
continue
}
if k, err := x509.ParsePKCS8PrivateKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "pkcs8-private", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "PKCS#8 private key — " + describePrivateKey(k),
})
i += 4 + total - 1
continue
}
if k, err := x509.ParsePKIXPublicKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "pkix-public", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "PKIX public key — " + describePublicKey(k),
})
i += 4 + total - 1
continue
}
}
return out
}
func isBase64Like(b []byte) bool {
if len(b) == 0 {
return false
}
for _, c := range b {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '+' || c == '/' || c == '=' {
continue
}
return false
}
return true
}
// Stringer helper used by the bridge; avoids leaking internal types to JS.
func (a KeyArtefact) String() string {
return strings.TrimSpace(fmt.Sprintf("%s %s @0x%x %s", a.Kind, a.Format, a.Offset, a.Summary))
}