Files
neural-hunt/cmd/client/identity.go
jbergner bcfbef390f
Some checks failed
release-tag / release-image (push) Failing after 2m44s
RC-7
2026-08-11 16:58:07 +02:00

305 lines
8.5 KiB
Go

package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"neuralhunt/internal/auth"
)
var rawURL = base64.RawURLEncoding
type privateJWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
D string `json:"d"`
Ext bool `json:"ext,omitempty"`
KeyOps []string `json:"key_ops,omitempty"`
Alg string `json:"alg,omitempty"`
Use string `json:"use,omitempty"`
Kid string `json:"kid,omitempty"`
}
type identityFile struct {
Version int `json:"version"`
PublicJWK auth.PublicJWK `json:"publicJwk"`
PrivateJWK privateJWK `json:"privateJwk"`
}
const identityKDFIterations = 250000
type encryptedIdentity struct {
Version int `json:"version"`
Format string `json:"format,omitempty"`
ClientID string `json:"clientId,omitempty"`
KDF string `json:"kdf,omitempty"`
Iterations int `json:"iterations,omitempty"`
Cipher string `json:"cipher,omitempty"`
Salt string `json:"salt"`
IV string `json:"iv"`
Ciphertext string `json:"ciphertext"`
}
func pad32Bytes(b []byte) []byte {
out := make([]byte, 32)
if len(b) > len(out) {
b = b[len(b)-len(out):]
}
copy(out[len(out)-len(b):], b)
return out
}
func generateIdentity() (identityFile, *ecdsa.PrivateKey, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return identityFile{}, nil, err
}
x := rawURL.EncodeToString(pad32Bytes(key.X.Bytes()))
y := rawURL.EncodeToString(pad32Bytes(key.Y.Bytes()))
d := rawURL.EncodeToString(pad32Bytes(key.D.Bytes()))
pub := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: x, Y: y, Ext: true, KeyOps: []string{"verify"}}
priv := privateJWK{Kty: "EC", Crv: "P-256", X: x, Y: y, D: d, Ext: true, KeyOps: []string{"sign"}}
return identityFile{Version: 1, PublicJWK: pub, PrivateJWK: priv}, key, nil
}
func privateKeyFromIdentity(id identityFile) (*ecdsa.PrivateKey, error) {
if id.Version != 1 || id.PrivateJWK.Kty != "EC" || id.PrivateJWK.Crv != "P-256" || id.PrivateJWK.D == "" {
return nil, errors.New("unsupported identity; expected version 1 P-256 JWK")
}
db, err := rawURL.DecodeString(id.PrivateJWK.D)
if err != nil {
return nil, fmt.Errorf("decode private JWK: %w", err)
}
d := new(big.Int).SetBytes(db)
curve := elliptic.P256()
if d.Sign() <= 0 || d.Cmp(curve.Params().N) >= 0 {
return nil, errors.New("invalid P-256 private scalar")
}
x, y := curve.ScalarBaseMult(pad32Bytes(db))
if rawURL.EncodeToString(pad32Bytes(x.Bytes())) != id.PublicJWK.X || rawURL.EncodeToString(pad32Bytes(y.Bytes())) != id.PublicJWK.Y {
return nil, errors.New("identity public/private key mismatch")
}
return &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, D: d}, nil
}
func defaultIdentityPath() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "./neuralhunt-identity.json"
}
return filepath.Join(home, ".neuralhunt", "identity.json")
}
func saveIdentity(path string, id identityFile) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(id, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(path, b, 0o600); err != nil {
return err
}
return os.Chmod(path, 0o600)
}
func loadOrCreateIdentity(path string) (identityFile, *ecdsa.PrivateKey, bool, error) {
b, err := os.ReadFile(path)
if err == nil {
var id identityFile
if err := json.Unmarshal(b, &id); err != nil {
return identityFile{}, nil, false, fmt.Errorf("parse identity %q: %w", path, err)
}
key, err := privateKeyFromIdentity(id)
return id, key, false, err
}
if !os.IsNotExist(err) {
return identityFile{}, nil, false, err
}
id, key, err := generateIdentity()
if err != nil {
return identityFile{}, nil, false, err
}
if err := saveIdentity(path, id); err != nil {
return identityFile{}, nil, false, err
}
return id, key, true, nil
}
func readIdentityImport(path, passphrase string) (identityFile, error) {
b, err := os.ReadFile(path)
if err != nil {
return identityFile{}, err
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(b, &probe); err != nil {
return identityFile{}, err
}
if _, encrypted := probe["ciphertext"]; !encrypted {
var id identityFile
if err := json.Unmarshal(b, &id); err != nil {
return identityFile{}, err
}
if _, err := privateKeyFromIdentity(id); err != nil {
return identityFile{}, err
}
return id, nil
}
if passphrase == "" {
return identityFile{}, errors.New("encrypted browser identity requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
}
var enc encryptedIdentity
if err := json.Unmarshal(b, &enc); err != nil {
return identityFile{}, err
}
if enc.Format != "" && enc.Format != "neuralhunt-identity-export" {
return identityFile{}, errors.New("unsupported identity export format")
}
if enc.KDF != "" && enc.KDF != "PBKDF2-HMAC-SHA256" {
return identityFile{}, errors.New("unsupported identity KDF")
}
if enc.Cipher != "" && enc.Cipher != "AES-256-GCM" {
return identityFile{}, errors.New("unsupported identity cipher")
}
salt, err := rawURL.DecodeString(enc.Salt)
if err != nil {
return identityFile{}, err
}
iv, err := rawURL.DecodeString(enc.IV)
if err != nil {
return identityFile{}, err
}
ct, err := rawURL.DecodeString(enc.Ciphertext)
if err != nil {
return identityFile{}, err
}
iterations := enc.Iterations
if iterations == 0 {
iterations = identityKDFIterations // compatibility with v1 exports
}
if iterations < 100000 || iterations > 2000000 {
return identityFile{}, errors.New("unsupported identity KDF iteration count")
}
key := pbkdf2SHA256([]byte(passphrase), salt, iterations, 32)
block, err := aes.NewCipher(key)
if err != nil {
return identityFile{}, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return identityFile{}, err
}
plain, err := gcm.Open(nil, iv, ct, nil)
if err != nil {
return identityFile{}, errors.New("identity decrypt failed (wrong passphrase or damaged export)")
}
var id identityFile
if err := json.Unmarshal(plain, &id); err != nil {
return identityFile{}, err
}
if _, err := privateKeyFromIdentity(id); err != nil {
return identityFile{}, err
}
if enc.ClientID != "" {
cid, err := auth.ClientID(id.PublicJWK)
if err != nil {
return identityFile{}, err
}
if cid != enc.ClientID {
return identityFile{}, errors.New("identity export client ID mismatch")
}
}
return id, nil
}
func exportBrowserIdentity(path, passphrase string, id identityFile) error {
if passphrase == "" {
return errors.New("export requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
}
if len(passphrase) < 12 {
return errors.New("identity export passphrase must be at least 12 characters")
}
plain, err := json.Marshal(id)
if err != nil {
return err
}
salt := make([]byte, 16)
iv := make([]byte, 12)
if _, err := rand.Read(salt); err != nil {
return err
}
if _, err := rand.Read(iv); err != nil {
return err
}
key := pbkdf2SHA256([]byte(passphrase), salt, identityKDFIterations, 32)
block, err := aes.NewCipher(key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
ct := gcm.Seal(nil, iv, plain, nil)
cid, err := auth.ClientID(id.PublicJWK)
if err != nil {
return err
}
enc := encryptedIdentity{
Version: 1, Format: "neuralhunt-identity-export", ClientID: cid,
KDF: "PBKDF2-HMAC-SHA256", Iterations: identityKDFIterations, Cipher: "AES-256-GCM",
Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct),
}
b, err := json.MarshalIndent(enc, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(path); dir != "." {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
}
return os.WriteFile(path, b, 0o600)
}
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
hLen := sha256.Size
blocks := (keyLen + hLen - 1) / hLen
out := make([]byte, 0, blocks*hLen)
for block := 1; block <= blocks; block++ {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
var n [4]byte
binary.BigEndian.PutUint32(n[:], uint32(block))
mac.Write(n[:])
u := mac.Sum(nil)
t := append([]byte(nil), u...)
for i := 1; i < iterations; i++ {
mac = hmac.New(sha256.New, password)
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
out = append(out, t...)
}
return out[:keyLen]
}