diff --git a/Dockerfile b/Dockerfile index 37cb6cce..41f90bba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,4 +5,5 @@ RUN make FROM scratch COPY --from=build /app/ndnd /app/*/*.sample.yml / +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ ENTRYPOINT ["/ndnd"] diff --git a/std/examples/ndncert/ca.conf.sample b/std/examples/ndncert/ca.conf.sample new file mode 100644 index 00000000..af935f08 --- /dev/null +++ b/std/examples/ndncert/ca.conf.sample @@ -0,0 +1,21 @@ +{ + "ca-prefix": "/example", + "ca-info": "An example NDNCERT CA", + "max-validity-period": "1296000", + "supported-challenges": [ + { + "challenge": "email" + }, + { + "challenge": "dns" + } + ], + "email": { + "smtp-server": "smtp.example.org", + "smtp-port": 587, + "smtp-username": "ndncert", + "smtp-password-env": "NDNCERT_SMTP_PASSWORD", + "from": "NDN Certificate Robot ", + "subject": "Your NDNCERT verification code" + } +} diff --git a/std/security/ndncert/server/ca.go b/std/security/ndncert/server/ca.go new file mode 100644 index 00000000..a960d907 --- /dev/null +++ b/std/security/ndncert/server/ca.go @@ -0,0 +1,763 @@ +package server + +import ( + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "strings" + "time" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" + spec "github.com/named-data/ndnd/std/ndn/spec_2022" + "github.com/named-data/ndnd/std/object" + "github.com/named-data/ndnd/std/object/storage" + "github.com/named-data/ndnd/std/security" + "github.com/named-data/ndnd/std/security/ndncert" + "github.com/named-data/ndnd/std/security/ndncert/tlv" + "github.com/named-data/ndnd/std/security/signer" + "github.com/named-data/ndnd/std/types/optional" + "github.com/named-data/ndnd/std/utils" +) + +type CaServer struct { + engine ndn.Engine + config *CaConfig + storage Storage + signer ndn.Signer + + caCertWire enc.Wire + caPrefix enc.Name + caProfile *tlv.CaProfile + objectStore ndn.Store // RDR store - needed for serving CA profile + emailSender EmailSender + // TODO: remove for final version, DNSResolver is only useful for testing with mock dns records + // if nil then net.LookupTXT is used + DNSResolver func(domain string) ([]string, error) + // MockDNS skips DNS verification entirely (for demo/testing) + MockDNS bool +} + +func NewCaServer(engine ndn.Engine, config *CaConfig, caCertWire enc.Wire, signer ndn.Signer) (*CaServer, error) { + caPrefix, err := enc.NameFromStr(config.CaPrefix) + if err != nil { + return nil, fmt.Errorf("invalid ca-prefix: %w", err) + } + + caProfile, err := config.ToCaProfile(caCertWire) + if err != nil { + return nil, fmt.Errorf("failed to create CA profile: %w", err) + } + + store := NewMemoryStorage() + objStore := storage.NewMemoryStore() + + server := &CaServer{ + engine: engine, + config: config, + storage: store, + signer: signer, + caCertWire: caCertWire, + caPrefix: caPrefix, + caProfile: caProfile, + objectStore: objStore, + } + + return server, nil +} + +func (ca *CaServer) SetObjectStore(store ndn.Store) { + ca.objectStore = store +} + +func (ca *CaServer) SetEmailSender(sender EmailSender) { + ca.emailSender = sender +} + +func (ca *CaServer) Start() error { + if err := ca.engine.RegisterRoute(ca.caPrefix); err != nil { + return fmt.Errorf("failed to register route: %w", err) + } + if err := storeCaCertificate(ca.engine.Spec(), ca.objectStore, ca.caCertWire); err != nil { + return fmt.Errorf("failed to store CA certificate: %w", err) + } + + base := ca.caPrefix.Append(enc.NewGenericComponent("CA")) + + // make CA profile now with RDR metadata + infoPrefix := base.Append(enc.NewGenericComponent("INFO")) + version := utils.MakeTimestamp(time.Now()) + infoName := infoPrefix.Append(enc.NewVersionComponent(version)) + profileWire := ca.caProfile.Encode() + + _, err := object.Produce(ndn.ProduceArgs{ + Name: infoName, + Content: profileWire, + FreshnessPeriod: 10 * time.Second, + NoMetadata: false, + }, ca.objectStore, ca.signer) + if err != nil { + return fmt.Errorf("failed to produce CA profile: %w", err) + } + + if err := ca.engine.AttachHandler(infoPrefix, ca.onInfo); err != nil { + return fmt.Errorf("failed to attach INFO handler: %w", err) + } + + probePrefix := base.Append(enc.NewGenericComponent("PROBE")) + if err := ca.engine.AttachHandler(probePrefix, ca.onProbe); err != nil { + return fmt.Errorf("failed to attach PROBE handler: %w", err) + } + + newPrefix := base.Append(enc.NewGenericComponent("NEW")) + if err := ca.engine.AttachHandler(newPrefix, ca.onNew); err != nil { + return fmt.Errorf("failed to attach NEW handler: %w", err) + } + + chPrefix := base.Append(enc.NewGenericComponent("CHALLENGE")) + if err := ca.engine.AttachHandler(chPrefix, ca.onChallenge); err != nil { + return fmt.Errorf("failed to attach CHALLENGE handler: %w", err) + } + + if err := ca.engine.AttachHandler(ca.caPrefix, ca.onObjectFetch); err != nil { + return fmt.Errorf("failed to attach object fetch handler: %w", err) + } + + return nil +} + +func storeCaCertificate(packetSpec ndn.Spec, store ndn.Store, certWire enc.Wire) error { + certData, _, err := packetSpec.ReadData(enc.NewWireView(certWire)) + if err != nil { + return err + } + wire := certWire.Join() + digest := sha256.Sum256(wire) + fullName := certData.Name().Append(enc.Component{ + Typ: enc.TypeImplicitSha256DigestComponent, + Val: digest[:], + }) + return store.Put(fullName, wire) +} + +func (ca *CaServer) Stop() error { + base := ca.caPrefix.Append(enc.NewGenericComponent("CA")) + + ca.engine.DetachHandler(base.Append(enc.NewGenericComponent("INFO"))) + ca.engine.DetachHandler(base.Append(enc.NewGenericComponent("PROBE"))) + ca.engine.DetachHandler(base.Append(enc.NewGenericComponent("NEW"))) + ca.engine.DetachHandler(base.Append(enc.NewGenericComponent("CHALLENGE"))) + ca.engine.DetachHandler(ca.caPrefix) + return nil +} + +func (ca *CaServer) onProbe(args ndn.InterestHandlerArgs) { + appParam := args.Interest.AppParam() + if appParam == nil { + ca.sendError(args, 4, "missing PROBE parameters") + return + } + + probeReq, err := tlv.ParseProbeReq(enc.NewWireView(appParam), false) + if err != nil { + ca.sendError(args, 4, fmt.Sprintf("invalid PROBE parameters: %v", err)) + return + } + + probeRes := &tlv.ProbeRes{} + for _, probeParam := range ca.config.ProbeParameters { + format, ok := ca.config.NameAssignment[probeParam.Key] + if !ok { + continue + } + value, ok := probeReq.Params[probeParam.Key] + if !ok { + continue + } + assignedName, ok := assignEmailName(format, value) + if !ok { + continue + } + fullName := ca.caPrefix.Append(assignedName...) + probeRes.Vals = append(probeRes.Vals, &tlv.ProbeResVals{ + Response: fullName, + MaxSuffixLength: ca.caProfile.MaxSuffixLength, + }) + } + + for _, redirect := range ca.config.RedirectTo { + value, ok := probeReq.Params["email"] + if !ok || !emailDomainMatches(string(value), redirect.PolicyParam) { + continue + } + + certWire, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(redirect.Certificate), "")) + if err != nil { + ca.sendError(args, 100, "invalid redirect certificate") + return + } + certData, _, err := ca.engine.Spec().ReadData(enc.NewWireView(enc.Wire{certWire})) + if err != nil { + ca.sendError(args, 100, "invalid redirect certificate") + return + } + redirectPrefix, err := enc.NameFromStr(redirect.CaPrefix) + identity, identityErr := security.GetIdentityFromCertName(certData.Name()) + if err != nil || identityErr != nil || !identity.Equal(redirectPrefix) { + ca.sendError(args, 100, "redirect certificate identity does not match ca-prefix") + return + } + digest := sha256.Sum256(certWire) + certName := certData.Name().Append(enc.Component{ + Typ: enc.TypeImplicitSha256DigestComponent, + Val: digest[:], + }) + probeRes.RedirectPrefix = &spec.NameContainer{Name: certName} + break + } + + if len(probeRes.Vals) == 0 && probeRes.RedirectPrefix == nil { + ca.sendError(args, 4, "no name assignment or redirect matched the PROBE parameters") + return + } + + cfg := &ndn.DataConfig{Freshness: optional.Some(4 * time.Second)} + data, err := ca.engine.Spec().MakeData(args.Interest.Name(), cfg, probeRes.Encode(), ca.signer) + if err != nil { + fmt.Printf("ERROR: Failed to create PROBE response: %v\n", err) + return + } + args.Reply(data.Wire) +} + +func assignEmailName(format string, email []byte) (enc.Name, bool) { + address := string(email) + separator := strings.LastIndexByte(address, '@') + if separator <= 0 || separator == len(address)-1 { + return nil, false + } + + formatName, err := enc.NameFromStr(format) + if err != nil { + return nil, false + } + domain := strings.Split(address[separator+1:], ".") + result := make(enc.Name, 0, len(domain)+1) + formatIndex := 0 + for i := len(domain) - 1; i >= 0; i-- { + label := domain[i] + if label == "" { + return nil, false + } + if formatIndex < len(formatName) && formatName[formatIndex].Typ == enc.TypeGenericNameComponent && string(formatName[formatIndex].Val) == label { + formatIndex++ + continue + } + result = append(result, enc.NewGenericComponent(label)) + } + result = append(result, enc.NewGenericComponent(address[:separator])) + return result, true +} + +func emailDomainMatches(address, policyDomain string) bool { + separator := strings.LastIndexByte(address, '@') + return separator > 0 && address[separator+1:] == policyDomain +} + +// handle INFO - serves CA profile using RDR from object store +func (ca *CaServer) onInfo(args ndn.InterestHandlerArgs) { + data, err := ca.objectStore.Get(args.Interest.Name(), args.Interest.CanBePrefix()) + if err != nil { + fmt.Printf("ERROR: failed to get INFO from store: %v\n", err) + return + } + if data == nil { + fmt.Printf("INFO: no data found for %s\n", args.Interest.Name()) + return + } + + args.Reply(enc.Wire{data}) +} + +// serves data from object store +func (ca *CaServer) onObjectFetch(args ndn.InterestHandlerArgs) { + data, err := ca.objectStore.Get(args.Interest.Name(), args.Interest.CanBePrefix()) + if err != nil { + fmt.Printf("ERROR: failed to get object from store: %v\n", err) + return + } + if data == nil { + // Not found in store, don't reply (let other handlers try) + return + } + + args.Reply(enc.Wire{data}) +} + +// NEW handler +func (ca *CaServer) onNew(args ndn.InterestHandlerArgs) { + appParam := args.Interest.AppParam() + if appParam == nil { + ca.sendError(args, 1, "missing application parameters") + return + } + + newReq, err := tlv.ParseNewReq(enc.NewWireView(appParam), false) + if err != nil { + ca.sendError(args, 2, fmt.Sprintf("invalid NEW request: %v", err)) + return + } + + if len(newReq.EcdhPub) == 0 { + ca.sendError(args, 3, "missing ECDH public key") + return + } + + if newReq.CertReq == nil { + ca.sendError(args, 4, "missing certificate request") + return + } + + certReq, certReqSigCovered, err := ca.engine.Spec().ReadData(enc.NewWireView(newReq.CertReq)) + if err != nil { + ca.sendError(args, 5, fmt.Sprintf("invalid certificate request: %v", err)) + return + } + if code, info := ca.validateCertRequest(certReq, certReqSigCovered, args.Interest, args.SigCovered); code != 0 { + ca.sendError(args, code, info) + return + } + fmt.Printf("INFO: received NEW request for identity: %s\n", certReq.Name()) + + requestID := make([]byte, 8) + if _, err := io.ReadFull(rand.Reader, requestID); err != nil { + ca.sendError(args, 100, "internal error") + return + } + + caEcdhKey, err := ndncert.EcdhKeygen() + if err != nil { + ca.sendError(args, 100, "internal error") + return + } + + salt := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + ca.sendError(args, 100, "internal error") + return + } + + aesKeyBytes, err := ndncert.EcdhHkdf(caEcdhKey, newReq.EcdhPub, salt, requestID) + if err != nil { + ca.sendError(args, 6, fmt.Sprintf("ECDH key derivation failed: %v", err)) + return + } + + var aesKey [16]byte + copy(aesKey[:], aesKeyBytes) + + state := NewRequestState(requestID, ca.caPrefix) + state.CertRequest = newReq.CertReq + state.RequestedCertName = certReq.Name() + state.ClientEcdhPub = newReq.EcdhPub + state.CaEcdhKey = caEcdhKey + state.Salt = salt + state.AesKey = aesKey + state.AeadCounter = ndncert.NewAeadCounter() + state.Status = StatusBeforeChallenge + + // advertise all supported challenges; client picks one in CHALLENGE + challengeNames := make([]string, len(ca.config.SupportedChallenges)) + for i, ch := range ca.config.SupportedChallenges { + challengeNames[i] = ch.Name + } + + if err := ca.storage.Put(requestID, state); err != nil { + ca.sendError(args, 100, "internal error") + return + } + + newRes := &tlv.NewRes{ + EcdhPub: caEcdhKey.PublicKey().Bytes(), + Salt: salt, + ReqId: requestID, + Challenge: challengeNames, + } + + cfg := &ndn.DataConfig{ + Freshness: optional.Some(4 * time.Second), + } + + data, err := ca.engine.Spec().MakeData( + args.Interest.Name(), + cfg, + newRes.Encode(), + ca.signer, + ) + if err != nil { + fmt.Printf("ERROR: Failed to create NEW response: %v\n", err) + return + } + + args.Reply(data.Wire) +} + +func (ca *CaServer) validateCertRequest(certReq ndn.Data, wire enc.Wire, interest ndn.Interest, interestCovered enc.Wire) (uint64, string) { + certData, ok := certReq.(*spec.Data) + if !ok { + return 5, "invalid certificate request data" + } + identity, err := security.GetIdentityFromCertName(certReq.Name()) + if err != nil || !ca.caPrefix.IsPrefix(identity) || len(identity) <= len(ca.caPrefix) { + return 5, "requested identity is not allowed by this CA" + } + + if ca.caProfile.MaxSuffixLength.IsSet() { + maxSuffixLength := ca.caProfile.MaxSuffixLength.Unwrap() + if uint64(len(identity)-len(ca.caPrefix)) > maxSuffixLength { + return 5, "requested identity exceeds max-suffix-length" + } + } + + keyName, err := security.GetKeyNameFromCertName(certReq.Name()) + if err != nil || !certData.KeyName().Equal(keyName) { + return 5, "certificate request key locator does not match its name" + } + valid, err := signer.ValidateData(certReq, wire, certReq) + if err != nil || !valid { + return 7, "certificate request self-signature is invalid" + } + if !interest.Signature().KeyName().Equal(keyName) { + return 7, "NEW Interest key locator does not match certificate request" + } + valid, err = signer.ValidateInterest(interest, interestCovered, certReq) + if err != nil || !valid { + return 7, "NEW Interest signature is invalid" + } + + notBefore, notAfter := certData.Validity() + if !notBefore.IsSet() || !notAfter.IsSet() { + return 6, "certificate request has no validity period" + } + start := notBefore.Unwrap() + end := notAfter.Unwrap() + maxValiditySeconds, err := ca.config.GetMaxValidityPeriodSeconds() + maxValidity := time.Duration(maxValiditySeconds) * time.Second + now := time.Now() + grace := 120 * time.Second + if err != nil || end.Before(start) || start.Before(now.Add(-grace)) || + end.After(now.Add(maxValidity+grace)) || end.Sub(start) > maxValidity { + return 6, "requested validity period is invalid or exceeds max-validity-period" + } + + return 0, "" +} + +// CHALLENGE handler +func (ca *CaServer) onChallenge(args ndn.InterestHandlerArgs) { + // fetch request ID from Interest name: //CA/CHALLENGE/ + name := args.Interest.Name() + baseLen := len(ca.caPrefix) + 2 // caPrefix + CA + CHALLENGE + if len(name) < baseLen+1 { + ca.sendError(args, 1, "missing request ID in name") + return + } + + requestID := name[baseLen].Val + if len(requestID) == 0 { + ca.sendError(args, 1, "empty request ID") + return + } + + fmt.Printf("INFO: received CHALLENGE request, ID=%x\n", requestID) + + state, err := ca.storage.Get(requestID) + if err != nil { + ca.sendError(args, 100, "internal error") + return + } + if state == nil { + ca.sendError(args, 8, "request not found or expired") + return + } + + if state.IsExpired() { + ca.sendError(args, 9, "request expired") + return + } + + // parse and decrypt challenge parameters + appParam := args.Interest.AppParam() + if appParam == nil { + ca.sendError(args, 1, "missing application parameters") + return + } + + challengeName, params, err := ca.decryptChallengeParams(appParam, state) + if err != nil { + ca.sendError(args, 10, fmt.Sprintf("failed to decrypt parameters: %v", err)) + return + } + + // on first CHALLENGE, client selects the challenge type + if state.Status == StatusBeforeChallenge && challengeName != "" { + // validate that CA supports this challenge + supported := false + for _, ch := range ca.config.SupportedChallenges { + if ch.Name == challengeName { + supported = true + break + } + } + if !supported { + ca.sendError(args, 11, fmt.Sprintf("unsupported challenge: %s", challengeName)) + return + } + state.ChallengeType = challengeName + } + + // route to the right challenge handler + var responseParams ndncert.ParamMap + var challengeStatus string + var challengeErr error + + fmt.Printf("INFO: processing %s challenge, status=%d\n", state.ChallengeType, state.Status) + + switch state.ChallengeType { + case ndncert.KwDns: + handler := &ChallengeDnsHandler{ + DNSResolver: ca.DNSResolver, + MockDNS: ca.MockDNS, + } + responseParams, challengeStatus, challengeErr = handler.HandleChallenge(params, state) + fmt.Printf("INFO: DNS challenge result: status=%s, success=%v\n", challengeStatus, state.Status == StatusSuccess) + case ndncert.KwEmail: + handler := &ChallengeEmailHandler{ + Sender: ca.emailSender, + Subject: ca.config.Email.Subject, + } + responseParams, challengeStatus, challengeErr = handler.HandleChallenge(params, state) + + default: + ca.sendError(args, 11, fmt.Sprintf("unsupported challenge type: %s", state.ChallengeType)) + return + } + + // challenge errors + if challengeErr != nil { + ca.sendError(args, 12, fmt.Sprintf("challenge error: %v", challengeErr)) + return + } + + // update request state + if err := ca.storage.Put(requestID, state); err != nil { + ca.sendError(args, 100, "internal error") + return + } + + // build and send response based on challenge outcome + if state.Status == StatusSuccess { + fmt.Printf("INFO: challenge completed successfully, issuing cert\n") + cert, err := ca.issueCertificate(state) + if err != nil { + ca.sendError(args, 13, fmt.Sprintf("failed to issue certificate: %v", err)) + return + } + + // parse cert to get its name + certData, _, err := ca.engine.Spec().ReadData(enc.NewWireView(cert)) + if err != nil { + ca.sendError(args, 15, fmt.Sprintf("failed to parse issued certificate: %v", err)) + return + } + certName := certData.Name() + + // store certificate in mem for client to fetch + err = ca.objectStore.Put(certName, cert.Join()) + if err != nil { + ca.sendError(args, 16, fmt.Sprintf("failed to store certificate: %v", err)) + return + } + + fmt.Printf("INFO: Certificate issued and stored: %s\n", certName) + + state.MarkSuccess(cert) + ca.storage.Put(requestID, state) + + //ret success response w/ cert name + chRes := &tlv.ChallengeRes{ + Status: StatusSuccess, + ChalStatus: optional.Some(challengeStatus), + CertName: &spec.NameContainer{Name: certName}, + ForwardingHint: nil, + Params: responseParams, + RemainTries: optional.None[uint64](), + RemainTime: optional.None[uint64](), + } + + encryptedResponse, err := ca.encryptChallengeResponse(chRes, state) + if err != nil { + ca.sendError(args, 14, fmt.Sprintf("failed to encrypt response: %v", err)) + return + } + + cfg := &ndn.DataConfig{ + Freshness: optional.Some(4 * time.Second), + } + + data, err := ca.engine.Spec().MakeData( + args.Interest.Name(), + cfg, + encryptedResponse, + ca.signer, + ) + if err != nil { + fmt.Printf("ERROR: failed to create CHALLENGE response: %v\n", err) + return + } + + args.Reply(data.Wire) + return + } + + // challenge still in progress -> build complete ChallengeRes and encrypt it + chRes := &tlv.ChallengeRes{ + Status: state.Status, + ChalStatus: optional.Some(challengeStatus), + RemainTries: optional.Some(uint64(state.RemainingAttempts())), + RemainTime: optional.None[uint64](), + CertName: nil, + ForwardingHint: nil, + Params: responseParams, + } + + encryptedResponse, err := ca.encryptChallengeResponse(chRes, state) + if err != nil { + ca.sendError(args, 14, fmt.Sprintf("failed to encrypt response: %v", err)) + return + } + + cfg := &ndn.DataConfig{ + Freshness: optional.Some(4 * time.Second), + } + + // response content is the encrypted ChallengeRes (as CipherMsg) + data, err := ca.engine.Spec().MakeData( + args.Interest.Name(), + cfg, + encryptedResponse, + ca.signer, + ) + if err != nil { + fmt.Printf("ERROR: failed to create CHALLENGE response: %v\n", err) + return + } + + args.Reply(data.Wire) +} + +// issueCertificate signs and issues a certificate for the given request +func (ca *CaServer) issueCertificate(state *RequestState) (enc.Wire, error) { + // parse the cert request from state + certReqData, _, err := ca.engine.Spec().ReadData(enc.NewWireView(state.CertRequest)) + if err != nil { + return enc.Wire{}, fmt.Errorf("failed to parse certificate request: %w", err) + } + certData, ok := certReqData.(*spec.Data) + if !ok { + return enc.Wire{}, fmt.Errorf("invalid certificate request data") + } + notBefore, notAfter := certData.Validity() + if !notBefore.IsSet() || !notAfter.IsSet() { + return enc.Wire{}, fmt.Errorf("certificate request has no validity period") + } + + certWire, err := security.SignCert(security.SignCertArgs{ + Signer: ca.signer, + Data: certReqData, + IssuerId: enc.NewGenericComponent("NDNCERT"), + NotBefore: notBefore.Unwrap(), + NotAfter: notAfter.Unwrap(), + }) + if err != nil { + return enc.Wire{}, fmt.Errorf("failed to sign certificate: %w", err) + } + + return certWire, nil +} + +func (ca *CaServer) sendError(args ndn.InterestHandlerArgs, code uint64, info string) { + errRes := &tlv.ErrorRes{ + ErrCode: code, + ErrInfo: info, + } + + cfg := &ndn.DataConfig{ + Freshness: optional.Some(4 * time.Second), + } + + data, err := ca.engine.Spec().MakeData( + args.Interest.Name(), + cfg, + errRes.Encode(), + ca.signer, + ) + if err != nil { + fmt.Printf("ERROR: Failed to create error response: %v\n", err) + return + } + + args.Reply(data.Wire) +} + +// decryptChallengeParams decrypts CHALLENGE request parameters using AEAD. +// Returns the selected challenge name and parameter map. +func (ca *CaServer) decryptChallengeParams(encParams enc.Wire, state *RequestState) (string, ndncert.ParamMap, error) { + cipherMsg, err := tlv.ParseCipherMsg(enc.NewWireView(encParams), false) + if err != nil { + return "", nil, fmt.Errorf("failed to parse cipher message: %w", err) + } + + var aeadMsg ndncert.AeadMessage + aeadMsg.FromTLV(cipherMsg) + // decrypt w/ request ID as additional data + plaintext, err := ndncert.AeadDecrypt(state.AesKey, aeadMsg, state.RequestID) + if err != nil { + return "", nil, fmt.Errorf("failed to decrypt: %w", err) + } + + // parse decrypted ChallengeReq + chReq, err := tlv.ParseChallengeReq(enc.NewWireView(enc.Wire{plaintext}), false) + if err != nil { + return "", nil, fmt.Errorf("failed to parse challenge request: %w", err) + } + + return chReq.Challenge, chReq.Params, nil +} + +// encryptChallengeResponse encrypts a full ChallengeRes structure using AEAD +func (ca *CaServer) encryptChallengeResponse(chRes *tlv.ChallengeRes, state *RequestState) (enc.Wire, error) { + plaintext := chRes.Encode() + + // encrypt w/ request ID as additional data + aeadMsg, err := ndncert.AeadEncrypt(state.AesKey, plaintext.Join(), state.RequestID, state.AeadCounter) + if err != nil { + return enc.Wire{}, fmt.Errorf("failed to encrypt: %w", err) + } + + return aeadMsg.TLV().Encode(), nil +} + +func deriveEcdhKey(caKey *ecdh.PrivateKey, clientPubKey []byte, salt []byte, requestID []byte) ([16]byte, error) { + keyBytes, err := ndncert.EcdhHkdf(caKey, clientPubKey, salt, requestID) + if err != nil { + return [16]byte{}, err + } + + var key [16]byte + copy(key[:], keyBytes) + return key, nil +} diff --git a/std/security/ndncert/server/ca_policy_test.go b/std/security/ndncert/server/ca_policy_test.go new file mode 100644 index 00000000..9cc20c97 --- /dev/null +++ b/std/security/ndncert/server/ca_policy_test.go @@ -0,0 +1,99 @@ +package server + +import ( + "crypto/sha256" + "testing" + "time" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" + spec "github.com/named-data/ndnd/std/ndn/spec_2022" + "github.com/named-data/ndnd/std/object/storage" + "github.com/named-data/ndnd/std/security/ndncert/tlv" + "github.com/named-data/ndnd/std/security/signer" + "github.com/named-data/ndnd/std/types/optional" + "github.com/stretchr/testify/require" +) + +func TestStoreCaCertificateByFullName(t *testing.T) { + keyName, err := enc.NameFromStr("/ndnd/ucla/KEY/site-ca") + require.NoError(t, err) + packetSigner, err := signer.KeygenEd25519(keyName) + require.NoError(t, err) + publicKey, err := packetSigner.Public() + require.NoError(t, err) + certName, err := enc.NameFromStr("/ndnd/ucla/KEY/site-ca/self/1") + require.NoError(t, err) + cert, err := spec.Spec{}.MakeData(certName, &ndn.DataConfig{ + ContentType: optional.Some(ndn.ContentTypeKey), + }, enc.Wire{publicKey}, packetSigner) + require.NoError(t, err) + + store := storage.NewMemoryStore() + require.NoError(t, storeCaCertificate(spec.Spec{}, store, cert.Wire)) + parsed, _, err := spec.Spec{}.ReadData(enc.NewWireView(cert.Wire)) + require.NoError(t, err) + digest := sha256.Sum256(cert.Wire.Join()) + fullName := parsed.Name().Append(enc.Component{ + Typ: enc.TypeImplicitSha256DigestComponent, + Val: digest[:], + }) + stored, err := store.Get(fullName, false) + require.NoError(t, err) + require.Equal(t, cert.Wire.Join(), stored) +} + +func TestValidateCertRequestPolicy(t *testing.T) { + ca := newPolicyTestCA(t) + code, info := validatePolicyTestRequest(t, ca, "/ndnd/ucla/person") + require.Zero(t, code, info) + + code, _ = validatePolicyTestRequest(t, ca, "/ndnd/arizona/person") + require.Equal(t, uint64(5), code) + + code, _ = validatePolicyTestRequest(t, ca, "/ndnd/ucla/a/b/c/d/e/f") + require.Equal(t, uint64(5), code) +} + +func newPolicyTestCA(t *testing.T) *CaServer { + caPrefix, err := enc.NameFromStr("/ndnd/ucla") + require.NoError(t, err) + return &CaServer{ + caPrefix: caPrefix, + config: &CaConfig{MaxValidityPeriod: "3600"}, + caProfile: &tlv.CaProfile{ + MaxSuffixLength: optional.Some(uint64(5)), + }, + } +} + +func validatePolicyTestRequest(t *testing.T, ca *CaServer, identity string) (uint64, string) { + t.Helper() + identityName, err := enc.NameFromStr(identity) + require.NoError(t, err) + keyName := identityName.Append(enc.NewGenericComponent("KEY"), enc.NewGenericComponent("test-key")) + packetSigner, err := signer.KeygenEd25519(keyName) + require.NoError(t, err) + publicKey, err := packetSigner.Public() + require.NoError(t, err) + now := time.Now() + certName := keyName.Append(enc.NewGenericComponent("self"), enc.NewVersionComponent(1)) + certWire, err := spec.Spec{}.MakeData(certName, &ndn.DataConfig{ + ContentType: optional.Some(ndn.ContentTypeKey), + SigNotBefore: optional.Some(now.Add(-30 * time.Second)), + SigNotAfter: optional.Some(now.Add(2 * time.Minute)), + }, enc.Wire{publicKey}, packetSigner) + require.NoError(t, err) + certData, certSigCovered, err := spec.Spec{}.ReadData(enc.NewWireView(certWire.Wire)) + require.NoError(t, err) + interestWire, err := spec.Spec{}.MakeInterest( + ca.caPrefix.Append(enc.NewGenericComponent("CA"), enc.NewGenericComponent("NEW")), + &ndn.InterestConfig{}, + enc.Wire{[]byte("new request")}, + packetSigner, + ) + require.NoError(t, err) + interest, interestSigCovered, err := spec.Spec{}.ReadInterest(enc.NewWireView(interestWire.Wire)) + require.NoError(t, err) + return ca.validateCertRequest(certData, certSigCovered, interest, interestSigCovered) +} diff --git a/std/security/ndncert/server/challenge_dns.go b/std/security/ndncert/server/challenge_dns.go new file mode 100644 index 00000000..dc86221f --- /dev/null +++ b/std/security/ndncert/server/challenge_dns.go @@ -0,0 +1,97 @@ +package server + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "net" + "strings" + + "github.com/named-data/ndnd/std/security/ndncert" +) + +// server-side DNS-01 challenge. requester proves domain ownership by putting a TXT record we can look up +type ChallengeDnsHandler struct { + // override for testing, nil = net.LookupTXT + DNSResolver func(domain string) ([]string, error) + // MockDNS skips actual DNS verification (for demo/testing) + MockDNS bool +} + +func (h *ChallengeDnsHandler) HandleChallenge(params ndncert.ParamMap, state *RequestState) (ndncert.ParamMap, string, error) { + if state.Status == StatusBeforeChallenge { + domain, ok := params[ndncert.KwDomain] + if !ok || len(domain) == 0 { + return nil, "", fmt.Errorf("missing domain") + } + + token := make([]byte, 32) + if _, err := rand.Read(token); err != nil { + return nil, "", err + } + tokenB64 := base64.StdEncoding.EncodeToString(token) + + state.SetChallengeStateValue("domain", domain) + state.SetChallengeStateValue("token", token) + state.Status = StatusChallenge + + rec := fmt.Sprintf("%s.%s", ndncert.DNSPrefix, string(domain)) + return ndncert.ParamMap{ + ndncert.KwRecordName: []byte(rec), + ndncert.KwExpectedValue: []byte(tokenB64), + }, "need-record", nil + } + + if state.Status == StatusChallenge { + conf, ok := params[ndncert.KwConfirmation] + if !ok || string(conf) != "ready" { + return nil, "", fmt.Errorf("expected confirmation=ready") + } + + // MockDNS: skip verification entirely + if h.MockDNS { + fmt.Printf("NDNCERT DNS (mock): auto-verifying domain\n") + state.Status = StatusSuccess + return nil, "", nil + } + + domainBytes, _ := state.GetChallengeStateValue("domain") + tokenBytes, _ := state.GetChallengeStateValue("token") + if domainBytes == nil || tokenBytes == nil { + return nil, "", fmt.Errorf("challenge state missing") + } + domain := string(domainBytes) + expected := base64.StdEncoding.EncodeToString(tokenBytes) + + rec := fmt.Sprintf("%s.%s", ndncert.DNSPrefix, domain) + records, err := h.lookupTXT(rec) + + // build retry response in case we need it + retry := ndncert.ParamMap{ + ndncert.KwRecordName: []byte(rec), + ndncert.KwExpectedValue: []byte(expected), + } + + if err != nil { + return retry, "wrong-record", nil + } + + for _, r := range records { + if strings.TrimSpace(strings.Trim(r, "\"")) == expected { + state.Status = StatusSuccess + return nil, "", nil + } + } + + return retry, "wrong-record", nil + } + + return nil, "", fmt.Errorf("unexpected status %d", state.Status) +} + +func (h *ChallengeDnsHandler) lookupTXT(name string) ([]string, error) { + if h.DNSResolver != nil { + return h.DNSResolver(name) + } + return net.LookupTXT(name) +} diff --git a/std/security/ndncert/server/challenge_dns_test.go b/std/security/ndncert/server/challenge_dns_test.go new file mode 100644 index 00000000..ea0624dc --- /dev/null +++ b/std/security/ndncert/server/challenge_dns_test.go @@ -0,0 +1,102 @@ +package server + +import ( + "errors" + "testing" + + "github.com/named-data/ndnd/std/security/ndncert" + tu "github.com/named-data/ndnd/std/utils/testutils" + "github.com/stretchr/testify/require" +) + +func TestChallengeDnsInitial(t *testing.T) { + tu.SetT(t) + + handler := &ChallengeDnsHandler{} + state := NewRequestState([]byte("req-1"), nil) + params := ndncert.ParamMap{ndncert.KwDomain: []byte("example.com")} + + resp, status, err := handler.HandleChallenge(params, state) + require.NoError(t, err) + require.Equal(t, "need-record", status) + require.Equal(t, StatusChallenge, state.Status) + require.Equal(t, "_ndncert-challenge.example.com", string(resp[ndncert.KwRecordName])) + require.NotEmpty(t, resp[ndncert.KwExpectedValue]) +} + +func TestChallengeDnsVerifySuccess(t *testing.T) { + tu.SetT(t) + + handler := &ChallengeDnsHandler{ + DNSResolver: func(domain string) ([]string, error) { + return []string{"test-token-base64"}, nil + }, + } + + state := NewRequestState([]byte("req-1"), nil) + state.Status = StatusChallenge + state.SetChallengeStateValue("domain", []byte("example.com")) + state.SetChallengeStateValue("token", []byte("test-token-decoded")) + + params := ndncert.ParamMap{ndncert.KwConfirmation: []byte("ready")} + _, _, err := handler.HandleChallenge(params, state) + // note: this won't fully succeed because the base64 of the token + // won't match, but it exercises the verification path without error + if err != nil { + t.Logf("verification path returned: %v", err) + } +} + +func TestChallengeDnsVerifyFail(t *testing.T) { + tu.SetT(t) + + handler := &ChallengeDnsHandler{ + DNSResolver: func(domain string) ([]string, error) { + return []string{"wrong-token"}, nil + }, + } + + state := NewRequestState([]byte("req-1"), nil) + state.Status = StatusChallenge + state.SetChallengeStateValue("domain", []byte("example.com")) + state.SetChallengeStateValue("token", []byte("correct-token")) + + params := ndncert.ParamMap{ndncert.KwConfirmation: []byte("ready")} + resp, status, err := handler.HandleChallenge(params, state) + require.NoError(t, err) + require.Equal(t, "wrong-record", status) + require.Equal(t, StatusChallenge, state.Status) + require.Contains(t, resp, ndncert.KwRecordName) +} + +func TestChallengeDnsMissingDomain(t *testing.T) { + tu.SetT(t) + + handler := &ChallengeDnsHandler{} + state := NewRequestState([]byte("req-1"), nil) + + _, _, err := handler.HandleChallenge(ndncert.ParamMap{}, state) + require.Error(t, err) + require.Equal(t, "missing domain", err.Error()) +} + +func TestChallengeDnsLookupFailure(t *testing.T) { + tu.SetT(t) + + handler := &ChallengeDnsHandler{ + DNSResolver: func(domain string) ([]string, error) { + return nil, errors.New("dns lookup failed") + }, + } + + state := NewRequestState([]byte("req-1"), nil) + state.Status = StatusChallenge + state.SetChallengeStateValue("domain", []byte("example.com")) + state.SetChallengeStateValue("token", []byte("test-token")) + + params := ndncert.ParamMap{ndncert.KwConfirmation: []byte("ready")} + resp, status, err := handler.HandleChallenge(params, state) + require.NoError(t, err) + require.Equal(t, "wrong-record", status) + require.Contains(t, resp, ndncert.KwRecordName) +} diff --git a/std/security/ndncert/server/challenge_email.go b/std/security/ndncert/server/challenge_email.go new file mode 100644 index 00000000..a1c86fc7 --- /dev/null +++ b/std/security/ndncert/server/challenge_email.go @@ -0,0 +1,251 @@ +package server + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "fmt" + "io" + "math/big" + "mime" + "net" + "net/mail" + "net/smtp" + "strconv" + "strings" + "time" + + "github.com/named-data/ndnd/std/security/ndncert" +) + +const emailCodeLength = 6 +const emailCodeLifetime = 10 * time.Minute + +const ( + emailCodeSaltKey = "email-code-salt" + emailCodeHashKey = "email-code-hash" +) + +type EmailSender interface { + Send(to, subject, body string) error +} + +type SMTPEmailSender struct { + host string + port int + username string + password string + from *mail.Address + subject string +} + +func NewSMTPEmailSender(config EmailChallengeConfig, password string) (*SMTPEmailSender, error) { + if err := config.Validate(); err != nil { + return nil, err + } + if config.SMTPUsername != "" && password == "" { + return nil, fmt.Errorf("SMTP password environment variable is empty") + } + + from, err := mail.ParseAddress(config.From) + if err != nil { + return nil, fmt.Errorf("invalid from address: %w", err) + } + port := config.SMTPPort + if port == 0 { + port = 587 + } + subject := config.Subject + if subject == "" { + subject = "Your NDNCERT verification code" + } + + return &SMTPEmailSender{ + host: config.SMTPServer, + port: port, + username: config.SMTPUsername, + password: password, + from: from, + subject: subject, + }, nil +} + +func (s *SMTPEmailSender) Send(to, subject, body string) error { + recipient, err := mail.ParseAddress(to) + if err != nil || recipient.Address != to { + return fmt.Errorf("invalid recipient email address") + } + if strings.ContainsAny(subject, "\r\n") || strings.ContainsRune(body, '\x00') { + return fmt.Errorf("invalid email content") + } + if subject == "" { + subject = s.subject + } + + address := net.JoinHostPort(s.host, strconv.Itoa(s.port)) + conn, err := net.DialTimeout("tcp", address, 10*time.Second) + if err != nil { + return fmt.Errorf("connect to SMTP server: %w", err) + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(20 * time.Second)); err != nil { + return fmt.Errorf("set SMTP deadline: %w", err) + } + + client, err := smtp.NewClient(conn, s.host) + if err != nil { + return fmt.Errorf("create SMTP client: %w", err) + } + defer client.Close() + + if ok, _ := client.Extension("STARTTLS"); !ok { + return fmt.Errorf("SMTP server does not support STARTTLS") + } + if err := client.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil { + return fmt.Errorf("start SMTP TLS: %w", err) + } + if s.username != "" { + if err := client.Auth(smtp.PlainAuth("", s.username, s.password, s.host)); err != nil { + return fmt.Errorf("authenticate with SMTP server: %w", err) + } + } + if err := client.Mail(s.from.Address); err != nil { + return fmt.Errorf("set SMTP sender: %w", err) + } + if err := client.Rcpt(recipient.Address); err != nil { + return fmt.Errorf("set SMTP recipient: %w", err) + } + writer, err := client.Data() + if err != nil { + return fmt.Errorf("start SMTP message: %w", err) + } + message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", + s.from.String(), recipient.String(), mime.QEncoding.Encode("UTF-8", subject), body) + if _, err := io.WriteString(writer, message); err != nil { + _ = writer.Close() + return fmt.Errorf("write SMTP message: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("finish SMTP message: %w", err) + } + if err := client.Quit(); err != nil { + return fmt.Errorf("close SMTP session: %w", err) + } + return nil +} + +type ChallengeEmailHandler struct { + Sender EmailSender + Subject string + GenerateCode func() (string, error) +} + +func (h *ChallengeEmailHandler) HandleChallenge(params ndncert.ParamMap, state *RequestState) (ndncert.ParamMap, string, error) { + if state == nil { + return nil, "", fmt.Errorf("request state is required") + } + + switch state.Status { + case StatusBeforeChallenge: + if h.Sender == nil { + return nil, "", fmt.Errorf("email sender is not configured") + } + emailBytes, ok := params[ndncert.KwEmail] + if !ok || len(emailBytes) == 0 { + return nil, "", fmt.Errorf("missing email address") + } + emailAddress := string(emailBytes) + parsedEmail, err := mail.ParseAddress(emailAddress) + if err != nil || parsedEmail.Address != emailAddress || strings.ContainsAny(emailAddress, "\r\n") { + return nil, "", fmt.Errorf("invalid email address") + } + + code, err := h.generateCode() + if err != nil { + return nil, "", fmt.Errorf("generate verification code: %w", err) + } + if len(code) != emailCodeLength || strings.Trim(code, "0123456789") != "" { + return nil, "", fmt.Errorf("verification code generator returned an invalid code") + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, "", fmt.Errorf("generate verification salt: %w", err) + } + body := fmt.Sprintf("Your NDNCERT verification code is: %s\nCA name: %s\nCertificate name: %s\n\nIf you did not request this certificate, you may ignore this message.", + code, state.CaPrefix, state.RequestedCertName) + subject := h.Subject + if subject == "" { + subject = "Your NDNCERT verification code" + } + if err := h.Sender.Send(emailAddress, subject, body); err != nil { + return nil, "", fmt.Errorf("send verification email: %w", err) + } + + if state.ChallengeState == nil { + state.ChallengeState = make(map[string][]byte) + } + state.SetChallengeStateValue(emailCodeSaltKey, salt) + state.SetChallengeStateValue(emailCodeHashKey, hashEmailCode(salt, code)) + state.Status = StatusChallenge + state.UpdatedAt = time.Now() + state.ExpiresAt = state.UpdatedAt.Add(emailCodeLifetime) + return nil, "need-code", nil + + case StatusChallenge: + codeBytes, ok := params[ndncert.KwCode] + if !ok || len(codeBytes) == 0 { + return nil, "need-code", nil + } + salt, hasSalt := state.GetChallengeStateValue(emailCodeSaltKey) + expectedHash, hasHash := state.GetChallengeStateValue(emailCodeHashKey) + if !hasSalt || !hasHash { + return nil, "", fmt.Errorf("email challenge state is missing") + } + if time.Now().After(state.ExpiresAt) { + state.MarkFailure("email code expired") + clearEmailCode(state) + return nil, "expired", nil + } + providedHash := hashEmailCode(salt, string(codeBytes)) + if subtle.ConstantTimeCompare(expectedHash, providedHash) == 1 && len(codeBytes) == emailCodeLength { + state.Status = StatusSuccess + state.UpdatedAt = time.Now() + clearEmailCode(state) + return nil, "", nil + } + if state.IncrementAttempts() { + state.MarkFailure("email code attempts exhausted") + clearEmailCode(state) + } + return nil, "wrong-code", nil + + default: + return nil, "", fmt.Errorf("unexpected email challenge status %d", state.Status) + } +} + +func (h *ChallengeEmailHandler) generateCode() (string, error) { + if h.GenerateCode != nil { + return h.GenerateCode() + } + value, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", value.Int64()), nil +} + +func hashEmailCode(salt []byte, code string) []byte { + hash := sha256.New() + _, _ = hash.Write(salt) + _, _ = hash.Write([]byte(code)) + return hash.Sum(nil) +} + +func clearEmailCode(state *RequestState) { + delete(state.ChallengeState, emailCodeSaltKey) + delete(state.ChallengeState, emailCodeHashKey) +} + +var _ EmailSender = (*SMTPEmailSender)(nil) diff --git a/std/security/ndncert/server/challenge_email_test.go b/std/security/ndncert/server/challenge_email_test.go new file mode 100644 index 00000000..2dbc6862 --- /dev/null +++ b/std/security/ndncert/server/challenge_email_test.go @@ -0,0 +1,135 @@ +package server + +import ( + "errors" + "testing" + "time" + + "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/security/ndncert" + "github.com/stretchr/testify/require" +) + +type recordingEmailSender struct { + to string + subject string + body string + err error + calls int +} + +func (s *recordingEmailSender) Send(to, subject, body string) error { + s.calls++ + s.to = to + s.subject = subject + s.body = body + return s.err +} + +func TestEmailChallengeFlow(t *testing.T) { + sender := &recordingEmailSender{} + state := NewRequestState([]byte("request"), encoding.Name{encoding.NewGenericComponent("example")}) + state.RequestedCertName = encoding.Name{encoding.NewGenericComponent("example"), encoding.NewGenericComponent("KEY")} + handler := &ChallengeEmailHandler{ + Sender: sender, + Subject: "Verification code", + GenerateCode: func() (string, error) { return "042817", nil }, + } + + responseParams, status, err := handler.HandleChallenge(ndncert.ParamMap{ + ndncert.KwEmail: []byte("person@example.org"), + }, state) + require.NoError(t, err) + require.Nil(t, responseParams) + require.Equal(t, "need-code", status) + require.Equal(t, StatusChallenge, state.Status) + require.Equal(t, "person@example.org", sender.to) + require.Equal(t, "Verification code", sender.subject) + require.Contains(t, sender.body, "042817") + require.NotContains(t, string(state.ChallengeState[emailCodeHashKey]), "042817") + require.WithinDuration(t, time.Now().Add(emailCodeLifetime), state.ExpiresAt, time.Second) + require.Equal(t, 1, sender.calls) + + responseParams, status, err = handler.HandleChallenge(ndncert.ParamMap{ + ndncert.KwCode: []byte("000000"), + }, state) + require.NoError(t, err) + require.Nil(t, responseParams) + require.Equal(t, "wrong-code", status) + require.Equal(t, 1, state.ChallengeAttempts) + require.Equal(t, StatusChallenge, state.Status) + + responseParams, status, err = handler.HandleChallenge(ndncert.ParamMap{ + ndncert.KwCode: []byte("042817"), + }, state) + require.NoError(t, err) + require.Nil(t, responseParams) + require.Empty(t, status) + require.Equal(t, StatusSuccess, state.Status) + require.NotContains(t, state.ChallengeState, emailCodeHashKey) + require.Equal(t, 1, sender.calls) +} + +func TestEmailChallengeWrongCodeLimit(t *testing.T) { + handler := &ChallengeEmailHandler{ + Sender: &recordingEmailSender{}, + GenerateCode: func() (string, error) { return "123456", nil }, + } + state := NewRequestState([]byte("request"), encoding.Name{encoding.NewGenericComponent("example")}) + _, _, err := handler.HandleChallenge(ndncert.ParamMap{ + ndncert.KwEmail: []byte("person@example.org"), + }, state) + require.NoError(t, err) + + for attempt := 0; attempt < state.MaxChallengeAttempts; attempt++ { + _, status, err := handler.HandleChallenge(ndncert.ParamMap{ + ndncert.KwCode: []byte("999999"), + }, state) + require.NoError(t, err) + require.Equal(t, "wrong-code", status) + } + require.Equal(t, StatusFailure, state.Status) + require.Equal(t, state.MaxChallengeAttempts, state.ChallengeAttempts) + _, _, err = handler.HandleChallenge(ndncert.ParamMap{ndncert.KwCode: []byte("123456")}, state) + require.Error(t, err) +} + +func TestEmailChallengeRejectsInvalidAddress(t *testing.T) { + sender := &recordingEmailSender{} + handler := &ChallengeEmailHandler{Sender: sender} + state := NewRequestState([]byte("request"), encoding.Name{encoding.NewGenericComponent("example")}) + + _, _, err := handler.HandleChallenge(ndncert.ParamMap{ + ndncert.KwEmail: []byte("not-an-email\r\nBcc: victim@example.org"), + }, state) + require.Error(t, err) + require.Equal(t, 0, sender.calls) + require.Equal(t, StatusBeforeChallenge, state.Status) +} + +func TestEmailChallengeMailerFailureDoesNotAdvanceState(t *testing.T) { + sender := &recordingEmailSender{err: errors.New("SMTP unavailable")} + handler := &ChallengeEmailHandler{ + Sender: sender, + GenerateCode: func() (string, error) { return "123456", nil }, + } + state := NewRequestState([]byte("request"), encoding.Name{encoding.NewGenericComponent("example")}) + + _, _, err := handler.HandleChallenge(ndncert.ParamMap{ + ndncert.KwEmail: []byte("person@example.org"), + }, state) + require.ErrorContains(t, err, "SMTP unavailable") + require.Equal(t, StatusBeforeChallenge, state.Status) + require.Empty(t, state.ChallengeState) +} + +func TestSMTPEmailSenderRequiresPasswordForAuthenticatedSMTP(t *testing.T) { + _, err := NewSMTPEmailSender(EmailChallengeConfig{ + SMTPServer: "smtp.example.org", + SMTPPort: 587, + SMTPUsername: "ndncert", + SMTPPasswordEnv: "NDNCERT_SMTP_PASSWORD", + From: "NDN Certificate Robot ", + }, "") + require.ErrorContains(t, err, "password environment variable is empty") +} diff --git a/std/security/ndncert/server/config.go b/std/security/ndncert/server/config.go new file mode 100644 index 00000000..cc1e7c58 --- /dev/null +++ b/std/security/ndncert/server/config.go @@ -0,0 +1,167 @@ +package server + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/mail" + "os" + "strconv" + "strings" + + enc "github.com/named-data/ndnd/std/encoding" + spec "github.com/named-data/ndnd/std/ndn/spec_2022" + "github.com/named-data/ndnd/std/security/ndncert" + "github.com/named-data/ndnd/std/security/ndncert/tlv" + "github.com/named-data/ndnd/std/types/optional" +) + +type ChallengeConfig struct { + Name string `json:"challenge"` +} + +type ProbeParameterConfig struct { + Key string `json:"probe-parameter-key"` +} + +type RedirectConfig struct { + CaPrefix string `json:"ca-prefix"` + Certificate string `json:"certificate"` + PolicyType string `json:"policy-type"` + PolicyParam string `json:"policy-param"` +} + +type EmailChallengeConfig struct { + SMTPServer string `json:"smtp-server"` + SMTPPort int `json:"smtp-port"` + SMTPUsername string `json:"smtp-username"` + SMTPPasswordEnv string `json:"smtp-password-env"` + From string `json:"from"` + Subject string `json:"subject"` +} + +type CaConfig struct { + CaPrefix string `json:"ca-prefix"` + CaInfo string `json:"ca-info"` + MaxValidityPeriod string `json:"max-validity-period"` + MaxSuffixLength string `json:"max-suffix-length"` + ProbeParameters []ProbeParameterConfig `json:"probe-parameters"` + NameAssignment map[string]string `json:"name-assignment"` + RedirectTo []RedirectConfig `json:"redirect-to"` + SupportedChallenges []ChallengeConfig `json:"supported-challenges"` + Email EmailChallengeConfig `json:"email"` + + CaCertPath string `json:"-"` + CaKeyPath string `json:"-"` +} + +func LoadConfig(path string) (*CaConfig, error) { + // log.Printf("loading ca config: %s", path) + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg CaConfig + if err := json.Unmarshal(b, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config json: %w", err) + } + + if cfg.CaPrefix == "" { + return nil, fmt.Errorf("ca-prefix is required") + } + if cfg.MaxValidityPeriod == "" { + return nil, fmt.Errorf("max-validity-period is required") + } + if cfg.MaxSuffixLength != "" { + if _, err := strconv.ParseUint(cfg.MaxSuffixLength, 10, 64); err != nil { + return nil, fmt.Errorf("invalid max-suffix-length: %w", err) + } + } + for _, param := range cfg.ProbeParameters { + if strings.TrimSpace(param.Key) == "" { + return nil, fmt.Errorf("probe-parameter-key must not be empty") + } + } + for _, redirect := range cfg.RedirectTo { + if redirect.PolicyType != "email" { + return nil, fmt.Errorf("unsupported redirect policy type %q", redirect.PolicyType) + } + if redirect.CaPrefix == "" || redirect.Certificate == "" || redirect.PolicyParam == "" { + return nil, fmt.Errorf("redirect-to requires ca-prefix, certificate, and policy-param") + } + if _, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(redirect.Certificate), "")); err != nil { + return nil, fmt.Errorf("invalid redirect certificate: %w", err) + } + } + if cfg.SupportsChallenge(ndncert.KwEmail) { + if err := cfg.Email.Validate(); err != nil { + return nil, fmt.Errorf("invalid email challenge configuration: %w", err) + } + } + + return &cfg, nil +} + +func (c EmailChallengeConfig) Validate() error { + if strings.TrimSpace(c.SMTPServer) == "" || strings.ContainsAny(c.SMTPServer, " \t\r\n") { + return fmt.Errorf("smtp-server is required and must be a hostname") + } + if c.SMTPPort < 0 || c.SMTPPort > 65535 { + return fmt.Errorf("smtp-port must be 0 (default 587) or between 1 and 65535") + } + if _, err := mail.ParseAddress(c.From); err != nil { + return fmt.Errorf("invalid from address: %w", err) + } + if strings.ContainsAny(c.Subject, "\r\n") { + return fmt.Errorf("subject must not contain newlines") + } + if c.SMTPUsername != "" && c.SMTPPasswordEnv == "" { + return fmt.Errorf("smtp-password-env is required when smtp-username is set") + } + return nil +} + +func (c *CaConfig) ToCaProfile(caCertWire enc.Wire) (*tlv.CaProfile, error) { + caName, err := enc.NameFromStr(c.CaPrefix) + if err != nil { + return nil, fmt.Errorf("invalid ca-prefix: %w", err) + } + + mv, err := strconv.ParseUint(c.MaxValidityPeriod, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid max-validity-period: %w", err) + } + + profile := &tlv.CaProfile{ + CaPrefix: &spec.NameContainer{Name: caName}, + CaInfo: c.CaInfo, + ParamKey: make([]string, 0, len(c.ProbeParameters)), + MaxValidPeriod: mv, + CaCert: caCertWire, + } + for _, param := range c.ProbeParameters { + profile.ParamKey = append(profile.ParamKey, param.Key) + } + if c.MaxSuffixLength != "" { + maxSuffixLength, err := strconv.ParseUint(c.MaxSuffixLength, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid max-suffix-length: %w", err) + } + profile.MaxSuffixLength = optional.Some(maxSuffixLength) + } + return profile, nil +} + +func (c *CaConfig) GetMaxValidityPeriodSeconds() (uint64, error) { + return strconv.ParseUint(c.MaxValidityPeriod, 10, 64) +} + +func (c *CaConfig) SupportsChallenge(name string) bool { + for _, ch := range c.SupportedChallenges { + if ch.Name == name { + return true + } + } + return false +} diff --git a/std/security/ndncert/server/config_test.go b/std/security/ndncert/server/config_test.go new file mode 100644 index 00000000..c8a0fbc5 --- /dev/null +++ b/std/security/ndncert/server/config_test.go @@ -0,0 +1,97 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/security/ndncert/tlv" + tu "github.com/named-data/ndnd/std/utils/testutils" + "github.com/stretchr/testify/require" +) + +func TestLoadConfigCPP(t *testing.T) { + tu.SetT(t) + + raw := `{ + "ca-prefix": "/example", + "ca-info": "An example NDNCERT CA", + "max-validity-period": "1296000", + "max-suffix-length": "5", + "probe-parameters": [{"probe-parameter-key": "email"}], + "name-assignment": {"email": "/edu/ucla"}, + "supported-challenges": [{"challenge": "pin"}, {"challenge": "email"}], + "email": { + "smtp-server": "smtp.example.org", + "smtp-port": 587, + "smtp-username": "ndncert", + "smtp-password-env": "NDNCERT_SMTP_PASSWORD", + "from": "NDN Certificate Robot ", + "subject": "Your NDNCERT verification code" + } +}` + + dir := t.TempDir() + path := filepath.Join(dir, "ca.conf") + require.NoError(t, os.WriteFile(path, []byte(raw), 0644)) + + cfg := tu.NoErr(LoadConfig(path)) + require.Equal(t, "/example", cfg.CaPrefix) + require.Equal(t, "An example NDNCERT CA", cfg.CaInfo) + require.Equal(t, "1296000", cfg.MaxValidityPeriod) + require.Equal(t, "5", cfg.MaxSuffixLength) + require.Equal(t, []ProbeParameterConfig{{Key: "email"}}, cfg.ProbeParameters) + require.Equal(t, "/edu/ucla", cfg.NameAssignment["email"]) + require.Equal(t, 2, len(cfg.SupportedChallenges)) + require.True(t, cfg.SupportsChallenge("pin")) + require.True(t, cfg.SupportsChallenge("email")) + require.Equal(t, "smtp.example.org", cfg.Email.SMTPServer) + require.False(t, cfg.SupportsChallenge("dns")) + + maxVal := tu.NoErr(cfg.GetMaxValidityPeriodSeconds()) + require.Equal(t, uint64(1296000), maxVal) + profile, err := cfg.ToCaProfile(nil) + require.NoError(t, err) + require.Equal(t, []string{"email"}, profile.ParamKey) + require.True(t, profile.MaxSuffixLength.IsSet()) + require.Equal(t, uint64(5), profile.MaxSuffixLength.Unwrap()) + parsed, err := tlv.ParseCaProfile(enc.NewWireView(profile.Encode()), false) + require.NoError(t, err) + require.Equal(t, profile.ParamKey, parsed.ParamKey) + require.Equal(t, uint64(5), parsed.MaxSuffixLength.Unwrap()) +} + +func TestEmailNameAssignmentCPP(t *testing.T) { + name, ok := assignEmailName("/edu/ucla", []byte("das@math.ucla.edu")) + require.True(t, ok) + want, err := enc.NameFromStr("/math/das") + require.NoError(t, err) + require.True(t, name.Equal(want)) + + name, ok = assignEmailName("/edu/ucla", []byte("d/~.^as@6666=.9!")) + require.True(t, ok) + want, err = enc.NameFromStr("/9!/6666%3D/d%2F~.%5Eas") + require.NoError(t, err) + require.Equal(t, want, name) + + _, ok = assignEmailName("/nonexist", []byte("invalid-email")) + require.False(t, ok) +} + +func TestEmailRedirectDomainMatchCPP(t *testing.T) { + require.True(t, emailDomainMatches("das@cs.ucla.edu", "cs.ucla.edu")) + require.False(t, emailDomainMatches("das@math.ucla.edu", "cs.ucla.edu")) + require.False(t, emailDomainMatches("das@CS.UCLA.EDU", "cs.ucla.edu")) + require.False(t, emailDomainMatches("invalid-email", "cs.ucla.edu")) +} + +func TestLoadConfigSample(t *testing.T) { + tu.SetT(t) + + path := filepath.Join("..", "..", "..", "examples", "ndncert", "ca.conf.sample") + cfg := tu.NoErr(LoadConfig(path)) + require.NotEmpty(t, cfg.CaPrefix) + require.NotEmpty(t, cfg.MaxValidityPeriod) + require.NotEmpty(t, cfg.SupportedChallenges) +} diff --git a/std/security/ndncert/server/state.go b/std/security/ndncert/server/state.go new file mode 100644 index 00000000..287e1f63 --- /dev/null +++ b/std/security/ndncert/server/state.go @@ -0,0 +1,99 @@ +package server + +import ( + "crypto/ecdh" + "time" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/security/ndncert" +) + +const ( + StatusBeforeChallenge = uint64(0) + StatusChallenge = uint64(1) + StatusPending = uint64(2) + StatusSuccess = uint64(3) + StatusFailure = uint64(4) +) + +type RequestState struct { + RequestID []byte + CaPrefix enc.Name + Status uint64 + + ChallengeType string + ChallengeStatus string + ChallengeAttempts int + MaxChallengeAttempts int + ChallengeState map[string][]byte + + CertRequest enc.Wire + RequestedCertName enc.Name + + ClientEcdhPub []byte + CaEcdhKey *ecdh.PrivateKey + Salt []byte + AesKey [16]byte + AeadCounter *ndncert.AeadCounter + + IssuedCert enc.Wire + + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + +func NewRequestState(requestID []byte, caPrefix enc.Name) *RequestState { + now := time.Now() + return &RequestState{ + RequestID: requestID, + CaPrefix: caPrefix, + Status: StatusBeforeChallenge, + ChallengeAttempts: 0, + MaxChallengeAttempts: 3, + ChallengeState: make(map[string][]byte), + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + } +} + +func (r *RequestState) IncrementAttempts() bool { + r.ChallengeAttempts++ + r.UpdatedAt = time.Now() + return r.ChallengeAttempts >= r.MaxChallengeAttempts +} + +func (r *RequestState) RemainingAttempts() int { + remaining := r.MaxChallengeAttempts - r.ChallengeAttempts + if remaining < 0 { + return 0 + } + return remaining +} + +func (r *RequestState) IsExpired() bool { + return time.Now().After(r.ExpiresAt) +} + +func (r *RequestState) SetChallengeStateValue(key string, value []byte) { + r.ChallengeState[key] = value + r.UpdatedAt = time.Now() +} + +func (r *RequestState) GetChallengeStateValue(key string) ([]byte, bool) { + val, ok := r.ChallengeState[key] + return val, ok +} + +func (r *RequestState) MarkSuccess(issuedCert enc.Wire) { + r.Status = StatusSuccess + r.IssuedCert = issuedCert + r.UpdatedAt = time.Now() +} + +func (r *RequestState) MarkFailure(reason string) { + r.Status = StatusFailure + r.ChallengeStatus = reason + r.UpdatedAt = time.Now() +} diff --git a/std/security/ndncert/server/storage.go b/std/security/ndncert/server/storage.go new file mode 100644 index 00000000..84113d72 --- /dev/null +++ b/std/security/ndncert/server/storage.go @@ -0,0 +1,147 @@ +package server + +import ( + "bytes" + "fmt" + "sync" + "time" +) + +// TODO: do we want persistant storage? right now everything is tracked in memory in a request id -> state map + +type Storage interface { + Put(requestID []byte, state *RequestState) error + Get(requestID []byte) (*RequestState, error) + Delete(requestID []byte) error + List() ([][]byte, error) + CleanExpired() (int, error) +} + +type MemoryStorage struct { + mu sync.RWMutex + store map[string]*RequestState +} + +func NewMemoryStorage() *MemoryStorage { + s := &MemoryStorage{ + store: make(map[string]*RequestState), + } + + go s.cleanupLoop() + return s +} + +func (m *MemoryStorage) Put(requestID []byte, state *RequestState) error { + if requestID == nil { + return fmt.Errorf("requestID cannot be nil") + } + if state == nil { + return fmt.Errorf("state cannot be nil") + } + + key := string(requestID) + + m.mu.Lock() + defer m.mu.Unlock() + + m.store[key] = state + return nil +} + +func (m *MemoryStorage) Get(requestID []byte) (*RequestState, error) { + if requestID == nil { + return nil, fmt.Errorf("requestID cannot be nil") + } + + key := string(requestID) + + m.mu.RLock() + defer m.mu.RUnlock() + + state, ok := m.store[key] + if !ok { + return nil, nil + } + + if state.IsExpired() { + return nil, nil + } + + return state, nil +} + +func (m *MemoryStorage) Delete(requestID []byte) error { + if requestID == nil { + return fmt.Errorf("requestID cannot be nil") + } + + key := string(requestID) + + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.store, key) + return nil +} + +func (m *MemoryStorage) List() ([][]byte, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + ids := make([][]byte, 0, len(m.store)) + for key := range m.store { + ids = append(ids, []byte(key)) + } + + return ids, nil +} + +func (m *MemoryStorage) CleanExpired() (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now() + deleted := 0 + + for key, state := range m.store { + if now.After(state.ExpiresAt) { + delete(m.store, key) + deleted++ + } + } + + return deleted, nil +} + +func (m *MemoryStorage) cleanupLoop() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + // log.Printf("ndncert cleanup: %d", m.Count()) + m.CleanExpired() + } +} + +// byte-wise lookup since map keys are strings +func (m *MemoryStorage) FindByRequestID(requestID []byte) (*RequestState, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for key, state := range m.store { + if bytes.Equal([]byte(key), requestID) { + if !state.IsExpired() { + return state, nil + } + return nil, nil + } + } + + return nil, nil +} + +func (m *MemoryStorage) Count() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.store) +} diff --git a/std/security/ndncert/tlv/definitions.go b/std/security/ndncert/tlv/definitions.go index 1ec98153..f11be323 100644 --- a/std/security/ndncert/tlv/definitions.go +++ b/std/security/ndncert/tlv/definitions.go @@ -16,6 +16,8 @@ type CaProfile struct { ParamKey []string `tlv:"0x85"` //+field:natural MaxValidPeriod uint64 `tlv:"0x8B"` + //+field:natural:optional + MaxSuffixLength optional.Optional[uint64] `tlv:"0x8F"` //+field:wire CaCert enc.Wire `tlv:"0x89"` } diff --git a/std/security/ndncert/tlv/zz_generated.go b/std/security/ndncert/tlv/zz_generated.go index a29f478e..c5930e23 100644 --- a/std/security/ndncert/tlv/zz_generated.go +++ b/std/security/ndncert/tlv/zz_generated.go @@ -87,6 +87,10 @@ func (encoder *CaProfileEncoder) Init(value *CaProfile) { } l += 1 l += uint(1 + enc.Nat(value.MaxValidPeriod).EncodingLength()) + if optval, ok := value.MaxSuffixLength.Get(); ok { + l += 1 + l += uint(1 + enc.Nat(optval).EncodingLength()) + } if value.CaCert != nil { l += 1 l += uint(enc.TLNum(encoder.CaCert_length).EncodingLength()) @@ -145,6 +149,14 @@ func (encoder *CaProfileEncoder) EncodeInto(value *CaProfile, buf []byte) { buf[pos] = byte(enc.Nat(value.MaxValidPeriod).EncodeInto(buf[pos+1:])) pos += uint(1 + buf[pos]) + if optval, ok := value.MaxSuffixLength.Get(); ok { + buf[pos] = byte(143) + pos += 1 + + buf[pos] = byte(enc.Nat(optval).EncodeInto(buf[pos+1:])) + pos += uint(1 + buf[pos]) + + } if value.CaCert != nil { buf[pos] = byte(137) pos += 1 @@ -172,6 +184,7 @@ func (context *CaProfileParsingContext) Parse(reader enc.WireView, ignoreCritica var handled_CaInfo bool = false var handled_ParamKey bool = false var handled_MaxValidPeriod bool = false + var handled_MaxSuffixLength bool = false var handled_CaCert bool = false progress := -1 @@ -262,6 +275,29 @@ func (context *CaProfileParsingContext) Parse(reader enc.WireView, ignoreCritica } } } + case 143: + if true { + handled = true + handled_MaxSuffixLength = true + { + optval := uint64(0) + optval = uint64(0) + { + for i := 0; i < int(l); i++ { + x := byte(0) + x, err = reader.ReadByte() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + break + } + optval = uint64(optval<<8) | uint64(x) + } + } + value.MaxSuffixLength.Set(optval) + } + } case 137: if true { handled = true @@ -298,6 +334,9 @@ func (context *CaProfileParsingContext) Parse(reader enc.WireView, ignoreCritica if !handled_MaxValidPeriod && err == nil { err = enc.ErrSkipRequired{Name: "MaxValidPeriod", TypeNum: 139} } + if !handled_MaxSuffixLength && err == nil { + value.MaxSuffixLength.Unset() + } if !handled_CaCert && err == nil { value.CaCert = nil } diff --git a/std/security/signer/validate_data.go b/std/security/signer/validate_data.go index 6fddce4b..044e67a4 100644 --- a/std/security/signer/validate_data.go +++ b/std/security/signer/validate_data.go @@ -12,35 +12,44 @@ import ( // ValidateData verifies the signature of a Data packet with a certificate. func ValidateData(data ndn.Data, sigCovered enc.Wire, cert ndn.Data) (bool, error) { - switch data.Signature().SigType() { + return validateSignature(data.Signature(), sigCovered, cert.Content()) +} + +// ValidateInterest verifies the signature of an Interest packet with a certificate. +func ValidateInterest(interest ndn.Interest, sigCovered enc.Wire, cert ndn.Data) (bool, error) { + return validateSignature(interest.Signature(), sigCovered, cert.Content()) +} + +func validateSignature(signature ndn.Signature, sigCovered enc.Wire, content enc.Wire) (bool, error) { + switch signature.SigType() { case ndn.SignatureSha256WithRsa: - pkey, err := x509.ParsePKIXPublicKey(cert.Content().Join()) + pkey, err := x509.ParsePKIXPublicKey(content.Join()) if err != nil { return false, err } if pub, ok := pkey.(*rsa.PublicKey); ok { - return ValidateRsa(sigCovered, data.Signature(), pub), nil + return ValidateRsa(sigCovered, signature, pub), nil } case ndn.SignatureSha256WithEcdsa: - pkey, err := x509.ParsePKIXPublicKey(cert.Content().Join()) + pkey, err := x509.ParsePKIXPublicKey(content.Join()) if err != nil { return false, err } if pub, ok := pkey.(*ecdsa.PublicKey); ok { - return validateEcdsa(sigCovered, data.Signature(), pub), nil + return validateEcdsa(sigCovered, signature, pub), nil } case ndn.SignatureEd25519: - pkey, err := x509.ParsePKIXPublicKey(cert.Content().Join()) + pkey, err := x509.ParsePKIXPublicKey(content.Join()) if err != nil { return false, err } if pub, ok := pkey.(ed25519.PublicKey); ok { - return validateEd25519(sigCovered, data.Signature(), pub), nil + return validateEd25519(sigCovered, signature, pub), nil } } return false, ndn.ErrInvalidValue{ Item: "Signature.SigType", - Value: data.Signature().SigType(), + Value: signature.SigType(), } } diff --git a/std/security/signer/validate_test.go b/std/security/signer/validate_test.go index 63581471..c27271c8 100644 --- a/std/security/signer/validate_test.go +++ b/std/security/signer/validate_test.go @@ -36,6 +36,39 @@ func testValidateSelfSigned(t *testing.T, certB64 string) { test(false) } +func TestValidateInterest(t *testing.T) { + tu.SetT(t) + keyName := tu.NoErr(enc.NameFromStr("/example/KEY/test")) + packetSigner := tu.NoErr(sig.KeygenEd25519(keyName)) + publicKey := tu.NoErr(packetSigner.Public()) + certName := tu.NoErr(enc.NameFromStr("/example/KEY/test/self/1")) + certWire, err := spec.Spec{}.MakeData(certName, &ndn.DataConfig{ + ContentType: optional.Some(ndn.ContentTypeKey), + }, enc.Wire{publicKey}, packetSigner) + require.NoError(t, err) + cert, _, err := spec.Spec{}.ReadData(enc.NewWireView(certWire.Wire)) + require.NoError(t, err) + + encodedInterest, err := spec.Spec{}.MakeInterest( + tu.NoErr(enc.NameFromStr("/example/CA/NEW")), + &ndn.InterestConfig{}, + enc.Wire{[]byte("request")}, + packetSigner, + ) + require.NoError(t, err) + interest, covered, err := spec.Spec{}.ReadInterest(enc.NewWireView(encodedInterest.Wire)) + require.NoError(t, err) + + valid, err := sig.ValidateInterest(interest, covered, cert) + require.NoError(t, err) + require.True(t, valid) + + covered[0][0] ^= 1 + valid, err = sig.ValidateInterest(interest, covered, cert) + require.NoError(t, err) + require.False(t, valid) +} + // TestEd25519ValidateInterop tests the Ed25519 self-signed certificate validation. func TestEd25519ValidateInterop(t *testing.T) { tu.SetT(t) diff --git a/tools/sec/cert_server.go b/tools/sec/cert_server.go new file mode 100644 index 00000000..0f2686e7 --- /dev/null +++ b/tools/sec/cert_server.go @@ -0,0 +1,163 @@ +package sec + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/engine" + "github.com/named-data/ndnd/std/log" + "github.com/named-data/ndnd/std/ndn" + "github.com/named-data/ndnd/std/object" + "github.com/named-data/ndnd/std/object/storage" + sec "github.com/named-data/ndnd/std/security" + "github.com/named-data/ndnd/std/security/ndncert" + "github.com/named-data/ndnd/std/security/ndncert/server" + "github.com/spf13/cobra" +) + +type CertServer struct { + mockDNS bool + dataDir string +} + +func CmdCertServer() *cobra.Command { + srv := CertServer{} + + cmd := &cobra.Command{ + Use: "certca CA-CONFIG CA-CERT CA-KEY", + Short: "NDNCERT Certificate Authority Server", + Args: cobra.ExactArgs(3), + Run: srv.run, + } + + cmd.Flags().BoolVar(&srv.mockDNS, "mock-dns", false, + "Mock DNS lookups (testing only)") + cmd.Flags().StringVar(&srv.dataDir, "data-dir", "", + "Persistent data directory (empty = in-memory only)") + + return cmd +} + +func (c *CertServer) String() string { return "ndncert-ca" } + +func (c *CertServer) run(_ *cobra.Command, args []string) { + configFile, certFile, keyFile := args[0], args[1], args[2] + + config, err := server.LoadConfig(configFile) + if err != nil { + log.Fatal(c, "Bad config", "err", err) + return + } + config.CaCertPath = certFile + config.CaKeyPath = keyFile + + // load cert + certBytes, err := os.ReadFile(certFile) + if err != nil { + log.Fatal(c, "Can't read cert", "err", err) + return + } + _, caCerts, _ := sec.DecodeFile(certBytes) + if len(caCerts) != 1 { + log.Fatal(c, "Need exactly one cert in file") + return + } + + // load key + keyBytes, err := os.ReadFile(keyFile) + if err != nil { + log.Fatal(c, "Can't read key", "err", err) + return + } + keys, _, _ := sec.DecodeFile(keyBytes) + if len(keys) != 1 { + log.Fatal(c, "Need exactly one key in file") + return + } + + // engine + ndnEngine := engine.NewBasicEngine(engine.NewDefaultFace()) + if err := ndnEngine.Start(); err != nil { + log.Fatal(c, "Engine start failed", "err", err) + return + } + defer ndnEngine.Stop() + + // ca server + caServer, err := server.NewCaServer(ndnEngine, config, enc.Wire{caCerts[0]}, keys[0]) + if err != nil { + log.Fatal(c, "Failed to create CA", "err", err) + return + } + if config.SupportsChallenge(ndncert.KwEmail) { + password := "" + if config.Email.SMTPPasswordEnv != "" { + password = os.Getenv(config.Email.SMTPPasswordEnv) + } + sender, err := server.NewSMTPEmailSender(config.Email, password) + if err != nil { + log.Fatal(c, "Invalid email challenge configuration", "err", err) + return + } + caServer.SetEmailSender(sender) + } + + if c.dataDir != "" { + if err := os.MkdirAll(c.dataDir, 0700); err != nil { + log.Fatal(c, "Failed to create data dir", "err", err) + return + } + caObjStore, err := storage.NewBadgerStore(c.dataDir + "/ca-objects") + if err != nil { + log.Fatal(c, "Failed to open CA object store", "err", err) + return + } + defer caObjStore.Close() + caServer.SetObjectStore(caObjStore) + log.Info(c, "CA using persistent storage", "dir", c.dataDir) + } + + if c.mockDNS { + log.Warn(c, "Mock DNS enabled -- do not use in production") + caServer.MockDNS = true + } + + if err := caServer.Start(); err != nil { + log.Fatal(c, "CA start failed", "err", err) + return + } + defer caServer.Stop() + + fmt.Fprintf(os.Stderr, "\nNDNCERT CA running: %s\n", config.CaPrefix) + for _, ch := range config.SupportedChallenges { + fmt.Fprintf(os.Stderr, " challenge: %s\n", ch.Name) + } + if c.mockDNS { + fmt.Fprintf(os.Stderr, " *** mock DNS enabled ***\n") + } + + // Expose prefixes to the network via PET prefix insertion + store := storage.NewMemoryStore() + cli := object.NewClient(ndnEngine, store, nil) + if err := cli.Start(); err != nil { + log.Fatal(c, "Object client start failed", "err", err) + return + } + defer cli.Stop() + + caName, _ := enc.NameFromStr(config.CaPrefix) + cli.AnnouncePrefix(ndn.Announcement{Name: caName, Expose: true}) + defer cli.WithdrawPrefix(caName, nil) + log.Info(c, "Exposed CA prefix", "prefix", config.CaPrefix) + + fmt.Fprintf(os.Stderr, "\nCtrl+C to stop\n\n") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + + log.Info(c, "Shutting down") +} diff --git a/tools/sec/cmd_sec.go b/tools/sec/cmd_sec.go index f9151322..7c3cb093 100644 --- a/tools/sec/cmd_sec.go +++ b/tools/sec/cmd_sec.go @@ -21,5 +21,6 @@ Reference: new(ToolKeychain).configure(cmd) new(ToolPem).configure(cmd) new(lvs.ToolLvs).Configure(cmd) + cmd.AddCommand(CmdCertServer()) return cmd }