This repository has no description
6.1 kB
171 lines
1#!/usr/bin/env python3
2"""
3Dummy age-plugin-se for Linux.
4
5This plugin allows encrypting secrets to Secure Enclave recipients (age1se... addresses)
6from Linux machines. The encrypted files can then be decrypted on macOS using the real
7age-plugin-se with hardware Secure Enclave support.
8
9The crypto is identical to the real plugin - only the key storage differs:
10- Real plugin: Private keys stored in Apple Secure Enclave hardware
11- This plugin: Only supports encryption (no private key storage)
12"""
13
14import sys
15import io
16import base64
17import hashlib
18from cryptography.hazmat.primitives.asymmetric import ec
19from cryptography.hazmat.primitives import hashes, serialization
20from cryptography.hazmat.primitives.kdf.hkdf import HKDF
21from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
22
23
24def bech32_decode(s):
25 """Simple bech32 decode for age1se addresses."""
26 charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
27 hrp_end = s.rfind('1')
28 if hrp_end < 1:
29 return None
30 data = s[hrp_end + 1:]
31 decoded = []
32 for c in data:
33 if c not in charset:
34 return None
35 decoded.append(charset.index(c))
36 # Convert from 5-bit to 8-bit groups
37 bits = 0
38 value = 0
39 result = []
40 for d in decoded[:-6]: # Skip checksum
41 value = (value << 5) | d
42 bits += 5
43 if bits >= 8:
44 bits -= 8
45 result.append((value >> bits) & 0xff)
46 value &= (1 << bits) - 1
47 return bytes(result)
48
49
50def write_stdout(s):
51 """Write to stdout with explicit flush (unbuffered)."""
52 stdout_raw.write(s.encode('utf-8'))
53 stdout_raw.flush()
54
55
56def handle_recipient_v1():
57 """Handle the age plugin recipient-v1 protocol."""
58 file_keys = []
59 recipients = []
60 got_done = False
61
62 # Read commands until -> done
63 while True:
64 line = sys.stdin.readline()
65 if not line: # EOF
66 break
67 line = line.strip()
68
69 if line.startswith("-> add-recipient "):
70 recipients.append(line[17:])
71 elif line == "-> wrap-file-key":
72 # Read body lines until empty line OR next command
73 body_parts = []
74 while True:
75 body_line = sys.stdin.readline()
76 if not body_line:
77 break
78 body_line_stripped = body_line.strip()
79 if body_line_stripped == "":
80 break # Empty line terminates body
81 if body_line_stripped.startswith("->"):
82 # Next command encountered
83 if body_line_stripped == "-> done":
84 got_done = True
85 break
86 body_parts.append(body_line_stripped)
87 file_keys.append("".join(body_parts))
88 if got_done:
89 break
90 elif line == "-> done":
91 got_done = True
92 break
93 # Ignore other lines (grease commands, extension-labels, etc.)
94
95 if not got_done:
96 sys.exit(1)
97
98 # Process each file key for each recipient
99 for file_idx, file_key_b64 in enumerate(file_keys):
100 for recipient in recipients:
101 try:
102 # Decode recipient public key (compressed P-256, 33 bytes)
103 pubkey_bytes = bech32_decode(recipient)
104 if not pubkey_bytes:
105 raise ValueError("Failed to decode recipient")
106
107 # Generate tag from public key hash (first 4 bytes of SHA256)
108 tag = base64.b64encode(
109 hashlib.sha256(pubkey_bytes).digest()[:4]
110 ).decode().rstrip('=')
111
112 # Decode file key (add padding for base64)
113 padding = (4 - len(file_key_b64) % 4) % 4
114 file_key = base64.b64decode(file_key_b64 + '=' * padding)
115
116 # Load P-256 public key
117 recipient_pubkey = ec.EllipticCurvePublicKey.from_encoded_point(
118 ec.SECP256R1(), pubkey_bytes
119 )
120
121 # Generate ephemeral key pair
122 ephemeral_private = ec.generate_private_key(ec.SECP256R1())
123 ephemeral_public = ephemeral_private.public_key()
124 ephemeral_bytes = ephemeral_public.public_bytes(
125 serialization.Encoding.X962,
126 serialization.PublicFormat.CompressedPoint
127 )
128
129 # ECDH shared secret
130 shared = ephemeral_private.exchange(ec.ECDH(), recipient_pubkey)
131
132 # HKDF to derive wrapping key (matches age-plugin-se format)
133 # salt = ephemeral || recipient_pubkey, info = "piv-p256"
134 hkdf_salt = ephemeral_bytes + pubkey_bytes
135 wrapping_key = HKDF(
136 algorithm=hashes.SHA256(),
137 length=32,
138 salt=hkdf_salt,
139 info=b"piv-p256",
140 ).derive(shared)
141
142 # Encrypt with ChaCha20-Poly1305 (zero nonce, no AAD)
143 cipher = ChaCha20Poly1305(wrapping_key)
144 encrypted = cipher.encrypt(b'\x00' * 12, file_key, None)
145
146 # Format output (base64 without padding)
147 ephemeral_b64 = base64.b64encode(ephemeral_bytes).decode().rstrip('=')
148 encrypted_b64 = base64.b64encode(encrypted).decode().rstrip('=')
149
150 # Output stanza (header + body)
151 header = f"-> recipient-stanza {file_idx} piv-p256 {tag} {ephemeral_b64}"
152 write_stdout(header + '\n')
153 write_stdout(encrypted_b64 + '\n')
154
155 except Exception:
156 pass # Silently skip failed recipients
157
158 # Signal completion with empty line after done (required by plugin protocol)
159 write_stdout("-> done\n\n")
160 sys.exit(0)
161
162
163if __name__ == "__main__":
164 # Use unbuffered stdout for reliable plugin communication
165 stdout_raw = io.FileIO(sys.stdout.fileno(), mode='w', closefd=False)
166
167 if len(sys.argv) > 1 and sys.argv[1] == '--age-plugin=recipient-v1':
168 handle_recipient_v1()
169 else:
170 # Unknown mode
171 sys.exit(1)