certificate.go raw
1 package find
2
3 import (
4 "crypto/rand"
5 "fmt"
6 "time"
7
8 "next.orly.dev/pkg/nostr/encoders/event"
9 "next.orly.dev/pkg/nostr/encoders/hex"
10 "next.orly.dev/pkg/nostr/interfaces/signer"
11 )
12
13 // GenerateChallenge generates a random 32-byte challenge token
14 func GenerateChallenge() (string, error) {
15 challenge := make([]byte, 32)
16 if _, err := rand.Read(challenge); err != nil {
17 return "", fmt.Errorf("failed to generate random challenge: %w", err)
18 }
19 return hex.Enc(challenge), nil
20 }
21
22 // CreateChallengeTXTRecord creates a TXT record event for challenge-response verification
23 func CreateChallengeTXTRecord(name, challenge string, ttl int, signer signer.I) (*event.E, error) {
24 // Normalize name
25 name = NormalizeName(name)
26
27 // Validate name
28 if err := ValidateName(name); err != nil {
29 return nil, fmt.Errorf("invalid name: %w", err)
30 }
31
32 // Create TXT record value
33 txtValue := fmt.Sprintf("_nostr-challenge=%s", challenge)
34
35 // Create the TXT record event
36 record, err := NewNameRecord(name, RecordTypeTXT, txtValue, ttl, signer)
37 if err != nil {
38 return nil, fmt.Errorf("failed to create challenge TXT record: %w", err)
39 }
40
41 return record, nil
42 }
43
44 // ExtractChallengeFromTXTRecord extracts the challenge token from a TXT record value
45 func ExtractChallengeFromTXTRecord(txtValue string) (string, error) {
46 const prefix = "_nostr-challenge="
47
48 if len(txtValue) < len(prefix) {
49 return "", fmt.Errorf("TXT record too short")
50 }
51
52 if txtValue[:len(prefix)] != prefix {
53 return "", fmt.Errorf("not a challenge TXT record")
54 }
55
56 challenge := txtValue[len(prefix):]
57 if len(challenge) != 64 { // 32 bytes in hex = 64 characters
58 return "", fmt.Errorf("invalid challenge length: %d", len(challenge))
59 }
60
61 return challenge, nil
62 }
63
64 // CreateChallengeProof creates a challenge proof signature
65 func CreateChallengeProof(challenge, name, certPubkey string, validUntil time.Time, signer signer.I) (string, error) {
66 // Normalize name
67 name = NormalizeName(name)
68
69 // Sign the challenge proof
70 proof, err := SignChallengeProof(challenge, name, certPubkey, validUntil, signer)
71 if err != nil {
72 return "", fmt.Errorf("failed to create challenge proof: %w", err)
73 }
74
75 return proof, nil
76 }
77
78 // RequestWitnessSignature creates a witness signature for a certificate
79 // This would typically be called by a witness service
80 func RequestWitnessSignature(cert *Certificate, witnessSigner signer.I) (WitnessSignature, error) {
81 // Sign the witness message
82 sig, err := SignWitnessMessage(cert.CertPubkey, cert.Name,
83 cert.ValidFrom, cert.ValidUntil, cert.Challenge, witnessSigner)
84 if err != nil {
85 return WitnessSignature{}, fmt.Errorf("failed to create witness signature: %w", err)
86 }
87
88 // Get witness pubkey
89 witnessPubkey := hex.Enc(witnessSigner.Pub())
90
91 return WitnessSignature{
92 Pubkey: witnessPubkey,
93 Signature: sig,
94 }, nil
95 }
96
97 // PrepareCertificateRequest prepares all the data needed for a certificate request
98 type CertificateRequest struct {
99 Name string
100 CertPubkey string
101 ValidFrom time.Time
102 ValidUntil time.Time
103 Challenge string
104 ChallengeProof string
105 }
106
107 // CreateCertificateRequest creates a certificate request with challenge-response
108 func CreateCertificateRequest(name, certPubkey string, validityDuration time.Duration,
109 challenge string, ownerSigner signer.I) (*CertificateRequest, error) {
110
111 // Normalize name
112 name = NormalizeName(name)
113
114 // Validate name
115 if err := ValidateName(name); err != nil {
116 return nil, fmt.Errorf("invalid name: %w", err)
117 }
118
119 // Set validity period
120 validFrom := time.Now()
121 validUntil := validFrom.Add(validityDuration)
122
123 // Create challenge proof
124 proof, err := CreateChallengeProof(challenge, name, certPubkey, validUntil, ownerSigner)
125 if err != nil {
126 return nil, fmt.Errorf("failed to create challenge proof: %w", err)
127 }
128
129 return &CertificateRequest{
130 Name: name,
131 CertPubkey: certPubkey,
132 ValidFrom: validFrom,
133 ValidUntil: validUntil,
134 Challenge: challenge,
135 ChallengeProof: proof,
136 }, nil
137 }
138
139 // CreateCertificateWithWitnesses creates a complete certificate event with witness signatures
140 func CreateCertificateWithWitnesses(req *CertificateRequest, witnesses []WitnessSignature,
141 algorithm, usage string, ownerSigner signer.I) (*event.E, error) {
142
143 // Create the certificate event
144 certEvent, err := NewCertificate(
145 req.Name,
146 req.CertPubkey,
147 req.ValidFrom,
148 req.ValidUntil,
149 req.Challenge,
150 req.ChallengeProof,
151 witnesses,
152 algorithm,
153 usage,
154 ownerSigner,
155 )
156 if err != nil {
157 return nil, fmt.Errorf("failed to create certificate: %w", err)
158 }
159
160 return certEvent, nil
161 }
162
163 // VerifyChallengeTXTRecord verifies that a TXT record contains the expected challenge
164 func VerifyChallengeTXTRecord(record *NameRecord, expectedChallenge string, nameOwner string) error {
165 // Check record type
166 if record.Type != RecordTypeTXT {
167 return fmt.Errorf("not a TXT record: %s", record.Type)
168 }
169
170 // Check record owner matches name owner
171 recordOwner := hex.Enc(record.Event.Pubkey)
172 if recordOwner != nameOwner {
173 return fmt.Errorf("record owner %s != name owner %s", recordOwner, nameOwner)
174 }
175
176 // Extract challenge from TXT record
177 challenge, err := ExtractChallengeFromTXTRecord(record.Value)
178 if err != nil {
179 return fmt.Errorf("failed to extract challenge: %w", err)
180 }
181
182 // Verify challenge matches
183 if challenge != expectedChallenge {
184 return fmt.Errorf("challenge mismatch: got %s, expected %s", challenge, expectedChallenge)
185 }
186
187 return nil
188 }
189
190 // IssueCertificate is a helper that goes through the full certificate issuance process
191 // This would typically be used by a name owner to request a certificate
192 func IssueCertificate(name, certPubkey string, validityDuration time.Duration,
193 ownerSigner signer.I, witnessSigners []signer.I) (*Certificate, error) {
194
195 // Generate challenge
196 challenge, err := GenerateChallenge()
197 if err != nil {
198 return nil, fmt.Errorf("failed to generate challenge: %w", err)
199 }
200
201 // Create certificate request
202 req, err := CreateCertificateRequest(name, certPubkey, validityDuration, challenge, ownerSigner)
203 if err != nil {
204 return nil, fmt.Errorf("failed to create certificate request: %w", err)
205 }
206
207 // Collect witness signatures
208 var witnesses []WitnessSignature
209 for i, ws := range witnessSigners {
210 // Create temporary certificate for witness to sign
211 tempCert := &Certificate{
212 Name: req.Name,
213 CertPubkey: req.CertPubkey,
214 ValidFrom: req.ValidFrom,
215 ValidUntil: req.ValidUntil,
216 Challenge: req.Challenge,
217 }
218
219 witness, err := RequestWitnessSignature(tempCert, ws)
220 if err != nil {
221 return nil, fmt.Errorf("failed to get witness %d signature: %w", i, err)
222 }
223
224 witnesses = append(witnesses, witness)
225 }
226
227 // Create certificate event
228 certEvent, err := CreateCertificateWithWitnesses(req, witnesses, "secp256k1-schnorr", "tls-replacement", ownerSigner)
229 if err != nil {
230 return nil, fmt.Errorf("failed to create certificate event: %w", err)
231 }
232
233 // Parse back to Certificate struct
234 cert, err := ParseCertificate(certEvent)
235 if err != nil {
236 return nil, fmt.Errorf("failed to parse certificate: %w", err)
237 }
238
239 return cert, nil
240 }
241
242 // RenewCertificate creates a renewed certificate with a new validity period
243 func RenewCertificate(oldCert *Certificate, newValidityDuration time.Duration,
244 ownerSigner signer.I, witnessSigners []signer.I) (*Certificate, error) {
245
246 // Generate new challenge
247 challenge, err := GenerateChallenge()
248 if err != nil {
249 return nil, fmt.Errorf("failed to generate challenge: %w", err)
250 }
251
252 // Set new validity period (with 7-day overlap)
253 validFrom := oldCert.ValidUntil.Add(-7 * 24 * time.Hour)
254 validUntil := validFrom.Add(newValidityDuration)
255
256 // Create challenge proof
257 proof, err := CreateChallengeProof(challenge, oldCert.Name, oldCert.CertPubkey, validUntil, ownerSigner)
258 if err != nil {
259 return nil, fmt.Errorf("failed to create challenge proof: %w", err)
260 }
261
262 // Create request
263 req := &CertificateRequest{
264 Name: oldCert.Name,
265 CertPubkey: oldCert.CertPubkey,
266 ValidFrom: validFrom,
267 ValidUntil: validUntil,
268 Challenge: challenge,
269 ChallengeProof: proof,
270 }
271
272 // Collect witness signatures
273 var witnesses []WitnessSignature
274 for i, ws := range witnessSigners {
275 tempCert := &Certificate{
276 Name: req.Name,
277 CertPubkey: req.CertPubkey,
278 ValidFrom: req.ValidFrom,
279 ValidUntil: req.ValidUntil,
280 Challenge: req.Challenge,
281 }
282
283 witness, err := RequestWitnessSignature(tempCert, ws)
284 if err != nil {
285 return nil, fmt.Errorf("failed to get witness %d signature: %w", i, err)
286 }
287
288 witnesses = append(witnesses, witness)
289 }
290
291 // Create certificate event
292 certEvent, err := CreateCertificateWithWitnesses(req, witnesses, oldCert.Algorithm, oldCert.Usage, ownerSigner)
293 if err != nil {
294 return nil, fmt.Errorf("failed to create certificate event: %w", err)
295 }
296
297 // Parse back to Certificate struct
298 cert, err := ParseCertificate(certEvent)
299 if err != nil {
300 return nil, fmt.Errorf("failed to parse certificate: %w", err)
301 }
302
303 return cert, nil
304 }
305
306 // CheckCertificateExpiry returns the time until expiration, or error if expired
307 func CheckCertificateExpiry(cert *Certificate) (time.Duration, error) {
308 now := time.Now()
309
310 if now.After(cert.ValidUntil) {
311 return 0, fmt.Errorf("certificate expired %v ago", now.Sub(cert.ValidUntil))
312 }
313
314 return cert.ValidUntil.Sub(now), nil
315 }
316
317 // ShouldRenewCertificate checks if a certificate should be renewed (< 30 days until expiry)
318 func ShouldRenewCertificate(cert *Certificate) bool {
319 timeUntilExpiry, err := CheckCertificateExpiry(cert)
320 if err != nil {
321 return true // Expired, definitely should renew
322 }
323
324 return timeUntilExpiry < 30*24*time.Hour
325 }
326