2024-04-30 12:04:49 +00:00
import io
2023-08-03 09:07:17 +00:00
import json
2023-07-26 13:21:57 +00:00
import os
import shutil
import subprocess
2023-11-29 11:40:48 +00:00
from collections . abc import Iterator
2023-08-08 11:44:15 +00:00
from contextlib import contextmanager
2023-07-26 13:21:57 +00:00
from pathlib import Path
from tempfile import NamedTemporaryFile
2023-11-29 11:40:48 +00:00
from typing import IO
2023-07-26 13:21:57 +00:00
2024-01-12 16:01:46 +00:00
from . . cmd import Log , run
2023-07-26 13:21:57 +00:00
from . . dirs import user_config_dir
2023-08-03 09:07:17 +00:00
from . . errors import ClanError
2023-07-26 13:21:57 +00:00
from . . nix import nix_shell
2023-09-06 15:30:31 +00:00
from . folders import sops_machines_folder , sops_users_folder
2023-07-26 13:21:57 +00:00
class SopsKey :
2023-08-08 11:44:15 +00:00
def __init__ ( self , pubkey : str , username : str ) - > None :
2023-07-26 13:21:57 +00:00
self . pubkey = pubkey
2023-08-08 11:44:15 +00:00
self . username = username
2023-07-26 13:21:57 +00:00
def get_public_key ( privkey : str ) - > str :
2023-12-08 14:00:11 +00:00
cmd = nix_shell ( [ " nixpkgs#age " ] , [ " age-keygen " , " -y " ] )
2023-08-29 14:20:39 +00:00
try :
2023-11-30 09:49:55 +00:00
res = subprocess . run (
cmd , input = privkey , stdout = subprocess . PIPE , text = True , check = True
)
2023-08-29 14:20:39 +00:00
except subprocess . CalledProcessError as e :
raise ClanError (
" Failed to get public key for age private key. Is the key malformed? "
) from e
2023-07-26 13:21:57 +00:00
return res . stdout . strip ( )
2024-04-19 15:02:02 +00:00
def generate_private_key ( out_file : Path | None = None ) - > tuple [ str , str ] :
2023-12-08 14:00:11 +00:00
cmd = nix_shell ( [ " nixpkgs#age " ] , [ " age-keygen " ] )
2023-09-19 17:19:18 +00:00
try :
2024-01-12 16:01:46 +00:00
proc = run ( cmd )
2023-09-19 17:19:18 +00:00
res = proc . stdout . strip ( )
pubkey = None
private_key = None
for line in res . splitlines ( ) :
if line . startswith ( " # public key: " ) :
pubkey = line . split ( " : " ) [ 1 ] . strip ( )
if not line . startswith ( " # " ) :
private_key = line
if not pubkey :
raise ClanError ( " Could not find public key in age-keygen output " )
if not private_key :
raise ClanError ( " Could not find private key in age-keygen output " )
2024-04-19 15:02:02 +00:00
if out_file :
out_file . parent . mkdir ( parents = True , exist_ok = True )
out_file . write_text ( res )
2023-09-19 17:19:18 +00:00
return private_key , pubkey
except subprocess . CalledProcessError as e :
raise ClanError ( " Failed to generate private sops key " ) from e
2023-07-26 13:21:57 +00:00
2023-11-03 11:51:17 +00:00
def get_user_name ( flake_dir : Path , user : str ) - > str :
2023-07-26 13:21:57 +00:00
""" Ask the user for their name until a unique one is provided. """
while True :
name = input (
2023-08-23 09:58:12 +00:00
f " Your key is not yet added to the repository. Enter your user name for which your sops key will be stored in the repository [default: { user } ]: "
2023-07-26 13:21:57 +00:00
)
if name :
user = name
2023-11-03 11:51:17 +00:00
if not ( flake_dir / user ) . exists ( ) :
2023-07-26 13:21:57 +00:00
return user
2023-11-03 11:51:17 +00:00
print ( f " { flake_dir / user } already exists " )
2023-07-26 13:21:57 +00:00
2023-11-03 11:51:17 +00:00
def ensure_user_or_machine ( flake_dir : Path , pub_key : str ) - > SopsKey :
2023-08-08 11:44:15 +00:00
key = SopsKey ( pub_key , username = " " )
2023-11-03 11:51:17 +00:00
folders = [ sops_users_folder ( flake_dir ) , sops_machines_folder ( flake_dir ) ]
2024-06-21 13:07:46 +00:00
2023-09-06 15:30:31 +00:00
for folder in folders :
if folder . exists ( ) :
for user in folder . iterdir ( ) :
2023-09-07 10:30:29 +00:00
if not ( user / " key.json " ) . exists ( ) :
2023-09-06 15:30:31 +00:00
continue
if read_key ( user ) == pub_key :
key . username = user . name
return key
raise ClanError (
f " Your sops key is not yet added to the repository. Please add it with ' clan secrets users add youruser { pub_key } ' (replace youruser with your user name) "
)
2023-07-26 13:21:57 +00:00
2023-08-08 11:44:15 +00:00
2023-09-06 15:30:31 +00:00
def default_sops_key_path ( ) - > Path :
raw_path = os . environ . get ( " SOPS_AGE_KEY_FILE " )
if raw_path :
return Path ( raw_path )
else :
return user_config_dir ( ) / " sops " / " age " / " keys.txt "
2023-07-26 13:21:57 +00:00
2023-11-03 11:51:17 +00:00
def ensure_sops_key ( flake_dir : Path ) - > SopsKey :
2023-07-26 13:21:57 +00:00
key = os . environ . get ( " SOPS_AGE_KEY " )
if key :
2023-11-03 11:51:17 +00:00
return ensure_user_or_machine ( flake_dir , get_public_key ( key ) )
2023-09-06 15:30:31 +00:00
path = default_sops_key_path ( )
2023-07-26 13:21:57 +00:00
if path . exists ( ) :
2023-11-03 11:51:17 +00:00
return ensure_user_or_machine ( flake_dir , get_public_key ( path . read_text ( ) ) )
2023-09-06 15:30:31 +00:00
else :
raise ClanError (
" No sops key found. Please generate one with ' clan secrets key generate ' . "
)
2023-07-26 13:21:57 +00:00
2023-08-08 11:44:15 +00:00
@contextmanager
def sops_manifest ( keys : list [ str ] ) - > Iterator [ Path ] :
with NamedTemporaryFile ( delete = False , mode = " w " ) as manifest :
json . dump (
dict ( creation_rules = [ dict ( key_groups = [ dict ( age = keys ) ] ) ] ) , manifest , indent = 2
)
manifest . flush ( )
yield Path ( manifest . name )
2024-03-25 10:18:20 +00:00
def update_keys ( secret_path : Path , keys : list [ str ] ) - > list [ Path ] :
2023-08-08 11:44:15 +00:00
with sops_manifest ( keys ) as manifest :
2024-03-25 10:18:20 +00:00
secret_path = secret_path / " secret "
time_before = secret_path . stat ( ) . st_mtime
2023-08-08 11:44:15 +00:00
cmd = nix_shell (
2023-12-08 14:00:11 +00:00
[ " nixpkgs#sops " ] ,
2023-08-08 11:44:15 +00:00
[
" sops " ,
" --config " ,
str ( manifest ) ,
" updatekeys " ,
" --yes " ,
2024-03-25 10:18:20 +00:00
str ( secret_path ) ,
2023-08-08 11:44:15 +00:00
] ,
)
2024-01-12 16:01:46 +00:00
run ( cmd , log = Log . BOTH , error_msg = f " Could not update keys for { secret_path } " )
2024-03-25 10:18:20 +00:00
if time_before == secret_path . stat ( ) . st_mtime :
return [ ]
return [ secret_path ]
2023-08-08 11:44:15 +00:00
2023-08-08 13:48:19 +00:00
def encrypt_file (
2024-03-03 08:18:45 +00:00
secret_path : Path , content : IO [ str ] | str | bytes | None , keys : list [ str ]
2023-08-08 13:48:19 +00:00
) - > None :
2023-07-26 13:21:57 +00:00
folder = secret_path . parent
folder . mkdir ( parents = True , exist_ok = True )
2023-09-13 08:51:50 +00:00
with sops_manifest ( keys ) as manifest :
if not content :
2023-08-09 08:17:11 +00:00
args = [ " sops " , " --config " , str ( manifest ) ]
2023-09-13 08:51:50 +00:00
args . extend ( [ str ( secret_path ) ] )
2023-12-08 14:00:11 +00:00
cmd = nix_shell ( [ " nixpkgs#sops " ] , args )
2024-02-20 10:05:52 +00:00
# Don't use our `run` here, because it breaks editor integration.
# We never need this in our UI.
p = subprocess . run ( cmd , check = False )
2023-09-13 08:51:50 +00:00
# returns 200 if the file is changed
if p . returncode != 0 and p . returncode != 200 :
raise ClanError (
f " Failed to encrypt { secret_path } : sops exited with { p . returncode } "
)
return
# hopefully /tmp is written to an in-memory file to avoid leaking secrets
with NamedTemporaryFile ( delete = False ) as f :
2023-07-26 13:21:57 +00:00
try :
2024-03-03 08:18:45 +00:00
if isinstance ( content , str ) :
with open ( f . name , " w " ) as fd :
2023-09-13 08:51:50 +00:00
fd . write ( content )
2024-03-03 08:18:45 +00:00
elif isinstance ( content , bytes ) :
with open ( f . name , " wb " ) as fd :
fd . write ( content )
2024-04-30 12:04:49 +00:00
elif isinstance ( content , io . IOBase ) :
2024-03-03 08:18:45 +00:00
with open ( f . name , " w " ) as fd :
2023-09-13 08:51:50 +00:00
shutil . copyfileobj ( content , fd )
2024-03-03 08:18:45 +00:00
else :
2024-04-30 11:56:07 +00:00
raise ClanError ( f " Invalid content type: { type ( content ) } " )
2023-09-13 08:51:50 +00:00
# we pass an empty manifest to pick up existing configuration of the user
args = [ " sops " , " --config " , str ( manifest ) ]
args . extend ( [ " -i " , " --encrypt " , str ( f . name ) ] )
2023-12-08 14:00:11 +00:00
cmd = nix_shell ( [ " nixpkgs#sops " ] , args )
2024-01-12 16:01:46 +00:00
run ( cmd , log = Log . BOTH )
2023-09-13 08:51:50 +00:00
# atomic copy of the encrypted file
with NamedTemporaryFile ( dir = folder , delete = False ) as f2 :
shutil . copyfile ( f . name , f2 . name )
os . rename ( f2 . name , secret_path )
finally :
try :
os . remove ( f . name )
except OSError :
pass
2023-08-03 09:07:17 +00:00
2023-08-08 13:48:19 +00:00
def decrypt_file ( secret_path : Path ) - > str :
2023-08-10 10:05:17 +00:00
with sops_manifest ( [ ] ) as manifest :
cmd = nix_shell (
2023-12-08 14:00:11 +00:00
[ " nixpkgs#sops " ] ,
[ " sops " , " --config " , str ( manifest ) , " --decrypt " , str ( secret_path ) ] ,
2023-08-10 10:05:17 +00:00
)
2024-01-12 16:01:46 +00:00
res = run ( cmd , error_msg = f " Could not decrypt { secret_path } " )
2023-08-08 13:48:19 +00:00
return res . stdout
2023-08-03 12:58:05 +00:00
def write_key ( path : Path , publickey : str , overwrite : bool ) - > None :
2023-08-03 09:07:17 +00:00
path . mkdir ( parents = True , exist_ok = True )
try :
flags = os . O_CREAT | os . O_WRONLY | os . O_TRUNC
if not overwrite :
flags | = os . O_EXCL
fd = os . open ( path / " key.json " , flags )
except FileExistsError :
2024-03-25 10:18:20 +00:00
raise ClanError (
f " { path . name } already exists in { path } . Use --force to overwrite. "
)
2023-08-03 09:07:17 +00:00
with os . fdopen ( fd , " w " ) as f :
json . dump ( { " publickey " : publickey , " type " : " age " } , f , indent = 2 )
def read_key ( path : Path ) - > str :
with open ( path / " key.json " ) as f :
try :
key = json . load ( f )
except json . JSONDecodeError as e :
raise ClanError ( f " Failed to decode { path . name } : { e } " )
if key [ " type " ] != " age " :
raise ClanError (
f " { path . name } is not an age key but { key [ ' type ' ] } . This is not supported "
)
publickey = key . get ( " publickey " )
if not publickey :
raise ClanError ( f " { path . name } does not contain a public key " )
return publickey