This patch depends on my patch 0029. In the past we have talked about not adding HOTP support due to the inability to guarantee timely updates to the counter across replication. I went ahead and implemented HOTP support for two reasons:
1. Testing the OTP stack with TOTP tokens can be a bit frustrating waiting for the tokens to generate. 2. Since we aren't implementing watermark support for TOTP, the HOTP security guarantee seems very similar to me. Nathaniel
>From 8df0458f68072d18b20d0a535ada7d9e44ace3f2 Mon Sep 17 00:00:00 2001 From: Nathaniel McCallum <[email protected]> Date: Wed, 8 Jan 2014 15:08:55 -0500 Subject: [PATCH] Add HOTP token support --- API.txt | 10 +++--- VERSION | 2 +- daemons/ipa-slapi-plugins/libotp/libotp.c | 43 +++++++++++++++++++---- install/share/70ipaotp.ldif | 2 ++ install/share/default-aci.ldif | 3 +- install/updates/40-otp.update | 1 + ipalib/plugins/otptoken.py | 26 ++++++++++---- ipaserver/install/plugins/update_anonymous_aci.py | 2 +- 8 files changed, 68 insertions(+), 21 deletions(-) diff --git a/API.txt b/API.txt index a6c3aed82370d690a678a034b0eea85d4f85b45f..e2f5e036e416bfc248b845c742e3d563958f1772 100644 --- a/API.txt +++ b/API.txt @@ -2220,12 +2220,13 @@ output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDA output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None) output: Output('value', <type 'unicode'>, None) command: otptoken_add -args: 1,20,3 +args: 1,21,3 arg: Str('ipatokenuniqueid', attribute=True, cli_name='id', multivalue=False, primary_key=True, required=False) option: Str('addattr*', cli_name='addattr', exclude='webui') option: Flag('all', autofill=True, cli_name='all', default=False, exclude='webui') option: Str('description', attribute=True, cli_name='desc', multivalue=False, required=False) option: Bool('ipatokendisabled', attribute=True, cli_name='disabled', multivalue=False, required=False) +option: Int('ipatokenhotpcounter', attribute=True, cli_name='counter', minvalue=0, multivalue=False, required=False) option: Str('ipatokenmodel', attribute=True, cli_name='model', multivalue=False, required=False) option: Str('ipatokennotafter', attribute=True, cli_name='not_after', multivalue=False, required=False) option: Str('ipatokennotbefore', attribute=True, cli_name='not_before', multivalue=False, required=False) @@ -2240,7 +2241,7 @@ option: Str('ipatokenvendor', attribute=True, cli_name='vendor', multivalue=Fals option: Flag('qrcode?', autofill=True, default=False) option: Flag('raw', autofill=True, cli_name='raw', default=False, exclude='webui') option: Str('setattr*', cli_name='setattr', exclude='webui') -option: StrEnum('type', attribute=False, cli_name='type', multivalue=False, required=False, values=(u'totp',)) +option: StrEnum('type', attribute=False, cli_name='type', multivalue=False, required=False, values=(u'totp', u'hotp')) option: Str('version?', exclude='webui') output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None) @@ -2254,11 +2255,12 @@ output: Output('result', <type 'dict'>, None) output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None) output: Output('value', <type 'unicode'>, None) command: otptoken_find -args: 1,20,4 +args: 1,21,4 arg: Str('criteria?', noextrawhitespace=False) option: Flag('all', autofill=True, cli_name='all', default=False, exclude='webui') option: Str('description', attribute=True, autofill=False, cli_name='desc', multivalue=False, query=True, required=False) option: Bool('ipatokendisabled', attribute=True, autofill=False, cli_name='disabled', multivalue=False, query=True, required=False) +option: Int('ipatokenhotpcounter', attribute=True, autofill=False, cli_name='counter', minvalue=0, multivalue=False, query=True, required=False) option: Str('ipatokenmodel', attribute=True, autofill=False, cli_name='model', multivalue=False, query=True, required=False) option: Str('ipatokennotafter', attribute=True, autofill=False, cli_name='not_after', multivalue=False, query=True, required=False) option: Str('ipatokennotbefore', attribute=True, autofill=False, cli_name='not_before', multivalue=False, query=True, required=False) @@ -2274,7 +2276,7 @@ option: Flag('pkey_only?', autofill=True, default=False) option: Flag('raw', autofill=True, cli_name='raw', default=False, exclude='webui') option: Int('sizelimit?', autofill=False, minvalue=0) option: Int('timelimit?', autofill=False, minvalue=0) -option: StrEnum('type', attribute=False, autofill=False, cli_name='type', multivalue=False, query=True, required=False, values=(u'totp',)) +option: StrEnum('type', attribute=False, autofill=False, cli_name='type', multivalue=False, query=True, required=False, values=(u'totp', u'hotp')) option: Str('version?', exclude='webui') output: Output('count', <type 'int'>, None) output: ListOfEntries('result', (<type 'list'>, <type 'tuple'>), Gettext('A list of LDAP entries', domain='ipa', localedir=None)) diff --git a/VERSION b/VERSION index 5ce16b5224fd95910a221e251b2d740318bded95..3072bfaed6bb02b86dc708fddc7ae0ce3afe3d65 100644 --- a/VERSION +++ b/VERSION @@ -89,4 +89,4 @@ IPA_DATA_VERSION=20100614120000 # # ######################################################## IPA_API_VERSION_MAJOR=2 -IPA_API_VERSION_MINOR=72 +IPA_API_VERSION_MINOR=73 diff --git a/daemons/ipa-slapi-plugins/libotp/libotp.c b/daemons/ipa-slapi-plugins/libotp/libotp.c index dd5ebe6a110c3ea7f61dde40dd71201deef4dd2e..92d0d215dd405467a6d44a3a9018e9b5a9c9ffee 100644 --- a/daemons/ipa-slapi-plugins/libotp/libotp.c +++ b/daemons/ipa-slapi-plugins/libotp/libotp.c @@ -44,14 +44,17 @@ #define TOKEN(s) "ipaToken" s #define O(s) TOKEN("OTP" s) #define T(s) TOKEN("TOTP" s) +#define H(s) TOKEN("HOTP" s) #define IPA_OTP_DEFAULT_TOKEN_STEP 30 -#define IPA_OTP_OBJCLS_FILTER "(objectClass=ipaTokenTOTP)" +#define IPA_OTP_OBJCLS_FILTER \ + "(|(objectClass=ipaTokenTOTP)(objectClass=ipaTokenHOTP))" enum otptoken_type { OTPTOKEN_NONE = 0, OTPTOKEN_TOTP, + OTPTOKEN_HOTP, }; struct otptoken { @@ -59,10 +62,15 @@ struct otptoken { Slapi_DN *sdn; struct hotp_token token; enum otptoken_type type; - struct { - unsigned int step; - int offset; - } totp; + union { + struct { + unsigned int step; + int offset; + } totp; + struct { + uint64_t counter; + } hotp; + }; }; static const char *get_basedn(Slapi_DN *dn) @@ -122,6 +130,9 @@ static bool validate(struct otptoken *token, time_t now, ssize_t step, case OTPTOKEN_TOTP: step = (now + token->totp.offset) / token->totp.step + step; break; + case OTPTOKEN_HOTP: + step = token->hotp.counter + step; + break; default: return false; } @@ -158,6 +169,13 @@ static bool writeback(struct otptoken *token, ssize_t step, bool sync) attr = T("clockOffset"); value = token->totp.offset + step * token->totp.step; break; + case OTPTOKEN_HOTP: + /* Having support for LDAP_MOD_INCREMENT could be helpful here. */ + if (step < 0) + return false; /* NEVER go backwards! */ + attr = H("counter"); + value = token->hotp.counter + step; + break; default: return false; } @@ -188,6 +206,9 @@ static bool writeback(struct otptoken *token, ssize_t step, bool sync) case OTPTOKEN_TOTP: token->totp.offset = value; break; + case OTPTOKEN_HOTP: + token->hotp.counter = value; + break; default: break; } @@ -241,6 +262,8 @@ static struct otptoken *otptoken_new(Slapi_ComponentId *id, Slapi_Entry *entry) for (int i = 0; vals[i] != NULL; i++) { if (strcasecmp(vals[i], "ipaTokenTOTP") == 0) token->type = OTPTOKEN_TOTP; + else if (strcasecmp(vals[i], "ipaTokenHOTP") == 0) + token->type = OTPTOKEN_HOTP; } slapi_ch_array_free(vals); if (token->type == OTPTOKEN_NONE) @@ -283,6 +306,10 @@ static struct otptoken *otptoken_new(Slapi_ComponentId *id, Slapi_Entry *entry) if (token->totp.step == 0) token->totp.step = IPA_OTP_DEFAULT_TOKEN_STEP; break; + case OTPTOKEN_HOTP: + /* Get counter. */ + token->hotp.counter = slapi_entry_attr_get_int(entry, H("counter")); + break; default: break; } @@ -425,7 +452,8 @@ bool otptoken_validate(struct otptoken *token, size_t steps, uint32_t code) if (validate(token, now, i, code, NULL)) return writeback(token, i + 1, false); - if (i == 0) + /* Counter-based tokens must NEVER validate old steps! */ + if (i == 0 || token->type == OTPTOKEN_HOTP) continue; /* Validate the negative step. */ @@ -489,7 +517,8 @@ bool otptoken_sync(struct otptoken * const *tokens, size_t steps, if (validate(tokens[j], now, i, first_code, &second_code)) return writeback(tokens[j], i + 2, true); - if (i == 0) + /* Counter-based tokens must NEVER validate old steps! */ + if (i == 0 || tokens[j]->type == OTPTOKEN_HOTP) continue; /* Validate the negative step. */ diff --git a/install/share/70ipaotp.ldif b/install/share/70ipaotp.ldif index d257a46c38d2e776147e6c2a5c997a33cd100ef1..620c2ccde39ed52dc95fc884b2f1a00f25a5be4c 100644 --- a/install/share/70ipaotp.ldif +++ b/install/share/70ipaotp.ldif @@ -22,7 +22,9 @@ attributeTypes: (2.16.840.1.113730.3.8.16.1.17 NAME 'ipatokenRadiusSecret' DESC attributeTypes: (2.16.840.1.113730.3.8.16.1.18 NAME 'ipatokenRadiusTimeout' DESC 'Server Timeout' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'IPA OTP') attributeTypes: (2.16.840.1.113730.3.8.16.1.19 NAME 'ipatokenRadiusRetries' DESC 'Number of allowed Retries' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'IPA OTP') attributeTypes: (2.16.840.1.113730.3.8.16.1.20 NAME 'ipatokenUserMapAttribute' DESC 'Attribute to map from the user entry for RADIUS server authentication' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE X-ORIGIN 'IPA OTP') +attributeTypes: (2.16.840.1.113730.3.8.16.1.21 NAME 'ipatokenHOTPcounter' DESC 'HOTP counter' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'IPA OTP') objectClasses: (2.16.840.1.113730.3.8.16.2.1 NAME 'ipaToken' SUP top ABSTRACT DESC 'Abstract token class for tokens' MUST (ipatokenUniqueID) MAY (description $ ipatokenOwner $ ipatokenDisabled $ ipatokenNotBefore $ ipatokenNotAfter $ ipatokenVendor $ ipatokenModel $ ipatokenSerial) X-ORIGIN 'IPA OTP') objectClasses: (2.16.840.1.113730.3.8.16.2.2 NAME 'ipatokenTOTP' SUP ipaToken STRUCTURAL DESC 'TOTP Token Type' MAY (ipatokenOTPkey $ ipatokenOTPalgorithm $ ipatokenOTPdigits $ ipatokenTOTPclockOffset $ ipatokenTOTPtimeStep) X-ORIGIN 'IPA OTP') objectClasses: (2.16.840.1.113730.3.8.16.2.3 NAME 'ipatokenRadiusProxyUser' SUP top AUXILIARY DESC 'Radius Proxy User' MAY (ipatokenRadiusConfigLink $ ipatokenRadiusUserName) X-ORIGIN 'IPA OTP') objectClasses: (2.16.840.1.113730.3.8.16.2.4 NAME 'ipatokenRadiusConfiguration' SUP top STRUCTURAL DESC 'Proxy Radius Configuration' MUST (cn $ ipatokenRadiusServer $ ipatokenRadiusSecret) MAY (description $ ipatokenRadiusTimeout $ ipatokenRadiusRetries $ ipatokenUserMapAttribute) X-ORIGIN 'IPA OTP') +objectClasses: (2.16.840.1.113730.3.8.16.2.5 NAME 'ipatokenHOTP' SUP ipaToken STRUCTURAL DESC 'HOTP Token Type' MAY (ipatokenOTPkey $ ipatokenOTPalgorithm $ ipatokenOTPdigits $ ipatokenHOTPcounter) X-ORIGIN 'IPA OTP') diff --git a/install/share/default-aci.ldif b/install/share/default-aci.ldif index 1e0c21eec5ee0b3719cc09921f231743134908f9..adf961a004de347ff7a16937b5ca4c53466b951d 100644 --- a/install/share/default-aci.ldif +++ b/install/share/default-aci.ldif @@ -3,7 +3,7 @@ dn: $SUFFIX changetype: modify add: aci -aci: (targetfilter = "(&(!(objectClass=ipaToken))(!(objectClass=ipatokenTOTP))(!(objectClass=ipatokenRadiusConfiguration)))")(target != "ldap:///idnsname=*,cn=dns,$SUFFIX")(targetattr != "userPassword || krbPrincipalKey || sambaLMPassword || sambaNTPassword || passwordHistory || krbMKey || userPKCS12 || ipaNTHash || ipaNTTrustAuthOutgoing || ipaNTTrustAuthIncoming")(version 3.0; acl "Enable Anonymous access"; allow (read, search, compare) userdn = "ldap:///anyone";) +aci: (targetfilter = "(&(!(objectClass=ipaToken))(!(objectClass=ipatokenTOTP))(!(objectClass=ipatokenHOTP))(!(objectClass=ipatokenRadiusConfiguration)))")(target != "ldap:///idnsname=*,cn=dns,$SUFFIX")(targetattr != "userPassword || krbPrincipalKey || sambaLMPassword || sambaNTPassword || passwordHistory || krbMKey || userPKCS12 || ipaNTHash || ipaNTTrustAuthOutgoing || ipaNTTrustAuthIncoming")(version 3.0; acl "Enable Anonymous access"; allow (read, search, compare) userdn = "ldap:///anyone";) aci: (targetattr = "memberOf || memberHost || memberUser")(version 3.0; acl "No anonymous access to member information"; deny (read,search,compare) userdn != "ldap:///all";) aci: (targetattr != "userPassword || krbPrincipalKey || sambaLMPassword || sambaNTPassword || passwordHistory || krbMKey || krbPrincipalName || krbCanonicalName || krbUPEnabled || krbTicketPolicyReference || krbPrincipalExpiration || krbPasswordExpiration || krbPwdPolicyReference || krbPrincipalType || krbPwdHistory || krbLastPwdChange || krbPrincipalAliases || krbExtraData || krbLastSuccessfulAuth || krbLastFailedAuth || krbLoginFailedCount || ipaUniqueId || memberOf || serverHostName || enrolledBy || ipaNTHash")(version 3.0; acl "Admin can manage any entry"; allow (all) groupdn = "ldap:///cn=admins,cn=groups,cn=accounts,$SUFFIX";) aci: (targetattr = "userpassword || krbprincipalkey || sambalmpassword || sambantpassword")(version 3.0; acl "selfservice:Self can write own password"; allow (write) userdn="ldap:///self";) @@ -103,4 +103,5 @@ add: aci aci: (targetfilter = "(objectClass=ipaToken)")(targetattrs = "objectclass || ipatokenUniqueID || description || ipatokenOwner || ipatokenNotBefore || ipatokenNotAfter || ipatokenVendor || ipatokenModel || ipatokenSerial")(version 3.0; acl "Users can read basic token info"; allow (read, search, compare) userattr = "ipatokenOwner#USERDN";) aci: (targetfilter = "(objectClass=ipaToken)")(targetattrs = "ipatokenUniqueID || description || ipatokenOwner || ipatokenNotBefore || ipatokenNotAfter || ipatokenVendor || ipatokenModel || ipatokenSerial")(version 3.0; acl "Users can write basic token info"; allow (write) userattr = "ipatokenOwner#USERDN";) aci: (targetfilter = "(objectClass=ipatokenTOTP)")(targetattrs = "ipatokenOTPkey || ipatokenOTPalgorithm || ipatokenOTPdigits || ipatokenTOTPclockOffset || ipatokenTOTPtimeStep")(version 3.0; acl "Users can add TOTP token secrets"; allow (write, search) userattr = "ipatokenOwner#USERDN";) +aci: (targetfilter = "(objectClass=ipatokenHOTP)")(targetattrs = "ipatokenOTPkey || ipatokenOTPalgorithm || ipatokenOTPdigits || ipatokenHOTPcounter")(version 3.0; acl "Users can add HOTP token secrets"; allow (write, search) userattr = "ipatokenOwner#USERDN";) aci: (target = "ldap:///ipatokenuniqueid=*,cn=otp,$SUFFIX")(targetfilter = "(objectClass=ipaToken)")(version 3.0; acl "Users can create and delete tokens"; allow (add, delete) userattr = "ipatokenOwner#USERDN";) diff --git a/install/updates/40-otp.update b/install/updates/40-otp.update index 1204d30a594bcf5a50db6a9b07343e80846d3560..5bcd179f745f1e824747d5a836fbc54c16ccf107 100644 --- a/install/updates/40-otp.update +++ b/install/updates/40-otp.update @@ -7,6 +7,7 @@ dn: $SUFFIX add: aci:'(targetfilter = "(objectClass=ipaToken)")(targetattrs = "objectclass || ipatokenUniqueID || description || ipatokenOwner || ipatokenNotBefore || ipatokenNotAfter || ipatokenVendor || ipatokenModel || ipatokenSerial")(version 3.0; acl "Users can read basic token info"; allow (read, search, compare) userattr = "ipatokenOwner#USERDN";)' add: aci:'(targetfilter = "(objectClass=ipaToken)")(targetattrs = "ipatokenUniqueID || description || ipatokenOwner || ipatokenNotBefore || ipatokenNotAfter || ipatokenVendor || ipatokenModel || ipatokenSerial")(version 3.0; acl "Users can write basic token info"; allow (write) userattr = "ipatokenOwner#USERDN";)' add: aci:'(targetfilter = "(objectClass=ipatokenTOTP)")(targetattrs = "ipatokenOTPkey || ipatokenOTPalgorithm || ipatokenOTPdigits || ipatokenTOTPclockOffset || ipatokenTOTPtimeStep")(version 3.0; acl "Users can add TOTP token secrets"; allow (write, search) userattr = "ipatokenOwner#USERDN";)' +add: aci:'(targetfilter = "(objectClass=ipatokenHOTP)")(targetattrs = "ipatokenOTPkey || ipatokenOTPalgorithm || ipatokenOTPdigits || ipatokenHOTPcounter")(version 3.0; acl "Users can add HOTP token secrets"; allow (write, search) userattr = "ipatokenOwner#USERDN";)' add: aci:'(target = "ldap:///ipatokenuniqueid=*,cn=otp,$SUFFIX")(targetfilter = "(objectClass=ipaToken)")(version 3.0; acl "Users can create and delete tokens"; allow (add, delete) userattr = "ipatokenOwner#USERDN";)' dn: cn=radiusproxy,$SUFFIX diff --git a/ipalib/plugins/otptoken.py b/ipalib/plugins/otptoken.py index 67f24859583bb9c72da1faf2f5fdaa1faf69f437..c7c0fe71f2f93d3790be8425764fbe98716ae4ce 100644 --- a/ipalib/plugins/otptoken.py +++ b/ipalib/plugins/otptoken.py @@ -53,7 +53,7 @@ EXAMPLES: register = Registry() -TOKEN_TYPES = (u'totp',) +TOKEN_TYPES = (u'totp', u'hotp') # NOTE: For maximum compatibility, KEY_LENGTH % 5 == 0 KEY_LENGTH = 10 @@ -102,7 +102,7 @@ class otptoken(LDAPObject): object_name = _('OTP tokens') object_name_plural = _('OTP tokens') object_class = ['ipatoken'] - possible_objectclasses = ['ipatokentotp'] + possible_objectclasses = ['ipatokentotp', 'ipatokenhotp'] default_attributes = [ 'ipatokenuniqueid', 'description', 'ipatokenowner', 'ipatokendisabled', 'ipatokennotbefore', 'ipatokennotafter', @@ -185,6 +185,12 @@ class otptoken(LDAPObject): minvalue=5, flags=('no_update'), ), + Int('ipatokenhotpcounter?', + cli_name='counter', + label=_('Counter'), + minvalue=0, + flags=('no_update'), + ), ) @@ -213,14 +219,17 @@ class otptoken_add(LDAPCreate): entry_attrs.setdefault('ipatokenserial', entry_attrs['ipatokenuniqueid']) entry_attrs.setdefault('ipatokenotpalgorithm', u'sha1') entry_attrs.setdefault('ipatokenotpdigits', 6) - entry_attrs.setdefault('ipatokentotpclockoffset', 0) - entry_attrs.setdefault('ipatokentotptimestep', 30) entry_attrs.setdefault('ipatokenotpkey', "".join(map(chr, random.SystemRandom().sample(range(255), KEY_LENGTH)))) - # Set the object class + # Set the object class and defaults for specific token types if options['type'] == 'totp': entry_attrs['objectclass'] = otptoken.object_class + ['ipatokentotp'] + entry_attrs.setdefault('ipatokentotpclockoffset', 0) + entry_attrs.setdefault('ipatokentotptimestep', 30) + elif options['type'] == 'hotp': + entry_attrs['objectclass'] = otptoken.object_class + ['ipatokenhotp'] + entry_attrs.setdefault('ipatokenhotpcounter', 0) # Resolve the user's dn _normalize_owner(self.api.Object.user, entry_attrs) @@ -239,13 +248,16 @@ class otptoken_add(LDAPCreate): args['issuer'] = issuer args['secret'] = base64.b32encode(entry_attrs['ipatokenotpkey']) args['digits'] = entry_attrs['ipatokenotpdigits'] - args['period'] = entry_attrs['ipatokentotptimestep'] args['algorithm'] = entry_attrs['ipatokenotpalgorithm'] + if options['type'] == 'totp': + args['period'] = entry_attrs['ipatokentotptimestep'] + elif options['type'] == 'hotp': + args['counter'] = entry_attrs['ipatokenhotpcounter'] # Build the URI label = urllib.quote(entry_attrs['ipatokenuniqueid']) parameters = urllib.urlencode(args) - uri = u'otpauth://totp/%s:%s?%s' % (issuer, label, parameters) + uri = u'otpauth://%s/%s:%s?%s' % (options['type'], issuer, label, parameters) setattr(context, 'uri', uri) return dn diff --git a/ipaserver/install/plugins/update_anonymous_aci.py b/ipaserver/install/plugins/update_anonymous_aci.py index 2e01217f524e35208ab12f52befd54bdead5ac3b..0233a547cbae8f8fbffd3c3719885be42e9f333f 100644 --- a/ipaserver/install/plugins/update_anonymous_aci.py +++ b/ipaserver/install/plugins/update_anonymous_aci.py @@ -35,7 +35,7 @@ class update_anonymous_aci(PostUpdate): aciname = u'Enable Anonymous access' aciprefix = u'none' ldap = self.obj.backend - targetfilter = '(&(!(objectClass=ipaToken))(!(objectClass=ipatokenTOTP))(!(objectClass=ipatokenRadiusConfiguration)))' + targetfilter = '(&(!(objectClass=ipaToken))(!(objectClass=ipatokenTOTP))(!(objectClass=ipatokenHOTP))(!(objectClass=ipatokenRadiusConfiguration)))' filter = None (dn, entry_attrs) = ldap.get_entry(api.env.basedn, ['aci']) -- 1.8.4.2
_______________________________________________ Freeipa-devel mailing list [email protected] https://www.redhat.com/mailman/listinfo/freeipa-devel
