From f86c0957bd9242b676ecd70b2ba93c6f1206b1a9 Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Wed, 27 May 2026 19:21:45 +0200 Subject: [PATCH 1/9] build-tooling Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad # Conflicts: # src/integrationTest/java/org/opensearch/security/hash/PBKDF2CustomConfigHashingTests.java # src/test/java/org/opensearch/security/ssl/CertificateValidatorTest.java --- build.gradle | 54 +++++++++++++---- src/test/resources/fips-jvm-truststore.bcfks | Bin 0 -> 186757 bytes src/test/resources/fips_java_test.security | 58 +++++++++++++++++++ src/test/resources/java_test.security | 55 ++++++++++++++++++ 4 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 src/test/resources/fips-jvm-truststore.bcfks create mode 100644 src/test/resources/fips_java_test.security create mode 100644 src/test/resources/java_test.security diff --git a/build.gradle b/build.gradle index 0c12f07111..19a451429f 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,6 @@ import com.diffplug.gradle.spotless.JavaExtension -import org.opensearch.gradle.info.FipsBuildParams import org.opensearch.gradle.test.RestIntegTestTask import groovy.json.JsonBuilder @@ -235,6 +234,24 @@ tasks.register("listTasksAsJSON") { } } +def configureFipsJvmArgs(Test task) { + if ('true'.equalsIgnoreCase(System.getenv('OPENSEARCH_FIPS_MODE'))) { + def fipsSecurityFile = project.rootProject.file('src/test/resources/fips_java_test.security') + // BCFKS truststore is sourced from the core project: buildSrc/src/main/resources/opensearch-fips-truststore.bcfks + // It can also be regenerated by running distribution/src/bin/opensearch-fips-demo-installer + def fipsTrustStore = project.rootProject.file('src/test/resources/fips-jvm-truststore.bcfks') + task.jvmArgs += "-Djava.security.properties==${fipsSecurityFile}" + task.jvmArgs += "-Dorg.bouncycastle.fips.approved_only=true" + task.jvmArgs += "-Djavax.net.ssl.trustStore=${fipsTrustStore}" + task.jvmArgs += "-Djavax.net.ssl.trustStoreProvider=BCFIPS" + task.jvmArgs += "-Djavax.net.ssl.trustStoreType=BCFKS" + task.jvmArgs += "-Djavax.net.ssl.trustStorePassword=changeit" + } else { + def nonFipsSecurityFile = project.rootProject.file('src/test/resources/java_test.security') + task.jvmArgs += "-Djava.security.properties==${nonFipsSecurityFile}" + } +} + tasks.register('copyExtraTestResources', Copy) { dependsOn testClasses @@ -285,6 +302,7 @@ def setCommonTestConfig(Test task) { } task.dependsOn copyExtraTestResources task.finalizedBy jacocoTestReport + configureFipsJvmArgs(task) } test { @@ -547,6 +565,9 @@ subprojects { configurations.integrationTestImplementation.extendsFrom configurations.implementation configurations.integrationTestRuntimeOnly.extendsFrom configurations.runtimeOnly } + tasks.withType(Test).configureEach { t -> + configureFipsJvmArgs(t) + } } } } @@ -580,6 +601,7 @@ allprojects { integrationTestImplementation "org.apache.logging.log4j:log4j-jul:${versions.log4j}" integrationTestImplementation 'org.hamcrest:hamcrest:2.2' integrationTestImplementation "org.bouncycastle:bc-fips:${versions.bouncycastle_jce}" + integrationTestImplementation "org.bouncycastle:bctls-fips:${versions.bouncycastle_tls}" integrationTestImplementation "org.bouncycastle:bcpkix-fips:${versions.bouncycastle_pkix}" integrationTestImplementation "org.bouncycastle:bcutil-fips:${versions.bouncycastle_util}" integrationTestImplementation('org.awaitility:awaitility:4.3.0') { @@ -660,6 +682,7 @@ tasks.register("integrationTest", Test) { "sun.util.resources.provider.*" ] } + configureFipsJvmArgs(it) } tasks.named("integrationTest") { @@ -684,16 +707,10 @@ dependencies { implementation libs.google.guava implementation 'org.greenrobot:eventbus-java:3.3.1' implementation 'commons-cli:commons-cli:1.11.0' - // When building with crypto.standard=FIPS-140-3 (set in gradle.properties), bcFips jars are provided by OpenSearch - if (FipsBuildParams.isInFipsMode()) { - compileOnly "org.bouncycastle:bc-fips:${versions.bouncycastle_jce}" - compileOnly "org.bouncycastle:bcpkix-fips:${versions.bouncycastle_pkix}" - compileOnly "org.bouncycastle:bcutil-fips:${versions.bouncycastle_util}" - } else { - implementation "org.bouncycastle:bc-fips:${versions.bouncycastle_jce}" - implementation "org.bouncycastle:bcpkix-fips:${versions.bouncycastle_pkix}" - implementation "org.bouncycastle:bcutil-fips:${versions.bouncycastle_util}" - } + compileOnly "org.bouncycastle:bc-fips:${versions.bouncycastle_jce}" + compileOnly "org.bouncycastle:bctls-fips:${versions.bouncycastle_tls}" + compileOnly "org.bouncycastle:bcpkix-fips:${versions.bouncycastle_pkix}" + compileOnly "org.bouncycastle:bcutil-fips:${versions.bouncycastle_util}" implementation 'org.ldaptive:ldaptive:1.2.3' implementation 'com.nimbusds:nimbus-jose-jwt:10.9.1' implementation 'com.rfksystems:blake2b:2.0.0' @@ -799,6 +816,7 @@ dependencies { exclude(group: 'org.hamcrest', module: 'hamcrest') } testImplementation "org.bouncycastle:bc-fips:${versions.bouncycastle_jce}" + testImplementation "org.bouncycastle:bctls-fips:${versions.bouncycastle_tls}" testImplementation "org.bouncycastle:bcpkix-fips:${versions.bouncycastle_pkix}" testImplementation "org.bouncycastle:bcutil-fips:${versions.bouncycastle_util}" // JUnit build requirement @@ -836,6 +854,18 @@ tasks.register('testsJar', Jar) { from(sourceSets.test.output) } +def configureSecurityAdminBcFips(AbstractArchiveTask task) { + def bcFipsJars = configurations.detachedConfiguration( + dependencies.create("org.bouncycastle:bc-fips:${versions.bouncycastle_jce}"), + dependencies.create("org.bouncycastle:bctls-fips:${versions.bouncycastle_tls}"), + dependencies.create("org.bouncycastle:bcpkix-fips:${versions.bouncycastle_pkix}"), + dependencies.create("org.bouncycastle:bcutil-fips:${versions.bouncycastle_util}") + ) + task.from(bcFipsJars) { + into 'deps/' + } +} + tasks.register("bundleSecurityAdminStandalone", Zip) { dependsOn(jar) archiveClassifier = 'securityadmin-standalone' @@ -852,6 +882,7 @@ tasks.register("bundleSecurityAdminStandalone", Zip) { into 'deps/securityconfig' } } +configureSecurityAdminBcFips(bundleSecurityAdminStandalone) tasks.register("bundleSecurityAdminStandaloneTarGz", Tar) { dependsOn(jar) @@ -871,6 +902,7 @@ tasks.register("bundleSecurityAdminStandaloneTarGz", Tar) { into 'deps/securityconfig' } } +configureSecurityAdminBcFips(bundleSecurityAdminStandaloneTarGz) buildRpm { arch = 'NOARCH' diff --git a/src/test/resources/fips-jvm-truststore.bcfks b/src/test/resources/fips-jvm-truststore.bcfks new file mode 100644 index 0000000000000000000000000000000000000000..22f4b6c455576ae0b087131a769ae384043a6e88 GIT binary patch literal 186757 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

1}s0CIXzv{VM7^(uEEC0$Eq3Ri=fJJsp1 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�IH92ijQ$P+O#H~4Czbfpi<$DHi4Arcy)s7Esedw@i#E$`q zyr|8%?}FZd&@pmN=2e!Z@ks=g;m7g~KC2~zVEtuzoF`;ri83`PmJ@^t!k*OhVI!on(=qbOE1% ztCUN&-@%&ZM4(3)yH0N-t>^J)@)Qs)gNmYH!%q_uVJ5VhXyDM(;P*S}%OK=Z{Ju^f z4tE11nj=wb{HUK;LW17>$qw+xk-H=9JjwXS1ayi(b}Jyc-{~b!AdKfw=)swgdp|z# z#p>Dsg8zV(S+PM*f53E8TtOl|^@_#Dy{al)d<|{6KdI!yt4g)zAD{|#ctIG_}dN934B%p7?CvUm$p} zvSXP4SvxqvE7?n8VnukH2f$mQxX2$0MYy*jugp)dk9o3*9gr>mu;HBPmo!*Dqh zM{DJO7sVf$#F?Sj{o|&0U2~F7;V?~+XEr%>tQfYE$&T1njDeV<3+zViE}7XV7Omw} zlW2fP+#-}FU~J$ae@K4%ET3moqxVS2p~y-9S@2-QwHE;iTI9}Z{wtSfmKaSa`qte` zfO0QLfy^O-YkS!la^}DZ|EswVkw*juxVvW_YX% zgFRJ6&oKT38CHkoJ1(Ii7K&J!_X%b1hA&1(mDY}-_c1LmEWfvg)xk_z^Dt4>u9yha zI)MH1Ql$$RFeobQ7I@Q!A6x|xK+n4@|7foh&GmlEdD6gBu zO@(DyA(QV`IkQC(Kxl8lH|O~1p%!X38?&&-8JZZ2`^7ohBzV}cHTGNsH96p-%XNTcNY0R7x@#U-MOb{ zz#gj2N`P-6nJRrYbJQANrw3onDy4D?i+l2t`HlOrPJHJ*;fa3&_YDi)HcuzeDs(^v z?@vxCEC~inppi4hx|bE|Sh&}8S&@+i2{jqQS>nme>>d8#FpUWyHfqQHyKhNeoaW2P#7{ij?zZw4hO zv&Y#{&WNfKS?hIPCeupy?fm$QjQ+^dj*p4qDwC7VAdpq576~B)IBe- zf<>i@BKnu_;?iHs=CHu8wEeY~fi&d^Yv<6n%LA4l8|j5RN8WAq{w?ldN@LeKpZxEtozFW4sGgN9>cUbAfuZ=Q{PHD`ApG(3#h1) zgYCD%N-LpU{e@D;dvOs#%l^1lH8?)o#!$qk2FhYYtf+6%CE8aj17pNI2l^qfCP3h| zoj;cnDUMAaBVz0DBQx~P){+moyC+VP{-p_>4bpdDW<7&m_ z>2rA{xp6jw6!Q5_C9i;XjCy-)9M(8AbzYi3s*eaBB&59WC|#Yk=zuNv+72ajPkx$kH{r_y=5J%(-CFN?XACv zX)egLqv1j4)3~^Z)z{zX(Z||YAXZdCI0%Nzj`!gC_9~wZPS(SpGDW0Z4h?A?Ka^U3 zxXFbyY>kC!V2OP)B&N*MJ$o0<2l~uGb*{Kz4i$C_tHc__Z15obm2rWzM!R1D#LjWH zb%932*@?<`JzcGpt^dL4fKKrZhas^&pa7H`2PT{sYE$sffm?_-F>_g;xH4&n+t@E$ zy!g22WEy(62j($Yk7Kf!6=LQ$uRsx^Gd0nWi?8`4E-mpE!Y|Fxx5veDcK`OaPCmDB zb6Or7DTt-WD|!dCuSo%mg@^BFmuG%e0xRfdJf3rx_91*tXXN~}_Z-r|*Ou9e5w*_Y zmMNi;MmB85lLWV>_UAM}9!EYfY{#0@L&<#ZDy%nrcCqi9WJ|LUG&m-z6WY zcjOT;EKN&0|F>-H{72&dd(>8q(Ux|EgJ7y$re;L*_?H%AsAT4F;}%uU&GBdaMZv9H zk}1Y`bdh{MLPC+VRzeRgD9;r_Mz0aoU@<5HP;YlQz`5hmK@I6_WjR7T z;x~t;pQYLCy*xUbl4Ve|t#JR4!0X>xKIuftf*~B#b9u51wPx`k=_;0zy8)l~rf&s` zx7*CAp0yeemdF|U2O2Ckw{3*{}+ zX~D4IMX^l+P<(1RKp2Ums4(j@1o?yi)DQ{bJeb1{1WJ3r<*HC+<~?JLzVF2J;p=9M z2+d;Y;py#%w11P_w;LQURTs}wf~1OBj*2k4yDZXwM4}}VyBE{gPA1cHNiyWqvWSyZ zvQ&tcb+Sn3?-)Jzdu_e%UO!0fI8naYQe4_EI^HNgHkz(*F`m=2$7dGKdJ+T9&*jzy zM`wR0Zy~$0#^_tS+|}sI-4Vx3;SS0oJHK?*o3T97RmMBB5G%at`oJ! z@T6fRloRoPa|X$yKk!_A@1M$M4L1Ba+V}+aSQ~E95E!z=5W=x=M?P893dmfs)6|FqI}qzkOF)`JNSistaT{e~ zZkwd55Cm!>w8aE^I%b7CxL0!L{FOZ93+QyJlxzTMXSi^{0Mi^!l~3A;78lYFB|SC_ zbwVsm1DKs~j8uKX3@6raN2RS)Iv1Zw$D!U;vk_j3-l;txPdBVNHBR_1U)x&oI$yo^ zC`X4OUrmydTsWZ8tJ-U9F*VVL>jLvR-%%wrpZ+=G4X5U z=zlfkoACU&wy+L?*+VS}WOO`AteA{v;(_aen6fs2w#2+@T%wB2gigoe?UuwE-Jozg z3E?oSZ@@8~ZFvK}fw~u2+KpsFCoieTo|r3_It&EL>9WXyP_h@ z=N_Q+dXXrppF>b1K85p-zFgjf8xi3dWOH1$MB4RLs-VXOgHz;!SjE_BXV(r?NCS4J zof_fF#uixzLSo?()Pm51;I$aH)IxLgD3N)Fd(X65wGIu(uM}(Rdbz2QvhOwo=g*W0 zPFJAMVznP@6)scDPMdR24`ct?sv31HK=c%do*tlL7<% zCWQ2!e6pi(MPNrxdr5prpSY2DybZO}^q{{=vMW45IwBe!s8K3@VzTRO#gb?rd)^A4 z6+)7AjVWK$0o`{n{|E*foDkz?Tr+^sTl4WMP+u8|o<`tmaiq2qF$vh9DUQ($R$T4j zm{qlB5U~n}l{O?rq7XQp26);7iY1UKe_j3=n1HNKt0kU-lES_@3fvvaGqN$ZLNUZ_ zulgU%chCTD^pF%!qXwWcgOrQLTAHmZn$2!Bv}44sog8n<+`g@z<^h!uQ>Tk*FYf1i z`F+FB9&D2-4~R7NllF*$rhZ&^<56@2#93k&xhVLE;8i-pU5~Zn_-5Fyi5zdit}+p= zhtEvN$iSxsi4}iiqGYACkY|-N;gegth8~a-m=N;{cQTQ-nYF6Xm{;s)Bb)>vkoYpI zd`yE|bp`uA=$45J1sqF|D&8g9ROJ-T8(AWUkMg{EeA5LZM6*mK4z9!OU=@s1GMEn% z3C=_6L;UcfHS<1tWUXz@h)cdaT$HkE34bleGy!6gz>o%nafR?RZHM5B&Ye*6GMm-u6Nu)gZDs*Y$?M2Os?Xl~E zU}4Ic5<9BdF)&|2e(>QjW$qnW+ogLxjIM9)F^BGR>5c^J{b+Ml4uid2ez9446z6rG zTp#L_KfvLs+)mKIQalog_;B-4+7RuyPEzuSBye zlzXMo(wHK&_xM5Yjl%oh9G}+~U~-f$2b%+AD0+?hpIiWWUF_@Dksqfg9}Hpk0JkK4 z<;8f57blr0FE4P{tIXb%&(5ekU_v9{;BlvUzv{% zlQ|`GS0~+8WuLDu*7V@0M0Wo=6a@T>1`0h6w+`6p&hiTs1eauChY_WDx@wKzkY6fW zONi=OQSp#6+g6S8^TJUFtda~^VXgJdX4gMqU-tl>D3O!L^%gBcmct#_r&NNIQIUw1 zkFHFLh4~Gg`)rTP9JV~H-t*7NDI6HMjI^3C&1o5xJOYLC!A{+E(^@JCfj*D8H8upc zbg4Sg3r^~Z3ZF>TTcw`s(#;gtg$0~A8^{V~j;OH#McOKJ9pz?YoNlS%FBQQl`aTfL zk$yE)#U31pR8M+K^V{a>NS*1{DMI_5aS{HGEey5GMqLE^XYbA0-(dVcZ@~#u*`Lrv zrH(T)t%7RzlvBj}gcHCA8Drgj~%`~`_SiPWHTxLZ`cQJC5 zoWF!r|G>uzsh_fE?|4yzQ(Eca*%gDv2|cGh%MzPhwL>d*)%}ywOI*2CZ1bz&NFtnH zbVq#xe*AflnzmXuhFojK!1Yy4L`&0Tj{XeeElyN~QL_3>I$ zc{svC{95-7&syXADA)20F(ZW>wqOT#{z0{uY5j=Iqs^2UBTTA+kvM=(KP>I-bMRzT zO8Lx0=s{}aKe`H5NB@f5-lQ6U#6G*lxxab;M4*$nSQR=K43<8NWkm&nZm9 z`xw+|xDnWghsav!t8pY=|BioG#g%0RW;zij5}~1jy^`N@iJVetbfYj!Ax0l8zKZAd z7+dF0)FL3f%26fR*ztCrgx3K|G(?~g_t$9(YL6GnUH4j~oj+9OzEIVo56aOObQjA+ zNhFuB&m7oLBuHv%c3bI(3N^Pt7*$$_-xU|H- zSW!C7ONr0G-q2cy*|Y)=4X09Z41NA*^F&U8F0}7|Y>Dt_D(l;E%Lc<3I)7-wfewoX zk`In;q2hj@(l}B)phvjRS5fukc>oqV(4_SSb5?R-P! zW?oIO4m|#*ZqiSA<#2V^?^2A+w5O+mJY)qV*yTtX%brmY=@jiW!=-YQ1R%^#havi? z*Y(e2$qACmHl@*hhVHN6Whdja(gI@pET}YIJ7#blf}-6YmD|$i8GETINKqr_BWvDL zQ$bRQ2my;~Vd9@2mE>lQS2XmBsF-MM1g|;R8rHC|`Qs8H^Vp+wWl)3d<#!3={A_53cC;d+VIQeDh%&Sl3?_feF>>eS|j>K-5Y!jLHPeQURpPLMP)h( zlxh{=hpc>&LNnj5dFKT2*fx`~4ry)(Bv!6~Tjei;q~#g!2E|dDT3igtjDmgBxDPZ$ ziIZWf$?ED~T(7e?OwEP;E@{L@G{a6^tabZh4-`3lMDzV(jL=#(PUHW_g!S1O$CC~X0Yv^Urx ztT!y)4lV3gODKC#9n<7d`0dydavdsb?q%AyKx3cb$7{hNTeI7lNJ5lYCqn+EEx$|` zpcj|Am`P+7{rEKQOMf48`8*I5NlW9oZ9CrspK-TAEqc%20TK30+N~>Ry^cOKWe@Mr z0N02-ZOdRqCRvBd4V-)c!ge((Gt!CYd<2!@f5F=-gRO!cjfPe>%*_6o-PHbanOpBh zw}7pYWelUz&{#h?^mXwWWTO5r^I|JVo&D-2?YqL-^fD}?4(LnjW!6AMJozG+5NeG3 zpGm$9vsCD81a(g^41*h%;(KkVs|myUh2+r?&J!)d!#Xcr#;jEGZ>A}{NGI{aB15^Z zz}*sI_HqT{w33$Ap9q{Fg~hmKdz&Y{Cj~t^+t8q#MoP+#NnW*o-kzPeF=Mid3RpZ8 z(Glo$uALMdaWB>&qOHme%?|?6!Z_`GuyzR{4v&U8=cE{&`UbA@3Pa_Xfy+H^gU-ff zNp~x5(JQ)34+Cvs?CMmz4S11_%T|;aYFH&0*B0+KM-F#OOZKH_$1H&X5u>xL_n#iH?0_2=og-Txsh@zO*IM`mSdMdSmTV`*P;NQd5E4zBmt2XHRD zIa$lPrG*vUvuY#F)|$067abTBG)LGnMBb>S_w>RPeW`y&YUFncW0@*z^gbQ3bANOB z(fMt>(;^1%wa`vHezY+k(SvfP((|Wx}6)7gaKrKB#pssf|8Zrh`<#WG5)ah>kE|wuP)%uU#g2g5hbELts z*2`H912rwm=OWo~_|wwWHK4VNFp*ZC6S0-C=& z9h8yt>^>bTvDPNMbXV=&|l`5P!}3fEx;?6C=5~UEdQJc z1W!d)9y(M{WYDeGg5R@iCtRyCur#g4I98OBE3Q;=a?!^Ta_o-*dNCyQZP%1jK&W_p zTM2h+h$8gyI%e#C{knius<oFv==V7 zYIcSJ--@E;CE`o}m{hLK&}O9e52$t#I2v|kpF?GIrwy>}n)RU{ArP`5P)ukCGatA^;$8OWeWr77?B&&D&Cv+j z71!?#GTvJ`I6zdtZj--_*S?-*Vc`9gL__*&^d?GG{83~V1y_LqDeG?HW6aX&zRkM$ zzgImEwx^I?%$2E-MQS0%nqzg9f(WeAU>z!a44`Dr#eUoFSE*)7y1$7uU>XjDlVeks@_|I7<7e=R z_W)9w0;X?U3~!#%EeD*jRp~a)H|%i?`AMj}zQJtK$*zGM6NWuY7PpF#a{)lYANFB* zF|P7dlK7!f3Nx6|kH}>&|J(2H_Ug<*^uK=!QLvYGj1r|xCC2s|({lgNcP-6D5x(;WbM`i%5k4|l{|gf>Yi!2+kIE<8a>1_U}{u_JaGb1s<~ z965CI3W(m^7xI;h` zRps_|LUZ8ASzd3xM>OE%hOggsR=ciz19cURjKS@pgmuds2K=C2O4`*0yc*1Uu=Gsgf5wr@M{}ddWOE<@znAeuJe?z>+`D<-06zPb1 zp@wWS<7i=Kc^6V|LirUo(>3i97kdpRAhSvq4q%RaVT4O<2ZyGD!U?y{S#e8lA|ltH zj&?h(RvkfXa#cUE`=uhq zsGPASExQTJ`vYG5;b|qJ;di8Q7R;8T47r`lNNJD4nCtreV7d)u;Y;?n3vnu-3WsFw zuU6sum=<2)$urv6#m8kFM;CfXIT=a&l<_lj&H5$u)`z@X*5J>6bJ_SMbbO|_eVAP; zUL(Ng!3iD?JcPfYTH6>=2ePgs`0o$%!bb7n6ZW*^r7YsCi@`ycyN$|XNO28wfWrEv z)tK!Dd9^K>(QvjpQbQA;Hj{f2h5*TKA~KXba4VfrJ%-Xm2H4irs`wz<3P9O=y^{*f z7q*3TeATw}oCoS&i!F`3<|N)394j_2J@Xk-li>dqp)t-evKLpMicXhRTL&12FX zN6p2s5{JR6cSImosu(a?d9c}j(K1yQ(RDzyrhEJSsf&r-j#4S%hkQwmRd>Gd7P}QMmZT3T zz@%Mv=r)Yiozyg`>TktNIuF_L(_HKt34Rh2h*3BYCtF zKeVM3ff;BaYCfxi@^mreUWwDs8$sAcPH^kIJ4u%COThVBP0f`Akz#&yTa;BA$!X7=^JuV( z=pEP=dGWzOuz9{8jpn(TT(Eti{>-E4y(Xh5h387jQ%-P2rS1Z~B(pE&RLQssIrm#P)>Oiczm6 zX$SAauB{>{G0R7}OjCjf7#$Vj+^vRIxzxj5X3Mjo9!&Ewlb*%T1DwX$0DyTh!B=vQ zcny}*(o_3Yhe(TuxuIl|ic0vN?DEtJr>XHf@~ zJ0yr$V~_`aj5@qe53kAA>|KM-Y4svAB;wYppcH&DTCI*TSd)U#GinDBSg4^p6>;gT z-Om;B7_Xl>*-uZHvCXM14p*;)gnsY+g-k|6Ge<0r#u0_+wa^=$?nb9a2={yK`N2JI z5on5O!}rxPOXJ-?2zaW4s)+^(F&@pqp@x1E+m7F~an&wbhM}wD`x!G=fChjtDb<6; zEHp|f|MU8Gr_{6N=v@Uy_b2Xg;UYVcqSb}!hZG4W z@Tbv&*>KEPDj?2xKHfCV(&scb^CXxA;pfDNq8y7a-1Z(#e6hYcYqJ=HD}Ke5_B=}j z&z6TlFQwfryMcQ5$yZcacW;b5D0I%T+Yxnc{7c=sMltegc znH=cE|03-kjXDyOtV7SG#hB9z=D%eAPO=o?W&8{9DDEy_;NzM-=*-da{A2ezXQwiL zsSyy+b^OFwNk!o6({vu8d3XOV-Ka#sFc5NW@2l}){<&BwvVKygW$oVlH$(&hu}wm% zx&Q$Y-cWs!U2;^`!g$^*CKTu(b{u(>VxtBQl4U2*Xl)ly(8)J;8F#4u(oNv9@%X_YUh z#J#gK_M0ye=;Q4u*cVRvqJlAKVrS`bZ$4nQvyh+OWeT4I&}M!(BUuDO6~>p9CMYGJ zjB05vp1ZmRnP><{R=1;4Kdbt+7{9AY+#sAJ>k3et0f`9>FaHJE2qqlUF&lrE3(1?>vt&K0YP~5{p@W=JQGG__jx11fr2B8aFwf zT~8Sjb#`&iyTF?)zQPX%4}x+ zcfrI;gLE(>ZPQ{|7f@Ukv8~T_1eb1${!3FZl>4bN?WGGzgN&BbnUIxZTbh8H>Ekob*Ps!)1vuJn<7M}~J zi(p^@ElFahj(Dz1XOC1}|55Cf1X87UDW*Eg{uTJ0gV-iWmGLK4cRalMZ1CvcxkV>QNFVx6B#GhTsXAT?fShdJoL4;kO%U+jUZamd*xXo z1&+GQPRBU>)W7FvNL5W4g5`91)A9m)9l)6=qWU z8|6+ZPmLF9I`}r@?3*37pbgjTSfyTlOUe0D#}UtMXy%Q#*QQ8pOF#&jLd=#LN+!Hl zTs}+B&)sl=pQ;oF8~lG1lSbkJ=*a$^=D8L7bUKKdC4KpFoHX2_lG!Z zwdbpvrZ)gp=OS_Hn5#feEu1qho--s?i<|i7OFH z$-B{o@>G2pK8R{j63|Wsc_@z?#pd>`5aL$4YSl=N1^-F$U8R4U89}P4eP_!-1}|7a8EE z%IW+Fem2xkn9XZ`UD&`XKl9%4CpyY;&u-Mvv~Vw)+*DO1zh>v{LLFq33eO)91z6*3 zk7reLb&GyEEVOo7-itpMacwknm>p=wEUSkP@QeTc9$FVwUK(p)`x-j5yDf1;7{QJ6 zcfw5oaU&zwiksvK@veyQG^MRJ^QKRUsmd#8&1@Qj$)_j+$fLDQ#k|pp#ySClXw+%| zG=Hxh7lo7I(9e8Dmx)d^$M23g#ebKlrM;%K8`vB*0l2P1*&EK<;rA1jj38K+vc;4? zD2P(>%})0kyPB$DcmgxlTIGxrwQ-n{#>d;EqJfp)W4E(+_LYv8YN2_OiDGou73%|{8VPfB|_X~}67_{6dAZ+QEEzUZRgSe;n(!=eaOFYw=6=G28v&3oUPEl)xt zPo9G^*7(d|X*@O_xc#gr;xj;D*z>6-o-62)if1(>s599<%5$ve2>Cs;n5gx+23Vs*xxL5SPt2I%IfppT&K~hGu~pV5nqPsRF*Up z$JlUV>U_k9Q};f^CHe`({ZZSw@T&S(wJpqLut+PjtQul8#$0>SeVnIa63EX3z`3mP z;cm4@@Iku_c3^CbwG+jR=O0JDLVruf^&fSRjN~bz=Y-f;10LwvJy440FpCE^Xd(_M zq?E}Z1X`dE$QB}JVGlVsW=TUA6`L5!+cd8#!i>0Zu}r^`k$!G?51Ai%Fd>YXE;%&d zr7&di0PBmISkIZ%Rdsta(*~qi0v?gz#^yjyNv~kxIZqqYC~tgryge3j(Rzg^?>BtB zqkOw1Xy_JJwEyp?B^a3m(wnCcMnKupS&S*`6@EZ1Rr(NuoNR`qO8%K9ialBJjxcUk z3`T9IF6>Gg5n!&44nKz^J!_5TX*L}J!b)NL+;BI;Yq?{jj?Z7=TlPNburGYm!F~XQu8RhaXYwrrwz_a61YMcPe(BH(0FTqdYzHnt`~ZHlz+GzQ#(`RyTpIH zI#+Ro4}Alh`*$pdL!+h{D3LIsbg;i&YL7bN31q zFhhmk&8}y_>?n_MP8C|#djblIC5Cs|oh{K`v6M(G{fC%b)qQZuC_S`=atoN1KFp5Ds!XWF)K_(mJ+2zwNc$Nt7`i|7ncsOO`cNQ{Vbw~O4& zcKQvd!cA0oDs!n32+L7if93H3kezs<-x!9>pP`q~tb`U^Mh;Wyy8?AjT4w_&>xLMl zdlpVwi^E)A%p|kOO@H#^9K+Ub)?&C5D`vy;l&qXBL+!28t+;m116H$TVn1l&^j~;g z!<%coy}Knl4z!2srim-(_fZ$<&~hs}YfZHgoW)IVf$St3 zg;AptEY6-iG{|ldyagk{lQg{e$^fitMD)<6N1COCpo^hM_khjUuDRg9+qvXG#AcJ`dH|yHiD|g znZn}>nr$+;-U)P2j4$=z1?ot4sdSIG7|%C{-nP6)AtD@8{iybsArXRJ1%_HUm5>s1!p8MBEai}7VFjo%TuCVjD0Xqc{o{W31aw>`d^&E1wnNu(AUn9x0nCw zz#M_B!E57Od&7@QO8&R0TIQIafXZD8^v4J(O)<_gof#PBez0%3yZw&C>;36e?tI^3NjW?awM5D>6|`lWZaVU!_pe`+d;$Yh zF6NlS&hH!>3)WZ3U9#-pbZ}CXvIx~D?ei2f=G-fAlr%gZChrflci(_|wrHPrFSm>x z!C8`7xg<=Yh&lb$E##~TZ0eHkT1}AEIM!>QqYZLWloAvXvbp&j2tb(aPa&(W(8v0Y zy*~dcd+1|CI?4z3>s9lryC3!h7H^qS&y9>P_khGgN-RMG+uW>l^NkqpVvw&|mXkY- zG^o&Z=pSmn_M!^d)o?t%G2cWFp~?ESpzCnXTfaYs(>r;ZMG3p#)>>t?uiZqklwU`* zII@>8O)q6+>_~ci+HKn_>+pxtS-r8bM>{p@6_D||Ff-%e35d?ipkN+}Rx)0`GUhMC zQv#?M*+V47v>YK&rba44pVk`fAT&|6OtupVi`W#7Sbbq#t0+ZnetHV;9WQocDYatu zQ>yq=Tn5dJX<#_O@!zF(>jG`Y-eQ zRaEVk&eB9S{v#9=2b+ll(mV*#qTDld!&Xu9Rfq%Tka#@4{Zxi?C>Gmvo^msHnA2mZ zXhN;xsmJ6pN|WXn3OPXxczZ+cMAgw-*?ojV?I=^Ho4Yk0iB%-gG)d^3Ps(Iy|3GMg3H!g&}*S*zv3cR^6egahAMT_wVoWJ z5_bV&zgOt5k^q~~w>74IM#fh5FYi2LI#xKK=Mgc{>Ki&eQfG;t`d>`@vrSeEHVre_ z>$c@6dUk*#tW+3DI+3Yce!VuA z^>WgXNMV3vB5FCydZlX)2+7&673<@ce+*W5kG)^{3-H>801f3}KJpC~%?@LrGuz(X z{~a)?z&4ZjjO^WLm>ylNiJS(Xm}qY0n{N))Sx=gAW;bQ65pI;SL&_eLpl5p#bFs-* zzlh45cS8y%?41fhr znzV0tGFgx}T&Oan*QW;UASO0tA#zX$q3Eo-iz13rq&YpfGB1!$mlWfpE0{&NcKId1 z6EBWqhiPyRN?cIFzS$u^y*7jgt8JFH^cm3?tN`;sv}G*l=$n=|k$yUlJ=h8+s2mstin`;Nvwf>0`UUNqYA?N0h_Wq8@#!*ZVqEJpGb;}9NnnMy) zprD4QTJQC6Mas$)2%aN;$zW-hx)cD2s7SvDUR_|m4{f8~9~Ve$rJ}0x3CQ5Zk&u)JZ(h z$&0dI7L!cn>;b4xbDm07mo#y1#wU2}0w>(ow(e2o(ui+w3y}1%#^Kd%N5-_X_aJTy zQ;A6T0>>NGRL-Vb|5Ig(g)Bd76|PW~>XGsgyUDqUf0jMFwwwcY4=y>Ix`$EeaGkD5 zX|SF`Fzju&;~PlLpZ$NHe_h1w1WYD{UiA0nF9?Y0g%f+2ER^PpT&~d`ogV2eLK4}; zWyBY?XAaCmuDFfewf0F(N8DUOK5x`uWh5_)+t@jl7k)`gi5{eRk8btZ8aK_hx3jB^!hQ3I zQVTA8{Rrd)twlXvcK@E@IAM1Nzk|++-|3N@Lwb#4p#+)I5{!E{fX#ev={x*jjR-Sk z7>n<}YJHfLI5cQyz;N8VJcmt7D0TG!IV3;$Svng^7Z%@N#n6J&vHc)bzL&vD@2HQl z;ysvDS{6Tk0L+C}LEMctDc?A^i_jP!L_sr1okVA*bwGWUm6ItXpE4^g3fa@z8$x?rjqBK}v8E zGDKdq4iHT_1UhBRHvu53S)ZF~pC0~Hbk3?P6Hp*S2?rwD_<8AaE@|boOz6?1Dd)D) zhU3V}e(z8zcqe#Pfjzo{U?4#OeIvkRs0$rUDn>38o@>r< zFuttOS57%EeM6+(L_Scy)Zq!46eCCc^W2Gpn&Px5H^REkt4FLryscj4sv6P(nzpl{ zs?E1fd4mV|ZZG39{O6>@`8T4XF<7~GhiZsWB(o~!eBhb!!C3tga5t;< z80n~Wa$Cw4vf&X_y)wByS20j)pS&!Uk@|WJZ6?$m=_eV}O$B|AvJN(qN`f4Nnc2-7 zl=a(XgBqG`cZHK$?C=89mu7JW=0aKwz6I_GHe~d}Fn&z9C1rBgy^xsne2acDi(uAn z3x;MwwwMxSs_Cs)zb$yW&H7T1vz-Nyt15kY)E@Y}zeP`*5W-QGc)*ZxPw7!dfRhSw zLGbfFCnS9bcnNE=`JX9ulC*zm^`ae=hL?O$6Yfh*uUJ|O!t~8YBv;(b4se#^dvo7A z2j23VDJ1tgM5(}ZrA~Oy>K@(}Rq)&6CQNoh4NUOQi}_^G-@6jv0eusKG7~Rrs&#tM z8&tTruALRpq9=n-?qwPeQ!)AS@s%DaNRpL>Yo-ppsrGFLs8GyG5!EKK*qnBOZrv#(swrTgamg zSX_jOpGoRQsS5GUEz6ju&6}E`7~y@Cj>fGtmAofCiR!Tau810{pU;`t_1K>%AX~AK z=sF)&)0GHUTXZ;E1%$)zDfR|qak0^U^4B-2 zSL4nj+ITjZhZ$~*?GJW{)oUdysfap%z^Aw7Ba+zkD4W&yQw~F^z+;tp+>;eN-Epj< zW}9m;KJM*`E$|>8L|7APqSi%q-2(^B)fmN$!~{mccY8n@00dQN@T)_ZIStMAP(!{F zs`X3fVPaz_@P6k~&E3wkDNyx<=bR_~FtB(wR`~#Z?RuFVWmh?9sEjlJcrn#{MMP`< z8(-JF*E>t(+>bs+O##p%u_8Zsg*!6E>x;5A)5at1^L(I+n`y&xoGHSKMDP&CGle}B zjtbMU`Fgi|crZrW8Wt-T!=)Zc>gv^%=K4x4-xd9gNXG^VYTQwE0}dBt0z&Jgxy(99 zmvfDcIVK6oCL66f7n=jU{^4+>qnB@5RbPsEAHqbKI^7j z*)lYFEF~~s0*@GFjZHA>$A(g=Vo=#G3&app>rYiVmXDG1M(q7F(V=SDR;re8o|m*E zcLxd2JZaSy3dxwmu%5_xJdIHUu>Aku^`MdOxuA2v(hpfBKpqr`Rp(sh6NGD+QmDCz zTntWnUFEkTUHwA$MjB)7~+^<^&FZQguWt6F8%k{RzR;ztRww#NW zt^tUS;>gi5Tu-A@=|Rdz(HY#Px72a^6Nb3l+0YmVq7Q9BGLR2!+cJRfOLGh&B=^U{ zIy@Idb0<=yACC^5I$jFAc?g!&mHn1ZEcJOfm zM$+W437~W!$P-Ek@;;9bkpb4V7F4d85t}Hu^$c3E4k}&mqnK7+47hyh`HF7Sn=w5I z{Ubxpos>M({_UkbIW(YE=YC~D{GZq%uX%`U&J+4wl$KXVfy24D1c7Hbd=nO;=(G#n z(76dTLt={UI5?MQ!DCQgH1q1{0v(*Zwcx$XceGa3oKL|tyEqCNR^jpp4=D5U&wUAaP zP!I^$ckb72-8VQku-|L7Jp@R;(-V0cWZdk6-*thg@1S`iguA^&IO*Y-cLOyQ8SE&r z#1VXJaqr+G z%yntuCY@JWr`;n90weD;LI``M42AWmvXTU4Yo{%qAp%<_;(X+~yYbMQhOCE5s^}{! z{}sT-l2{nChl8$^YlEG~7>J(PY91Kf+G%GnN5fi?l!?O?Wk7R!_L0nvIzSX!)*=f! z)H>XULZD)+nUJZBK>}8@WMc*Z3Dtukf1rS7EdF!g7|znqoDJ%;cg4kd9fgxhuHZ|6 zK1@j_HT&r!{xZiC`~4PrARof$z{&C;#EYWdoZBLEJx23oy<;rZwn_6!P+GI`Gt){u zZF99t$2{JqTRED9Ro)e*>M69gs;x5+F`lh(7tGC;h}o~28cIZ5RnEz;k)-rR;(b{7 zaOi0kK`3#rRpW)2$9!9%X2Uf3i?SXgoa(3|8JCZ+C2Muo?a}Nks2ielGqp@))<^ZB zr1HS=oHofaOFQkfYp&XCPM3zGdQsv(mEpCHg&U$yotRsym%6&L+|<76cf=_7AJb7z z9nA6jv%jYn`RSe<(9j6zfYh_rcuGU)Nk)1gG(YJa31{TG>^7chs0KRHM(F{mc|C>c zOSDgveFo1gyOi8UT&k&|edKkYnKr!|+*BjlhUSut2cn(9{3ij-~Cch!0alCYb z7{({H(G>Q8t}7o?1N}1N%glO6T-Th*DQ`tu*ocY0%{WFrtv_slin6A$$$y$nN?g-f7ry7l=o2&QyIThq0RLJJo>uLL!Bi1U%)J=+eQ+$U_-{>< zZi>~6(yjHfpN|gt82CWp@|t+V@X|6EAz(2LwyJ<<-Yq@0IFx#3pkLxzS;T_e_5N4` z$ND!0VBnI?HU_prHa*f`V6*v{vH{oHtDV;%yd3SUONJ6?PIo;;$4Ljl3gTPajTFKM zTYgs$grdlh9vT02%a4YF?6rA54Ji4v8@FOW<@XxTTi`tI48W3adJb=)Ut|=om)5=k z<8C+7Xa{&bjdV={WS!_=n5&h@?NF56He`b5F?ZaWeBCN`W|QRS(S+9VN)fR`K%N=n z3-rzomrD?->%*{9cCn`HrUUA=f7a))Mo5+aW%}9EH^vfD_05P4_V`o|bEV~~^~8mG zyl1(^PdwMfkN!N?M7WKm2;wpaZHN7k< zTY~6O6TPDVpvQt*h3Boqy8S;`@h6dXt=DrygYA`4NW3AuJjJ=hZySWXAcvHAvy3;Z zF#>`lIk_W;VSHgeiCC~x*h$GG^;U~xhfo$#uGzPqgzz1iWf8NX*vqX*_$4m4X7afc zoOQ^-0%zXIrv}D|8%FC0!wgIp19zU_zs7+l$HX7Wg>pzS6hd{K{=)!V$CN;1RAcDi zVz`#v`%)*otkza=nK&b!GYgCSkJ(=Hd0}ERuIBDcVshZu;6&kQJ{{%tMquh#SZ)MT z$!HERv^%CWCqFeBOS&QDq$_zp-`ph!8v5L?Rvs+hRQ&)|P|&i$hb2%oa1iE1DIJsp7Az zwF;SYGEbQnnOB9}39s~ClDuaH+`S)j;U=dv1s0U1vihqkIycWN@M}q~8If>8&GU18 z8|*FbyCR_PX79E8QXvCkc{kAGF2<*fNqf3Xsa7t{#G^^T?O8@x$IsBYZtv7Le z>8oJv&jhb?zeErWxbJDMI08i+Oc6bXr&dqRlZdVK4@G4@t8EFM=RFj$Nm9)>8fd2^ zgZHAzLdCh?#L`O{aU{Xeh*I!!qXIBCTxbs{EMlz{FZ_|Bx+|Yl{EHLmCZFS1`!qE) z-9RFdg+S^m`U?KT^eOmGZSZqvKPggJ#$( z#4S_{jTYV7RuxrzcF=xc(bu0UUQ9N_$CRc$#R8{EMcP;-9PN9dLAT$Lf@6)(jBkKO zTET&1W+fU%Pl=0W!+klkh)`+^FDWh$q9vDmg0Z5R#2XzrV2GfK60no~k|v;>jQuv? z(|?n;j_OkuGtDQnrCWESyky|G?9EAv=Y#?aMPEdi;u@iKR8r5^=-9 zg2@L!E}#hf{QG~nHHOsTrjROoR6t@aUsl*nn!k;}0{TTiH@--T+4anr@(S(4s}jsT z3{R+8ve5lTc}d3Uxfl9ocSUJV2&N=X@?6N?t|?oK5~EH?s)q(vf*Ubh3~{&=r#QqY z0f7K@p?zZJ`6aoQ#EaH$sGeX)fuz;2ksZ>AGG6axCEYBEZ+KpS&5pUJFmf%2VJ=A9 z6=R#8C^?@SQ=fB$gHgtAMQI04l8_12;a)l`J{uOfi@EQi->@uW(}_mpc^ae~_{E@G zL5@v!k{rr!3b-O#kEl1TjAc=Di?WDs-=y2tr3gc#CAu#=#gHsmZmV@XDQxO8>VF75 z2N4_#3E^D8@$4@^=dQ0aCPfGkReIK0(|t2~>0yJG7f=d(`U!3kG>_3_s~TO`vU$tD z=otav(iO#r=EivMuniRYg1&D6<9clBVR4J4;LxImYNm0baZp24x<>xom!oCdIfH_= z4m%b4TG77b|9wib>86Q&%@p@f8I}8GQ}aQ1+w1mBTVs*lm$aF@Z5j!nMPHdj;j@2* z;k`=`Ql8gR{6hA^t2TpcAl?cQf(Ort+o7<*PTcRnsqgCKqg8!NS9i9Xo9y-Arf+P-hr%dCb&dz$hg(K7T^82(>%8=Ix3d$Vq**B` zRj~T54Rj4b{UbP8-yO>a+k*?GvzlgG)ideD8H+ zMyI!_n5AA%pW!gCI9J8`?Y4sHjc~|%JOLotc2s7OAZ$TEz%6R)ltsYOt9yDi&lfx{ zw6=reneF`MC>rcooy?WLp+)^_Usb(PH7ZbHKvT(iKw%upejZwPY~6!oTZwdJFO zMi%Y-jzaAq@z&>9EnpLJ3b(E`ROh@PlU1rKmM};_ARgBpRp95yvMZPZo7vDU)`Vvk zKzD4mfzQ!^Pv0U|dK3dqJLyhoei7(rNBf6;@DS z#VQ&mpll}y)$cX8I09{K%y~8+HVr0IPWtpZiE5AR_wAg;uHDUScN2-GZbRRFKr!cH zfwIgnP4NP}HG93{46n$HGg=}>YYy{c<|tCAxpcEt;-8$Y)!)FV-R=FULI3go)fDGr z)693DFh;eZyI%x)OGXkN06*vSOA_zv^r(_vNAK+96u{dz*b$bgM@NcKSikoIG&v@a zR6-eq^)RJ~8q4;M`RdB@<0D4#8G@QgzJ!k2BJQ9mxbH;WIr!M{+jLlK#i_WE$15j#b%(C|DOZ z0rl>7FrhEw(8Fs!4+0s)+ zsMQaY?hG%_si>mF9zA1WZNmphUU+?;W<;)b&>;=`yuUXsspJXq9vnM2k7hAadl+#L znNH~wf8=BfU~gWo>MC}6fD?l;vb_W}dZQ(Mi-wC750GHLuiL_4hKzpj>e31z;lhVT z6hm^CSO~Ao`W}~8QHGzekQDFlXJzkqUDqbC%?@Rk;MpXRD+;|yN*D<#sFx(Vk^2>V zIn z5&DE!@302-^6>vBc_^;88~r-CKp48jW*m~>%XP+Klxa2H%E29pf}0Yu5H9L^!O`yf zqaTThhbTWR^7)1zY-N+0bk#&gHM>x=hus1CU{>99T_RZTR>6ijo?&9WH(C^Qb3DYT zUJ;{^cSBtL{%f=zfgZpA(u8`kIfz+uVMdzGQ`^Qyvk)S-^DRg1JJ~GlsxAI?=rfT zquzVm@N~*7HnJ(dM9dPC0%BjS-l4H`b&_RlyLAepwO=9;wVho%8w5t*w-nJlvp!)pfHCpKnc$WnP}m@P6fg22Nvbyrt^ zop4_SWT12IF&9yyaNwIWA*O(s=Qst9`#T(n5=`^Y zJuPUlIGN2hg<@Tu)aX%=M+Dk)qzt7=;yg4rBfB+wOQ)*Q?h@~=VF_xK9C)+(RG&)M z>lf5f_&6wY_*xO`G1@Va$hy_H{=kL|lXn3e?sc)bDjinjRA;J`&oJOk}a(6Scgogw2Mc?OphN0^&jL# zv_VH`W)^M-Eho)09a5sEPEPUb_^+qWKN!r#`H@rPzS0RDL!p)I<+Mv{2v%EDO*P8{YTAD2(KV*gbf%jmbNDsqb?Q*j3&h)><)_Z=y-@Vm7W#>J`^?WglRg9TGQrceoL60moa~)Kk#0wEl>m}&bW4t&Kd8>`3WON_{U*UjE>Ps z72LH4Mx9NHLV)>viU2DucIJ`e07v>eL)5DGC=5nH2V>0`t1(CJL4cUw<=o9sl($#_ z$ejG`o?)hK-z9iKPa#h=q_2yYwL7*fi^`_WC=)f6=EHvh1A?@-I1&n7hSfhd|5x z+h;7r@JDGI-f^A^gkdGqZdlWR#~o=!q;9mUC!@<8xRqP}G<1f=vH2;YGsoi9%N=$&T%74Y5q9pXa&y>)Dl|uKDRFZGZwRAq&!9#1x z5CDTI-@KCk7=_n`A%df1A4USJaF^Ph@w!>F;_ynI48JwTms%Sg`e^_^?ubXJv%(TI zqA5vo7v4RUaN9PEOfPz_MZxQ;smDsETZCZei?cVRX&!WtU>2(U$b5e zF!VArjqjYS{Wya*RitS6w=%emqP!;Z&7&=1Ow~m(ih*T_ES%oxpmSbn(flhqXTe(O z&L$eI4c;jaB|@Ym4)1_Zt|e6AkG$WyD~G;U{$`E#o9&h2=0(T(h%m)#a;J+c=_V3h~=anhCCm60)r}cdB~`-mJtqtjOB_IzkH>~np=Sl-WbNlDfU%y zBDhQ>N#NyAb0Lwm{`VIRZN*;YFdZtC8LNxBIe&r39I$K>$B<)Qv5u>^F6hx0!ExSc zYTn_NLGHXf`XMI53ec!q&ve1F-zRHxD?lk{p`e1`U+( zY8E16Haui0P-F`*@?-!N?bA7|G-gJzL-o*Js@|lJeb<9?Zx3J~QBqiwX9;lS`FJ84 zD9Sp0Oqc8&Cq<~AWiaQe9>b6OKE#j@>U|@PY`KCVjI^4 z_G3rlepccgRzHANmu;Xpyu1Zw|CZI%X$iDT+(rk=Ta=Xrh!!unTFmU3Qkh`;$B;ff z>d3`p7*UO2>X3U<*;7`q`Ds;ewHMdl`D-O@i^^4HCXy9P22of%?dx`^S?kYjkOK?)+da_s=GX__Qn|LP0u_M(v2wylOuidP@>zkmlbX^s|Jr(95pYNh63oNr z8%y>k3u~ZIvgz4-_|hq}@6_C*QCKzN>QlATf5`C;ESz$)bWGJ&1;4u3wQMZCv^BS8 zj?7{j#BUok4r}@A z=kaPc4wDrE(f^bzqY3_y5ZZRc(2Brq27mdAd-)(>)7At&KfncT9}4y9NnQN#qG5r^ zO+I9n_>L!&XJp5nzLLi2ifIRbD{TzPtCysQWzj}{bV<9j?WzQ0%bma|AnGa6+$Ia( z0ga1XBikw=2&Vf^Z#z8v*2SkGVMn42EpUGp-T=N>kP$ZV_@LE!(>0r0*m9^7=?ma~ z8m)oe-fzdT!l)})COl(%YlF@_#12w&+p+sgQ&nr7(N^^d=TIPjr$z9UhlOG^kA6V( zSwFnFrR@WYUp{I4DDum%W{Z>l31tn9Xh@y>Zp!^kiBK>P|B z)m3&QEqak4zgy!yzFG5;>Jz5k7<~M9JCgWG>ViyBdN!`hnsJ#t*AZE|jrnXO5j3(A zyK^kcKNNH<;d^YbYGayoIg77GfG92y?8V`977gfUGvGfU-B!I>yPR#bN#{El8NqcqPH4pAGPI#lD z^PE;GwDtOBD8oEJgsauTh7`p9O`TvDeTL&+*(&GjZ=)N2v}6ckmX?O}XpSu*P+E9h zE*Qw=E2%qWX?DCln8oYQr=sF#%#nzYT5qnn>Wh!dIYouPK}R(MHY-UBp+;*APXt*6hwSCA8F_X@cioll)=;XiNAIpXo zqI5uV1>nhF)zvmzar?=aRm83MAu<*jlah*mLLziiKZk&i9Zc2H*N>*F#7)YG{8NGa z5;`pDzYOx*dihFg?=)E;dDmdFHw@CX<+8&UP`4A#xu`x+jz1;j;KzG6Fd1}Ym0emm zm{s;@DORIiR)X9I5%A>z4wRz3m+IsA>fx2_N4#h@c% z#MxpJ4RSaoE2`j-P3O2H?Yjho&+*x*OFms4M6bB{+`o7;u%35+1!K-=L24f#QHa;Y zVZtaQn-+6r2-Tx{85#WQC&~%yS8uwfJB%UW*T@p0t2W@L#o>WLtWg{(vRi0J3+Ub;Yv!m@&Q5L$eHBTZuFGu`Bn^bm>;8-mI1CvgT;&)6U=P zx=@WS`??-)uvGsLRC!;E2gur5pEZK}QF|tAG8xL{o8(A$}F! zkV&IT{y7NcUh0|Sm4t-N=3p+Be8)|U@+rNq^I(nL);hrse2bW^-Mw+{8c&c!9OLcw z;lfAi@F3ynyJWYeLoRFNA5iq<;J9a7#{D*C8 zKU?>C4FY2D|6fiRt4q@GOaA^k#>;Oo1Kp%qNZ4B_xDEA8(!LjFh-utkJQs9%xVc_5 zLI4f|J|>)ykWHfwL|FbnOHD9tX9aaN8jFQUB(&cZroGM+$ zdln4qFcTPZvIE!*h!7o!H)y-2_X{D?NhXOTAzw94EzM-C|BA@Y5aqCBuN6iJ48U51)8Bug~Gn62YSUn(;JI)~7H-E|_V^-A0ekiK;9w}k5smFLisdh4< znk^0Q!e$l!<3y4ol|sS<=GG%Y!F1JshL&I(lAW5sEYRrPx)RpiZ47JU#Md@pP8y5I zmSKm=u&<`2JuReqN`E#f0oooKUX~FE36Z2hQ9gMJylwO^PM5BR8G(Ad+Ar%mGNauc|9exm+3{kN{vo-odo|5f0aw!(7c!mR*IUS_!-ayS4dHJf?0YI25S?-r^L z?MlQ9(E!x%sl73zE5{cf{;T_@th%%sU9a#SFX8RU2=njF z6at2=J;N9nDY}GS*iQblLuXta_p25I{M|JMl>yAc;uPr-jZCUY&RH#te|CE@+tJOM zTVk>a^{f9Ytf#ZDw)(am+!uhTMp<@HXL4+0Wt|#!v6pT)!c@KXVW+|pH$NK>wh-2a zS#kIV;!#?mvWZJa+&5JX;xiejPj_5h$1a#LHoolQ|1kbXT0?mHRZ+x}6^Bn}pkN;| z9LK7YY5XNQQ5}Pz;)pz|Ckj8F?2I%o&FtyvbpQ`raKoNVN~WH^K`(Nb*p40IBq5=L zl8~U5Dt`pB64fr_l9F^^ZSBc^8@H zcuKgI9W@a#g`w{(P`DE+-iZ#og_ll?dYkAk{%ou5lg7aOPpxuHGP!_)t>j+*?L{zW zgC6NVcB3;eiFRj%f5*flDtgYIFOx@2yhWB2xSGYTXHCLiB7JuORYYhc8FJ23V}#Zf zIR|=^H1L-@rX|003_p0z|28h3OM2g4a{m5k^It3nHxJxLuQv6O!8dTYLECdsZ{RPu z-^t}JUpO2DBK)`Mfs%H8K@8GyeerKpl9nuzUX+cd00tBZ#dTLfbn(G zqbx6|YwI5%wO^PDBKY$#!gnb&kA6;gUzBW*5`Y)3{}(M(t?1TUWftm?`2&DvPY9oT z7CNFz$8r6yfJ5!wE8&uLoNG*VTfboh_r1A3lM4*)hZRTI7N(Lo#XFbLDZbU6!{c5D znthz55Hv3@B~+IlC?%u2C-6d4UbbRn-4fsp6W%`bJ67OHxsU19B6sXaqx=vU#70dQ zG9n0=X}|V9WA2YiP<8`j(E}VvekP@z+mKvMAyr+BQk>`Z#1q(`U^DshGOXNYL_rId z0Tq}Obe(zXBm(xO1lgH&QCQC*vEXOqyYS4E^!FjXVyHp4M|9-hUsa#=R35i0i!@Wi z`~l)bfBxF9ksw#wrR`)WYQ^piv(qt=Fvw!ERIp={0akDr4l2d?UQq<>*jEJhFDV$8 z){RvGTlG11$KdTBeJea|be&-QfkP32uX_sFv(Fe%u#bd#alGj``m-z|fmq(?<5CnE z${R>rei9N9fVik{_c~wMJZe)Oo}{$k=kmc|-tDFS&CJbR)J&JIKr=T?hSSVgo)E;J z?ZLS1*WnIN4x;H4&F%YBuXR&;xO3|yE$8S+B#h<^jKEZs&*H=rq2^_%3H>F?po-Jt zJD&;-+ zHbe$l#=|L#gc()Q*7lc!xf%lBl+#@467F&he=(oiDngCJDmh)nx&LgosrZvT14<}n z&RHKQAIw>=DZH7wilV*(t#S6?SSwj zO6qu36vKsjWphr;S3{PrFRK!ckCnzFKWf<++&#jMm%xsx%3J?VJn%AEmC@)i?w}J8 zljMXIwqQ3Z0;nNxx+hw8cicwl>mibnJHJkxeTLm@I8>A0>sT-O9TC zI?2TUwM7t)vaC%|M)GgvQ8&t6lle82QIbi!Mcu~+;=6RRcM^yaSa@t&bW@Zb??b!8 zw5CR;Rf$*$m9jTWf}H@?~wY-e$7e20jJbG#X-6mdS{@{*g6%EDBt89Fv?dQ*9a z9nQrK{&>#a_7YZwrJ${{44 z9hYviFAW$Mc`6ulpda5%A z=q1_2ikQoxdSK3ivi>3R)qPQ(E#l6S^=>K^%NE0`lU6ks&L(Anfhyu>6v^6s%@ zHN%nJkjKpa;ssL(C>9OZt>%SvCv;M5tQl)~dOXNtVddZnTDxKtyX6E{pv&oCO;ukf z=ZRV*H)-d?4 zE<474DH@b82HSHM+XNyzx*le$?b@|yxl|~14H%R=dxf;tZG(ik&Vv+gEzTMU-4Rg( zWgh9+K>5*(m?Fnal0UgKf6R>Tet>6KyDubUZW`)!(Lw$y6WF$w7W&q8VN z;D^~*wt@wtExH2*(#QwTYfx~*~bOb?opJtk7jhBllc`67PLq1Q1tT{L^u&M}9Kx8{3E zKZ`odZ0(;dl-r~_Q_sXv{pB^4uKjTN^d8e-J(OEbxmlFp`?{YNKRklOs)m)Q{%-=j zqnHCLlnhJ`-=Q|LFgoQyqB8vA#HalcvDtRo?GEtYcu^yLn*Xc16s-Qj(Ptd>I8oaT z)&WZCk$$gZm=nmvn=x2JZwCO?X=I7QAI(o7ZhZ>{`z&UV)W@}f`hAVbiz z%F}=?)JYxmMP;(|r!p%Y7{0tsZg|Iloc#-xnJ^UtaqG298onrvs<`uLHVC;l`U%lR zPUCWN+1c>{vGvw$lskR~37~nTq7dPP3h$;qcfR5A?4WAe!A)k?#JJ$V`Nj88$9qN} zOeyv(Np4v#yJuiq%l$BbfE}Xr!oGl~D}#M&b-!Y-UjbDUTOV|}EbF3Cv{g+ACUAL+ zziWcn(Y9M0;oLqY+v_r-HmpqPUQy29{!^!(=92|5kTtP5>P}XCvx%5hZ#W<{ym)?~ z()8Ka>B30yG(6B!5j;x}`;B57$B5m{Re=)lJ)UD_?fZ#3CXzhbeQR#UAqo)GOgTHgg<>xUG=VA zKh|4me}r*{fHOZQojBRqCHnl8rs2e8Xo4amrR!hM^>%V1IIQ4ibDg-{mNfd!S*v|^ z<4DJdp}so=329`M%@2rs4W;7o9%B@1w2n=_^lP}Bd@_C52sR$cLLd38Gib87MPROx zL0RX?HBmnqgNn0lnU?k4KVSLlhU}R=23fI%PF0?oSy;52L%neP1#|Y1DhqA{+ zBp~T|zyX(F(*!sLHE1hC*vQ(yr7usRyh!*?VFMp}jh&`#tDVyU^c-ho7xH|)>eL=_ zDNa}I)zjZ(LpW`6=C*3oquyn82odG5;uFs|M3o7X zB34MB^V0oE);bBPAJ2C!URci@0NXIylfBkO#F#9QhBF$*wzK%)!{(IVui-if)3qOB z+NS-{)FsQ@ibMMbA_G_76IQ+*FWdRwOA?Ic9Iwc+@(aTZy8%u_W$qpfRVPCi1~Hx~ zG59?h!oDXwpx2m*hx@R4NtWa_Ihz~`$hhM%>-^4kd;)q~oG3Vr)bFZtN z{lJnvL{5S;F%JEkMNmKZi~l9!MRsq<*zu^xmE-T@-h*VzHqVWL*QMgTvKw3YY@^g5 zq+-clJn2B+T78u6AG}IN^~XqO>#RDq0JqMqAl)r!^E=zfy%vgSZz!jm8Ot3q_5ta8 zD%0_MLvD_A?nTPDYxDyo;$3+}oiomtun7&hy6YZ-XLJ>*UT4nvq`N1%11`1})aJuz zillTU>8RMe(_-3>2>Hb38;@K355#ouW>ef4e`6eJC{7W#DcvPgVLJpK>1MHU<(+zl zdjlVHnSBXg<|9Y-=5#jLob^m@anOvCvj>}@X>;QH-c_>MwP+Kay0$d(N1+@^vp|As z8yHs-ulq|>g7`uIs2!s)FGWb6y0BH-GbuWH1oj=o9Vp|Z zYpdkNode!TQ}0sN5h4$v(20Q>pv51bkIis5#auMqwuiu@pahn!R7!Pq5vq zIp{%&^hBC;wn#cu6-6*veAXj7wa!#UU*&AkZ=8ck*uiE?%X;6^k z7$&#if#-Srt5LdqG%fC_G09vSx2e#8Sc~gcmWG}bh+&2IKSSH$K9YXWgvB5mslf(j z<~4ut1bW+`yXtD{^{mu)sZm;0dYJDo9xC|1@sT2HEM)%8HoTSTqThF^g;;UdAsDfv z_!2^+3}g>In~Pm055yk6l&+Y6NR{_dA!m*yqR70Smd~R@s`w9U<+lm;_L%F)1eXv= zUlO+bI{6bzfwjYDk*a&X#XT?Dsg>9RdlvtLInNaAd6$qa*f2_Tib8@MWrM7W@cH_U zCoOtGQ!SJmewp=Ms-wfGTh`75PGn4?&hv`7P_&Lt`SwKr~0SkX}UWihy&bApI)O%Cz)V?pCdzu%dzuh$G&VvP zhAU^3Jc>u{!9SRXRq5PhZXS!gOh4>)w&s-#2kP^Ic<3_Wk@F_3fxlcR+s1PI;`f$- zbrG(SeKLb5Ks6?!z<~1|uib<8j*lx;B-s}S0Zw2X=m)#i9}dB2C0Gej7|BfYnZ@8X zArfM|0gR>%~kyvtt&GmO>i*iGwD7sx#|gbhtVy} z-KcMUCD`6)WJREAQp&KYv44FZAT>-jPLn{}o7~c@AcW%E9e~kbl+Tj1M}sfj^%;h= zU2?w%N%KBZiUvykiDP(ZQ=_|5BUaVvp+&xJ#v_HDF%rt(kWZH`pv2yDBWP+H}X+o{~l<= z@nqMOae7_^Lg*3_=7+E;UXwE2M7Q*^z@kJ}@=&vME1Xj}Az7dVM?Bz019Z~_n|l;F z3#;YV-Vo!kY!4NRTghA|FU}(0848gF@AXq4AH!2P5l4=g)T}3{DzZtrj>-0m6CpVh z?WwC+EP}dLsunDK!vy*uKC~s`Q>H=PefJk6SXlrlq;1ug_Ld`ag?#>y%`CE4O8Ec^ zDSE{V$W+(puc9EpO>_AC0t&OQS-jE>=dsdW;3ED|EiARGEQi++4L>di+n77(ZKx<# zcYE>4t4B*Pka1|F=0VCC-YNFp+eSieJ3)~=1Z!gsRCgxU`l(7rxA!BG*CiD}a&{z@ zQ^S-FunUt&OGu1UpQ+b9LeqK?gSv)#?LGvG4B)}{|7&&{S<(u!S_nFK@l@*$IbJW{ zYDwgor%q`tBMSuoj)wRq)peU3S-X+Hh}78-zY+s@hHvVz!o^B>z)fZP}9ZZlHdr z{kpKy_=NuU69zN5_xxq=y_C}~A}!ftdkgWj78PWR!+iH^5f~1IaX&EaM{4;K-O{(B zufX+W*h;pN5RAtoN`B}1RC%Iv-za%fGf!0FyS5)~Jj>geLa9OqD_V2iI`gYS&N6o9 zg2>D@4@6S*M)EeQ7eJQH29(RwO;!o(yG84|oOUgz(1J--P*osAb#z$mKeH=Izomc6 zNKu(2K_dIe@s9uK1(yX(n3LE5j8Hp*LWEigFq*9~rH?Fy?<4RoP+X78!v18*ayuoCWZJi$tvpVgezD;F}Mr1gV`kFk(A?Q{_h@=8VyeF;?wu6b9E z^XV}pRUOMRtEHCS8ofV*zvMt7lulU(fYZv3a(p!lq>fbQ9Yg}EirTy@{ryklppMDy zcDGQAhd0!$m{wedY5R!APrx22>Yy6}SQg$@-hxcj_Iu2h9ZAWE!QJ{01BwWTSyBmE zade0z{~ZTK6;FWlB&kp$vg}uw!BCpP9QU=v324tb^R)K4vOnp~;i1Aa9E~?iBS;cP z&z%1?J6=9H*Xh?!N1nZ`2Jfz2UOwUwM6YYA&2hfPmb@7h(Ybw8=|>IL@5y)a1#%6& zIz_J-)UZ+L=Z+dWB9P`u%1k?RlFW;=AmJj9Uv5+((m&78-u?tVSj)@Ro9Lnc&F&&>>=ffh0_*U`TWJM`=ys9ccZ=NiPaAUBn8rE`<`}^gK~x%Xpo)1d z&ckYafnD`BkIYEio+|C!f7@>Kb(m;J->6;wOjm~+JVmTg%$`Z9e|pngWDOz3&$qSH z1f9Sf-PO(TeuzQF{CLK8s|378=N)eJP;hCSN1nar$}>d}uvi@+V)dmv;3#;@^p@>J zP?IlaDX}E~>ryxMQJUj#U)Vr}N_lkk2XJ{(*%2JxaNTprS?YJKs)z(5>*_0G{3>Ce zhAn|%%CqDu1oyl)A&AWk0s?#ZRrRON_-35JpG z8s8x-07XE$zl{`DxUuC_rgkFn0BQtT=^}mSVhfb9>P|kGZjq=%dss%M3K+O)Qq@zK z9JF5xxhOnFVE4y{j!o%+^2nyUhr5RJ

uX4~nuS|xNgF3;l=mIVA_~)%Go4J(K!N9!D^`T0i;vd*b ztj1tu5=!V9?x5h7GJ}*G~r_DpK+w(TGJ5A z3+@aBgmvBYgK-J&OMPJjpy=rU;WS zYufwr;oSkLQDyo^$vh>;X!PL6WGU&vqeTM4A?4n|wR|TfhvIufGWQ98cCO{R7uqrhdEiqP{w2WPtxi)t(;~IF@0iDT6HFvD#u&nZm zkL_Ptm);F?QgJj)Wfl%NrfXCg9DB%i(nyDqn}j(MzWk2$E7&6+T}PC9XzDB{&L+hP zW32~a{Reuc7$A^f?p(xr(a+Ov5+V+vR0<_~XTFU4M4nuGq*d#YDhZR85|}{2t*UD_ z5q+ZJFIR(>{zxo)nBGd4KAag`jhK1xu`{#oByE=q$%5_vYy-tv3)DB`w2Uf3R21z$ z)Qjgtj36U+an9o3SQ}l0a6aqFMwHBtT2Kz;0tV3XmX2OE9)Dq;$HUWgOGkBGHK^oi za@yQ~?!PU&`h@w`s2}wCt1o+MRM3VrZ2T(_g#Z85;04ZVSHG!&|7-WFWi;-* zraW0|zg=M7uyE<0j1i~hr1H@GWP4q0_iuAA=MB3vGEp8JoFPbJ6t+YNS0s70dr`rr zyE+%TT_msRrmg{FYt5!V+9B!w`7BN>Edh`S40t0Sksr89n#4zbNJalsiCn8t8}i#; zi?6vkxE3(v_&$+;?T^=yk{a$!xk8CB`%J1fNoCt5p_0J4611zeS{2EZg#xTv)yP73 z$#%7j;p@Wg%Q=V9N}rZ%;7pC*#pRe*QRzGr+=ZmOYov}i{O(P5iK;#_`D}8v{0<(> z&tApEaFBw{0yl5%_irGp87p=|Ps^6szq)JTw)0!>GL>h_gq4twft{$^0R;frjl9o$ zxt>Jn%rm-7&$22c9}$C-b| zRHtQws$nsy(nH(3z%B8&eCEaOGB&>($O|EdGtqiiSvy^P)m$lcNI`alRBDIr^la}0 z|NC;{ageXUH3Zil=)=Z#EFk$+JuM$^L4tCe)6yeGOJP!OKKJZ)!XTmIqeb>@AW=Hr z)|eZYch1hb=D*}BdemRH-LxOX@V z*fCOP0%}aSsMU$so$-t&I$_(yjHl6Nh*(J6E~J)Cnz5Xn3H6zslgfuF_P!P5PZr?y zixQp=QmJZd*Hk9<&vRihPCdxINy*V&p{t>Q!$))V!ukmY2B>cYEEbv%f6x1RIu7jOldA&+8QUV(9E zxlmnQU0OtvRC0x*foAkh623#99ipNrb8j0)J~yagDonIwn4YDvvnl*H)>z)iTo#L$ zBT^nKjEgIsoA7T+IZ$k&-l28^8F?Gc;4LpLQLZX_#U%w4BlychT0dmXMBEmSl`kDx zN`{=VK!z#{yHvRk(gNgep|$yoNe!Qw5P}6~1Chw1Z@f}c+J#_nARoWxcYywT!{}?6 z5+W|sBe?*SR2X%JsX6f)KX3@vG8JQG~g0er~){ud6F&G>dr3?k{! z3K2p?PID<_qebArc4IoKRfxC>xUyS+TkM-%e^A{Kh78MMZSi};C{J;+PGUiK(Yt#* zi-sy%;kn~^iE3_3M>5yPgCQnU-2RTX$SjGF-{}-Ttp)W4;458k&vpic`3vptTH_@r zl!Tlnq{)@NJybu%jreOGP9;kJC9BKA6x?w6TIWFdk`LXtrqoMX==?yh6O=u>TJz|_$sHi!p179*8bu^n{*Xz=WGRbEv-mdbXv3bOl+mdnJ z5bge~hfI1>F!y6jRRQu9?Id}VZJaPvSdrg_5Rkoq41ecWK{qTikE8{MSHS>fc!=9{ z*u4z+T;!T542JK5F?FMqx`P#pFYY^9W>GVpWOle?rM$d6mvy`=Pd_r&KW9VoDEy9a z!spiw4N5J;VO_5W0u*o*JW6tEYmdSM7zgtDf~w!eAdB7#+x8K1 zJ-*G8r`c#fW^I%8|CZHORbjmW;2TyVndJ)7P1>dKd zk(Vw3sde&oLIO;m}!8FY&qzG!FbRTtfbu;@BoH5s{CrwWRkG{7RBccZS{8k!~O)V=7m0#isU6f zzS{svga*5TUj{4761h{6I3^#t1GoYo|1>XdVci23?!VzEFRqy~=LozJ2OgRL-v0A) zy;V|%b`nzTB_zFKA45Hm@sj;3F?8282+bpdwb{wJ5z&wIDN1uxi&kp4@Gq|QTy38E z2F;;G|NeJ8t1kV(tCBd@JDCN_7ry@xXkV0f|DybiS|3nZ*AbLuu|2VD!B@lbC%3lUYY&8m2rmYs8E zVf@KY_@NqatPapGHgm+dBClKn$O0~@qW*Yz7rL;xatATu)ZqDq44ER50c)tom1BYx z^Vx3bP@w^a_Q$50V!-j2Spj7%ar)b~?qdydy~GI&-iBR*huPHJVoDS{J!V^hnQ$!W z!LbV{DE<{$D$4H>4|U!u-3+eS?p+Lsq&#Ti!~c<%cjA7#5;*B;3mK6e=&hX!jrh;{ zT%d2G0fN{AWI`ArgFl=AH4?4fQstjl{9@^G~?Q7Cb zm2tru0jnc0w%SRNgVNmk*+I)05L2jAFk+o1)k#H6x&Ts|fCJb^7x_0?3RDzowfaJe z0G_qJ*=DLKo~|@@TLwh(VCXWYn;`1JVSJRhf6+^jg9t#^%$>7>RPAtJNiknS5?`1#YPd*V^K~~s*1kRBI{=hHL2;0=eAXdA z%7}q!__T-#l^|B~Ln~wTg1fauL@)}X4es5d((LVtt;tl68ktKqtUF^Won|j*6QUYP z@PR96n3uAmxQ;@jXu9e^`l%!f2PmS%|IBTvODZ<@KQld?$AGW5r13jQ<&6ugVuxj7 zC-OnEkTQ#(*aq81=f2JY$Bp0G$BXs-&D6Y~_g->1Gog3poeGPpXrzcHkieret7OoaF9z~e!JKU{m6WCNv82sdPv<6u46 zE0$}6SCKtAGYCQNcP0>Ug3vN7BVeurRW}{ky|iyWC^*pKRfWTh{fgiAMF0qD8~uai z35F(k~h5oa)-&ze1-VMBsDK6M5g4Z}J%!oA2sb0RmVRii*k~fBb z!LKiXFsPes8d2Y7?%rlkDa{`#M4TlAR7FnpRwChwhttqrgDww}Um4Ze3`=|;mpbD45`cye!6jXO3R2@<=htbJT+run4 zz!de(MDyj(^I3PI?i__$*Hh!pF``O=dIMG-)TA9g*<}{Hnf$4^%wX!V2~T%GO;SR+ z$lzHyjg_vp?af~U6QT}r5az4bNZ}-iCMx+d$X)nvUjXcWk|lR?YYJ@JpFi$#EDmA@ zW&=9Z*sveMvg_43{j4f}8N*)hKorFNZYB27KGJ7e#;Q9YFYJ$=u|?sns;8c;C7}WJ z(KDV&_3exA%^utwww{Oxde;n0STpBdGwbM%mY=A0`4EMj*uvp+Vr|t2SlK^$kOlh+ z`a*WhBC>{V#cmGz?>xbt)!YtJB=G^ageF}0bRM3%{_8Rxlo|N8Oc3$xPRT~Q`4*U} z04@|0)^_XHuD`1cQe&>A896qXkDANp6HKrnw=Y(aeuOO-WN$PRduW|Tl`-TVlqdd+ zblHN9>SVr<@Q^`rrV@axxLCn9YdHl2(ukl+D8cG(l><4vYU>l%fp zjO=Agp0ta4uCXCN^Fqt;y!Hl^^UthC5Y{}Rlrh~bu*!gSX~e6uCm11GUKru?nE!2X zvUMe33zE47KMfpb;(B<>7PIg0Q}$VU{7SHm)vXN{lM*&lMd8*thg0%$=#h_42CW*! zsYtw}hQ%RKr7(H}OMPhaHhbe-IwJZIS4J38|F;ZrY?h3 z!9{G0XW;oPqOP0}*3N*Er7F50tKJoij(K?^R=^iYdCQ9URj0WZCrXtZDtazFIf{#Q zk2a?NBdmiv@cSZ{N3lsE|5oZO=-4E)Bq&Q0Mq|1LRV(_o{7xH@fJjqZ_S8t1dfH~n zxjaFx8Y#5@R*=d@QD2t5g@~iLm(hVyVQr7^th&Etg&spzfM82~&VfEDr}N)x-%1FV zs^D|t`671qKzkzgS@BEH@eNyQ{mdt=1?r=*d`QEe4c2Ng5Hf7=61GDQL}-m_+pW_; zaYpJu+5P}>4lJsGOxk99ewP%e@fMrMbGzY#u&{7#hSd+E%$rT_$Z4)Y?pO^u~=QgTSQOcg@xNTHla@| z!1&NGW^uh_^aq=W)A$m&)@owgW45VEb@E_LQt(7gCL<7SpjB!nv`4@nuP1sI2$VVV zgPnYQ^yCa?Y~!hvI<9VXeB5%^+~|WL4}vH4!;SKIr4GsQc>{k^o*L_P?LE5 z@b*LtLigw6&j*e{op`K|(?yCFz2Df)#I+RD|LBC^+$fXNWH7ssPG$qhYb4Ts1^Mg~ z0Op~xtVVka=wX(XB*8|3o0XS=i5j2RxAv3%*|PyrJPx?~e-Lw{#X?<1LOk>gLagyq zdbqwE2T@>_e#mFp1Re3-(d8}W-Kh}{1<}nm?Ccp@*|5Z8%?aiZ`kr__>fqJeC{@pT z#2EJA3#Ect2xPSzDX=BXW_c7T@S&Uv9{33pR6AOvL9QWu)=vYnzKn@dbyEx-?$;uE zp6tsCd3R_?#lhUd17cOz{L`WIL|5f&_RuRb+7Ys3@IWNckd6SDmq?D#Oy#YRNp62& zIok%4LNi?oT|9!S{g1f$^1;*Htw|x~iTRUy`1$N<7o8gXh^{)mcWLM>F?8#brLC2l zb0c&^1*g68<`X;?DjU}5f7il{#wpAob#}`K_skUz5zy|E*BgBSf_UK(RFGc>FRBc;h^-En%i-5-?+_?L| zJzIa#=!-ru6`N`u_5W13$85HTb9$4#pT#FedWC%s3es{WvNkh9zMEXw@JQyYr^tqp z%yGbX(cTo+sIdF!cqWvxY>nX^V?`G>H)Q_Wh$Y8$PEq4zx^nZ@YK#NT886Xu3PNb^ zJ%qC(ADyJ-DZaB%m2_-ki1@AjwO-BNH zAjH{z9U0!~<z=|B zHPYC5?}CZ|JQaJztW9q0G>ItX{AO@)(?+mZD}iToGI{hayd~F8KpC#h8CN++ka$L5 z0^2;d312eqk*sX_KW3_*;57vWXc>GclAMdp5}GM-g?mt*ujh||F5Ss{Of=_iH@R&A zP|MD0cR+F24dFJrne0e%hv?K;#tj$3hjfhiOf$~ld0I>Kk-BREgak^-Tq)|9(zWFLqoj}rBJ&>OuORocA( z6dxY2cF`x5D9FLnzcwm|9(}89oCax);pYxGqdx3)5 zG-S^Hayi8l2xDw=wcFBKjsXeP>B%uOx&D|D{`E5V-pKkSQ2T4sT^Ypc!7Z-lJFHxn z;pI<#`o~Y6M6lU?p+C`DUcYjwBg3u+!^yyhiSf}7i9rv3K1Ssp@ctc^r#6J0N@VRa zT^UWdMaNenFvcv3b$`{>uad*4PIed*Yr7=?e#;Dp`nV~+^`C8!#}k5v%WSwmz_eF$ z%|4ewVWrc9)aZ1?q))1cNII%Q!T{^(jm(9eW`BKL*S@TOf^hP)Ya7ucL8J4{?yi7 zGMkVEv-t=PWq!rdHLnMh5T!jOt&Qiy6b zHHYKh09ry=hJyn{w?!|KV7*2+wL%5djr1skx+&4f1pKu5hKnv>xo|#j)<@S-6*i*8 z>9^Oq2POvlJxAXfu>x21*nTZ>1QCUEMG({$VyHdEHtpI%Dl$#@b4Nd1x#&Q zYPcZb#Gn~jblrIet>?wZo>?J&E^~b|78ef_7csMOpJ7AyyU-#^vXqM^@7CtMMIs^{ zel&hy(kGV=*7+ zz*H@EH44O>aySWG7ZUPG+Hi9|_%j{+iP6A_7OF@*+zW|5l5Oz75Ax9{@tZABa|LC4 z3dsHNJD_m_9r_Ddx%Ji&w#`DN2@|P7yuK6AsPNW2bCFHu-HR>IqnZXYfsT;EH6{5; z<@|5a?kY4;*qddI$#u&5^HcDolK=e&k*mY}+^MEvhi) z&mMruS0^2@NT2;vj1jRhkhIrvRgwvP!&wO-kj|paH{jw;v%BYZ4Xl)Os

hoDya> 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

_IOZ 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 literal 0 HcmV?d00001 diff --git a/src/test/resources/fips_java_test.security b/src/test/resources/fips_java_test.security new file mode 100644 index 0000000000..77bbea2fa9 --- /dev/null +++ b/src/test/resources/fips_java_test.security @@ -0,0 +1,58 @@ +# Security properties for FIPS test runs. +# Used with == (complete override): only the providers listed here are available. +# SunJCE is intentionally absent — this removes DES, MD5-based PBE, and other +# non-FIPS algorithms without requiring programmatic Security.removeProvider() calls. + +security.provider.1=org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider C:HYBRID;ENABLE{All}; +security.provider.2=org.bouncycastle.jsse.provider.BouncyCastleJsseProvider fips:BCFIPS +security.provider.3=SunPKCS11 +security.provider.4=SUN +security.provider.5=SunJGSS +security.provider.6=JdkLDAP + +login.configuration.provider=sun.security.provider.ConfigFile + +policy.expandProperties=true +policy.allowSystemProperty=true + +keystore.type=BCFKS +keystore.type.compat=true + +ssl.KeyManagerFactory.algorithm=PKIX +ssl.TrustManagerFactory.algorithm=PKIX + +jdk.certpath.disabledAlgorithms=MD2, MD5, SHA1 jdkCA & usage TLSServer, \ + RSA keySize < 1024, DSA keySize < 1024, EC keySize < 224, \ + SHA1 usage SignedJAR & denyAfter 2019-01-01 + +jdk.security.legacyAlgorithms=SHA1, \ + RSA keySize < 2048, DSA keySize < 2048, \ + DES, DESede, MD5, RC2, ARCFOUR + +jdk.jar.disabledAlgorithms=MD2, MD5, RSA keySize < 1024, \ + DSA keySize < 1024, SHA1 denyAfter 2019-01-01 + +jdk.tls.disabledAlgorithms=SSLv3, TLSv1, TLSv1.1, DTLSv1.0, RC4, DES, \ + MD5withRSA, DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL, \ + ECDH, TLS_RSA_* + +jdk.tls.legacyAlgorithms=NULL, anon, RC4, DES, 3DES_EDE_CBC + +jdk.tls.keyLimits=AES/GCM/NoPadding KeyUpdate 2^37, \ + ChaCha20-Poly1305 KeyUpdate 2^37 + +crypto.policy=unlimited + +jdk.security.caDistrustPolicies=SYMANTEC_TLS,ENTRUST_TLS,CAMERFIRMA_TLS + +jdk.tls.alpnCharset=ISO_8859_1 + +# Revocation via BCTLS TrustManager (covers all TLS including LDAPS) +com.sun.net.ssl.checkRevocation=true + +# BC FIPS CertPath revocation mechanisms +ocsp.enable=true +org.bouncycastle.x509.enableCRLDP=true + +# OCSP stapling — request stapled response from server +jdk.tls.client.enableStatusRequestExtension=true diff --git a/src/test/resources/java_test.security b/src/test/resources/java_test.security new file mode 100644 index 0000000000..82cf1ed27c --- /dev/null +++ b/src/test/resources/java_test.security @@ -0,0 +1,55 @@ +# Security properties for non-FIPS test runs. +# Used with == (complete override) so all desired providers must be listed explicitly. +# Standard JDK providers are preserved in their default order; BCFIPS is appended last +# so it is available for tests that rely on BC-specific features without displacing JDK providers. + +security.provider.1=SUN +security.provider.2=SunRsaSign +security.provider.3=SunEC +security.provider.4=SunJSSE +security.provider.5=SunJCE +security.provider.6=SunJGSS +security.provider.7=SunSASL +security.provider.8=XMLDSig +security.provider.9=SunPCSC +security.provider.10=JdkLDAP +security.provider.11=JdkSASL +security.provider.12=SunPKCS11 +security.provider.13=org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider C:HYBRID;ENABLE{All}; + +login.configuration.provider=sun.security.provider.ConfigFile + +policy.expandProperties=true +policy.allowSystemProperty=true + +keystore.type=pkcs12 +keystore.type.compat=true + +ssl.KeyManagerFactory.algorithm=SunX509 +ssl.TrustManagerFactory.algorithm=PKIX + +jdk.certpath.disabledAlgorithms=MD2, MD5, SHA1 jdkCA & usage TLSServer, \ + RSA keySize < 1024, DSA keySize < 1024, EC keySize < 224, \ + SHA1 usage SignedJAR & denyAfter 2019-01-01 + +jdk.security.legacyAlgorithms=SHA1, \ + RSA keySize < 2048, DSA keySize < 2048, \ + DES, DESede, MD5, RC2, ARCFOUR + +jdk.jar.disabledAlgorithms=MD2, MD5, RSA keySize < 1024, \ + DSA keySize < 1024, SHA1 denyAfter 2019-01-01 + +jdk.tls.disabledAlgorithms=SSLv3, TLSv1, TLSv1.1, DTLSv1.0, RC4, DES, \ + MD5withRSA, DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL, \ + ECDH, TLS_RSA_* + +jdk.tls.legacyAlgorithms=NULL, anon, RC4, DES, 3DES_EDE_CBC + +jdk.tls.keyLimits=AES/GCM/NoPadding KeyUpdate 2^37, \ + ChaCha20-Poly1305 KeyUpdate 2^37 + +crypto.policy=unlimited + +jdk.security.caDistrustPolicies=SYMANTEC_TLS,ENTRUST_TLS,CAMERFIRMA_TLS + +jdk.tls.alpnCharset=ISO_8859_1 From 0d45021cbbaaead28646dc594eab85cff82fbbb6 Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Thu, 26 Feb 2026 12:11:57 +0100 Subject: [PATCH 2/9] Ensure FIPS-compliance on cryptographic primitives, keystore handling, TLS, LDAP, JWT/token encryption, hashing, CLI admin tool Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad spotlessApply Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad fix broken tests after removing SunJCE Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad revisit LDAP's HostnameAwareConnectionFactory Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad add PKCS#11 store format Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad Adopt IT's to run on FIPS cluster Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad Refactor SNI TLS socket handling to improve hostname verification Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad Address PKCS#11 provider validation, error messaging, and sensitive data handling across SSL/LDAP/auth components Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad spotlessApply Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad Introduce support for keystore-based secret for JWT signing Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad Add BCFipsEntropyDaemonFilter to ThreadLeakFilters Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad remove hardcoded BCFIPS provider; minor cleanups Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad spotlessApply Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad # Conflicts: # src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java --- .../AbstractDefaultConfigurationTests.java | 23 +- .../security/ConfigurationFiles.java | 27 +- .../security/ResourceFocusedTests.java | 9 +- .../SecurityConfigurationBootstrapTests.java | 26 +- .../TlsHostnameVerificationTests.java | 7 +- .../org/opensearch/security/TlsTests.java | 9 +- .../UserBruteForceAttacksPreventionTests.java | 26 +- .../api/AbstractApiIntegrationTest.java | 15 +- .../api/AccountRestApiIntegrationTest.java | 29 ++- ...xpPasswordRulesRestApiIntegrationTest.java | 14 +- .../InternalUsersRestApiIntegrationTest.java | 47 ++-- ...edPasswordRulesRestApiIntegrationTest.java | 4 +- .../RollbackVersionApiIntegrationTest.java | 11 + .../api/ViewVersionApiIntegrationTest.java | 6 +- .../hash/Argon2DefaultConfigHashingTests.java | 17 +- .../hash/BCryptCustomConfigHashingTests.java | 12 +- .../hash/BCryptDefaultConfigHashingTests.java | 15 +- .../hash/PBKDF2CustomConfigHashingTests.java | 8 +- .../hash/PBKDF2DefaultConfigHashingTests.java | 5 +- .../security/http/BasicAuthTests.java | 4 +- .../http/BasicWithAnonymousAuthTests.java | 4 +- .../http/OnBehalfOfJwtAuthenticationTest.java | 4 +- .../security/http/RolesMappingTests.java | 7 +- .../UntrustedLdapServerCertificateTest.java | 7 +- .../security/privileges/ApiTokenTest.java | 7 +- .../ssl/util/SSLRequestHelperTests.java | 6 +- .../test/framework/TestSecurityConfig.java | 22 +- .../framework/certificate/AlgorithmKit.java | 16 +- .../certificate/CertificatesIssuer.java | 7 +- .../CertificatesIssuerFactory.java | 11 +- .../framework/certificate/PemConverter.java | 2 +- .../cluster/CloseableHttpClientFactory.java | 4 +- ...inimumSecuritySettingsSupplierFactory.java | 4 + .../cluster/OpenSearchClientProvider.java | 12 +- .../framework/cluster/ReactorHttpClient.java | 96 ++++++-- .../security/OpenSearchSecurityPlugin.java | 32 ++- .../auditlog/sink/ExternalOpenSearchSink.java | 2 +- .../auth/http/jwt/keybyoidc/JwtVerifier.java | 2 +- .../kerberos/HTTPSpnegoAuthenticator.java | 26 +- .../auth/http/kerberos/util/JaasKrbUtil.java | 30 ++- .../InternalAuthenticationBackend.java | 3 +- .../backend/LDAPAuthenticationBackend.java | 2 +- .../backend/LDAPAuthorizationBackend.java | 100 +++++++- .../ldap2/HostnameAwareConnectionFactory.java | 37 +++ .../ldap2/LDAPConnectionFactoryFactory.java | 81 +++++- .../ldap2/SNISettingTLSSocketFactory.java | 187 ++++++++++++++ .../jwt/EncryptionDecryptionUtil.java | 73 +++--- .../security/authtoken/jwt/JwtVendor.java | 11 +- .../hasher/AbstractPasswordHasher.java | 14 ++ .../security/hasher/PBKDF2PasswordHasher.java | 10 + .../security/hasher/PasswordHasher.java | 20 ++ .../security/ssl/DefaultSecurityKeyStore.java | 3 +- .../ssl/OpenSearchSecuritySSLPlugin.java | 14 -- .../ssl/config/KeyStoreConfiguration.java | 8 +- .../security/ssl/config/KeyStoreUtils.java | 16 +- .../security/ssl/config/SanParser.java | 5 +- .../ssl/config/SslCertificatesLoader.java | 18 +- .../ssl/config/TrustStoreConfiguration.java | 6 +- .../security/ssl/util/SSLConfigConstants.java | 15 +- .../security/ssl/util/SSLRequestHelper.java | 18 +- .../opensearch/security/support/FipsMode.java | 26 ++ .../security/support/PemKeyReader.java | 233 ++++++++++-------- .../security/tools/SecurityAdmin.java | 184 ++++++++++++-- .../SecuritySettingsConfigurer.java | 9 +- .../opensearch/security/user/UserService.java | 92 +++++-- .../opensearch/security/util/KeyUtils.java | 39 +++ .../util/SettingsBasedSSLConfigurator.java | 15 +- .../util/SettingsBasedSSLConfiguratorV4.java | 10 +- ...earchSecurityPluginFIPSValidationTest.java | 33 ++- .../security/UserServiceUnitTests.java | 90 ++++++- .../org/opensearch/security/UtilTests.java | 49 ++++ .../auth/InternalAuthBackendTests.java | 76 ++++-- .../http/saml/HTTPSamlAuthenticatorTest.java | 7 + .../security/auth/ldap/LdapBackendTest.java | 9 +- .../ldap/LdapBackendTestNewStyleConfig.java | 10 +- .../security/auth/ldap/srv/LdapServer.java | 11 +- .../HostnameAwareConnectionFactoryTest.java | 43 ++++ .../ldap2/LdapBackendTestNewStyleConfig2.java | 19 +- .../ldap2/LdapBackendTestOldStyleConfig2.java | 18 +- .../ldap2/LdapMtlsSniAuthenticationTest.java | 77 ++++++ .../ldap2/SNISettingTLSSocketFactoryTest.java | 217 ++++++++++++++++ .../security/authtoken/jwt/JwtVendorTest.java | 78 +++++- .../hasher/AbstractPasswordHasherTests.java | 19 +- .../hasher/BCryptPasswordHasherTests.java | 4 +- .../hasher/PBKDF2PasswordHasherTests.java | 11 + .../ssl/CertificateValidatorTest.java | 18 +- .../security/ssl/CertificatesRule.java | 76 +++++- .../security/ssl/CertificatesUtils.java | 6 +- .../org/opensearch/security/ssl/SSLTest.java | 91 +++---- .../SslSettingsManagerReloadListenerTest.java | 34 ++- .../security/ssl/SslSettingsManagerTest.java | 3 + .../config/JdkSslCertificatesLoaderTest.java | 7 +- .../config/PemSslCertificatesLoaderTest.java | 5 +- .../security/ssl/config/SanParserTest.java | 8 +- .../security/support/FipsModeTest.java | 55 +++++ .../PemKeyReaderDetectStoreTypeTest.java | 15 +- .../support/PemKeyReaderLoadKeyStoreTest.java | 68 +++++ .../PemKeyReaderLoadSecretKeyTest.java | 167 +++++++++++++ .../test/AbstractSecurityUnitTest.java | 9 +- .../test/helper/cluster/ClusterHelper.java | 12 +- .../security/test/helper/file/FileHelper.java | 14 +- .../security/test/helper/rest/RestHelper.java | 13 +- .../security/tools/HasherTests.java | 79 +++--- .../democonfig/CertificateGeneratorTests.java | 3 +- .../SecuritySettingsConfigurerTests.java | 22 +- .../util/KeyUtilsLoadKeyFromKeystoreTest.java | 79 ++++++ .../SettingsBasedSSLConfiguratorV4Test.java | 12 +- .../configuration_wrong_endpoint_names.yml | 1 - .../endpoints/routing/configuration_valid.yml | 2 - .../configuration_wrong_endpoint_names.yml | 1 - .../configuration_wrong_endpoint_types.yml | 1 - src/test/resources/ldap/config.yml | 1 - src/test/resources/ldap/config_ldap2.yml | 1 - src/test/resources/ldap/test1.yml | 1 - src/test/resources/ssl/pem/node-4.crt.pem | 50 ---- src/test/resources/ssl/pem/node-4.key | 29 --- src/test/resources/ssl/pem/node-4.p12 | Bin 3821 -> 0 bytes .../resources/sslConfigurator/pem/kirk.key | 55 +++-- .../sslConfigurator/pem/wrong-kirk.key | 55 +++-- tools/securityadmin.bat | 25 +- tools/securityadmin.sh | 35 +-- 121 files changed, 2791 insertions(+), 897 deletions(-) create mode 100644 src/main/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactory.java create mode 100644 src/main/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactory.java create mode 100644 src/main/java/org/opensearch/security/support/FipsMode.java create mode 100644 src/test/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactoryTest.java create mode 100644 src/test/java/org/opensearch/security/auth/ldap2/LdapMtlsSniAuthenticationTest.java create mode 100644 src/test/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactoryTest.java create mode 100644 src/test/java/org/opensearch/security/support/FipsModeTest.java create mode 100644 src/test/java/org/opensearch/security/support/PemKeyReaderLoadKeyStoreTest.java create mode 100644 src/test/java/org/opensearch/security/support/PemKeyReaderLoadSecretKeyTest.java create mode 100644 src/test/java/org/opensearch/security/util/KeyUtilsLoadKeyFromKeystoreTest.java delete mode 100644 src/test/resources/ssl/pem/node-4.crt.pem delete mode 100644 src/test/resources/ssl/pem/node-4.key delete mode 100644 src/test/resources/ssl/pem/node-4.p12 diff --git a/src/integrationTest/java/org/opensearch/security/AbstractDefaultConfigurationTests.java b/src/integrationTest/java/org/opensearch/security/AbstractDefaultConfigurationTests.java index ab12797e61..b95ed0d88f 100644 --- a/src/integrationTest/java/org/opensearch/security/AbstractDefaultConfigurationTests.java +++ b/src/integrationTest/java/org/opensearch/security/AbstractDefaultConfigurationTests.java @@ -9,6 +9,7 @@ */ package org.opensearch.security; +import java.time.Duration; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -44,10 +45,15 @@ protected AbstractDefaultConfigurationTests(LocalCluster cluster) { this.cluster = cluster; } + private static final Duration AWAIT_TIMEOUT = Duration.ofSeconds(30); + @Test public void shouldLoadDefaultConfiguration() { try (TestRestClient client = cluster.getRestClient(NEW_USER)) { - Awaitility.await().alias("Load default configuration").until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); + Awaitility.await() + .alias("Load default configuration") + .timeout(AWAIT_TIMEOUT) + .until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); } try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) { client.confirmCorrectCredentials(ADMIN_USER.getName()); @@ -103,7 +109,10 @@ private void prepareRolesTestCase() { public void securityUpgrade() throws Exception { try (var client = cluster.getRestClient(ADMIN_USER)) { // Setup: Make sure the config is ready before starting modifications - Awaitility.await().alias("Load default configuration").until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); + Awaitility.await() + .alias("Load default configuration") + .timeout(AWAIT_TIMEOUT) + .until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); // Setup: Collect default roles after cluster start final var expectedRoles = client.get("_plugins/_security/api/roles/"); final var expectedRoleNames = extractFieldNames(expectedRoles.getBodyAs(JsonNode.class)); @@ -143,7 +152,10 @@ public void securityUpgrade() throws Exception { public void securityRolesUpgrade() throws Exception { try (var client = cluster.getRestClient(ADMIN_USER)) { // Setup: Make sure the config is ready before starting modifications - Awaitility.await().alias("Load default configuration").until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); + Awaitility.await() + .alias("Load default configuration") + .timeout(AWAIT_TIMEOUT) + .until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); // Setup: Collect default roles after cluster start final var expectedRoles = client.get("_plugins/_security/api/roles/"); @@ -185,7 +197,10 @@ public void securityRolesUpgrade() throws Exception { public void securityRolesUpgradeSpecifyingRoles() throws Exception { try (var client = cluster.getRestClient(ADMIN_USER)) { // Setup: Make sure the config is ready before starting modifications - Awaitility.await().alias("Load default configuration").until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); + Awaitility.await() + .alias("Load default configuration") + .timeout(AWAIT_TIMEOUT) + .until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); // Setup: Collect default roles after cluster start final var expectedRoles = client.get("_plugins/_security/api/roles/"); diff --git a/src/integrationTest/java/org/opensearch/security/ConfigurationFiles.java b/src/integrationTest/java/org/opensearch/security/ConfigurationFiles.java index 4b0ef62b49..4cc9bbc365 100644 --- a/src/integrationTest/java/org/opensearch/security/ConfigurationFiles.java +++ b/src/integrationTest/java/org/opensearch/security/ConfigurationFiles.java @@ -11,11 +11,13 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Objects; import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.test.framework.TestSecurityConfig; public class ConfigurationFiles { @@ -25,7 +27,6 @@ public static Path createConfigurationDirectory() { String[] configurationFiles = { CType.ACTIONGROUPS.configFileName(), CType.CONFIG.configFileName(), - CType.INTERNALUSERS.configFileName(), CType.NODESDN.configFileName(), CType.ROLES.configFileName(), CType.ROLESMAPPING.configFileName(), @@ -34,12 +35,36 @@ public static Path createConfigurationDirectory() { for (String fileName : configurationFiles) { copyResourceToFile(fileName, tempDirectory.resolve(fileName)); } + writeInternalUsersFile(tempDirectory.resolve(CType.INTERNALUSERS.configFileName())); return tempDirectory.toAbsolutePath(); } catch (IOException ex) { throw new RuntimeException("Cannot create directory with security plugin configuration.", ex); } } + // Generates internal_users.yml with a hash derived from DEFAULT_TEST_PASSWORD so the + // admin user can authenticate in both FIPS and non-FIPS mode without a hardcoded BCrypt hash. + private static void writeInternalUsersFile(Path destination) throws IOException { + String hash = TestSecurityConfig.hashPassword(TestSecurityConfig.DEFAULT_TEST_PASSWORD); + String content = """ + --- + _meta: + type: "internalusers" + config_version: 2 + new-user: + hash: "%s" + limited-user: + hash: "%s" + opendistro_security_roles: + - "user_limited-user__limited-role" + admin: + hash: "%s" + opendistro_security_roles: + - "user_admin__all_access" + """.formatted(hash, hash, hash); + Files.writeString(destination, content, StandardCharsets.UTF_8); + } + public static void copyResourceToFile(String resource, Path destination) { try (InputStream input = ConfigurationFiles.class.getClassLoader().getResourceAsStream(resource)) { Objects.requireNonNull(input, "Cannot find source resource " + resource); diff --git a/src/integrationTest/java/org/opensearch/security/ResourceFocusedTests.java b/src/integrationTest/java/org/opensearch/security/ResourceFocusedTests.java index c7e54f8791..f9b4b6fd83 100644 --- a/src/integrationTest/java/org/opensearch/security/ResourceFocusedTests.java +++ b/src/integrationTest/java/org/opensearch/security/ResourceFocusedTests.java @@ -48,7 +48,6 @@ import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; -import reactor.netty.http.HttpProtocol; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -158,13 +157,7 @@ private void runResourceTestWithGenericClient( final int totalNumberOfRequests ) { final byte[] compressedRequestBody = createCompressedRequestBody(size); - try ( - final ReactorHttpClient client = cluster.getGenericClient( - HttpProtocol.HTTP3, - true, - Settings.builder().loadFromMap(NODE_SETTINGS).build() - ) - ) { + try (final ReactorHttpClient client = cluster.getGenericClient(Settings.builder().loadFromMap(NODE_SETTINGS).build())) { List> requestUris = new ArrayList<>(); for (int i = 0; i < totalNumberOfRequests; i++) { requestUris.add(Tuple.tuple(requestPath, compressedRequestBody)); diff --git a/src/integrationTest/java/org/opensearch/security/SecurityConfigurationBootstrapTests.java b/src/integrationTest/java/org/opensearch/security/SecurityConfigurationBootstrapTests.java index dec52950ec..27f17b207f 100644 --- a/src/integrationTest/java/org/opensearch/security/SecurityConfigurationBootstrapTests.java +++ b/src/integrationTest/java/org/opensearch/security/SecurityConfigurationBootstrapTests.java @@ -139,17 +139,21 @@ public void shouldStillLoadSecurityConfigDuringBootstrapAndActiveConfigUpdateReq } }); - Awaitility.await().alias("Load default configuration").pollInterval(Duration.ofMillis(100)).until(() -> { - // After the configuration has been loaded, the rest clients should be able to connect successfully - cluster.triggerConfigurationReloadForCTypes( - internalNodeClient, - List.of(CType.ACTIONGROUPS, CType.CONFIG, CType.ROLES, CType.ROLESMAPPING, CType.TENANTS), - true - ); - try (final TestRestClient freshClient = cluster.getRestClient(USER_ADMIN)) { - return client.getAuthInfo().getStatusCode(); - } - }, equalTo(200)); + Awaitility.await() + .alias("Load default configuration") + .pollInterval(Duration.ofMillis(100)) + .timeout(Duration.ofSeconds(30)) + .until(() -> { + // After the configuration has been loaded, the rest clients should be able to connect successfully + cluster.triggerConfigurationReloadForCTypes( + internalNodeClient, + List.of(CType.ACTIONGROUPS, CType.CONFIG, CType.ROLES, CType.ROLESMAPPING, CType.TENANTS), + true + ); + try (final TestRestClient freshClient = cluster.getRestClient(USER_ADMIN)) { + return client.getAuthInfo().getStatusCode(); + } + }, equalTo(200)); } } } diff --git a/src/integrationTest/java/org/opensearch/security/TlsHostnameVerificationTests.java b/src/integrationTest/java/org/opensearch/security/TlsHostnameVerificationTests.java index 6b3768834f..4abeebf756 100644 --- a/src/integrationTest/java/org/opensearch/security/TlsHostnameVerificationTests.java +++ b/src/integrationTest/java/org/opensearch/security/TlsHostnameVerificationTests.java @@ -17,6 +17,7 @@ import org.junit.Rule; import org.junit.Test; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.certificate.TestCertificates; import org.opensearch.test.framework.cluster.ClusterManager; import org.opensearch.test.framework.cluster.LocalCluster; @@ -71,7 +72,11 @@ public void clusterShouldNotStart_nodesSanIpsAreInvalid() { ); clusterFuture.cancel(true); } catch (Exception e) { - logsRule.assertThatContain("No subject alternative names matching IP address 127.0.0.1 found"); + if (FipsMode.isEnabled()) { + logsRule.assertThatStackTraceContain("No subject alternative name found matching IP address 127.0.0.1"); + } else { + logsRule.assertThatContain("No subject alternative names matching IP address 127.0.0.1 found"); + } } } } diff --git a/src/integrationTest/java/org/opensearch/security/TlsTests.java b/src/integrationTest/java/org/opensearch/security/TlsTests.java index e2f5cd2e30..8c5f4cc0eb 100644 --- a/src/integrationTest/java/org/opensearch/security/TlsTests.java +++ b/src/integrationTest/java/org/opensearch/security/TlsTests.java @@ -24,6 +24,7 @@ import org.junit.Test; import org.opensearch.security.auditlog.impl.AuditCategory; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.AuditCompliance; import org.opensearch.test.framework.AuditConfiguration; import org.opensearch.test.framework.AuditFilters; @@ -47,7 +48,7 @@ public class TlsTests { private static final User USER_ADMIN = new User("admin").roles(ALL_ACCESS); - public static final String SUPPORTED_CIPHER_SUIT = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"; + public static final String SUPPORTED_CIPHER_SUIT = "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384"; public static final String NOT_SUPPORTED_CIPHER_SUITE = "TLS_RSA_WITH_AES_128_CBC_SHA"; public static final String AUTH_INFO_ENDPOINT = "/_opendistro/_security/authinfo?pretty"; @@ -95,7 +96,11 @@ public void shouldSupportClientCipherSuite_negative() throws IOException { try (CloseableHttpClient client = cluster.getClosableHttpClient(new String[] { NOT_SUPPORTED_CIPHER_SUITE })) { HttpGet httpGet = new HttpGet("https://localhost:" + cluster.getHttpPort() + AUTH_INFO_ENDPOINT); - assertThatThrownBy(() -> client.execute(httpGet), instanceOf(SSLHandshakeException.class)); + if (FipsMode.isEnabled()) { + assertThatThrownBy(() -> client.execute(httpGet), instanceOf(IllegalStateException.class)); + } else { + assertThatThrownBy(() -> client.execute(httpGet), instanceOf(SSLHandshakeException.class)); + } } } } diff --git a/src/integrationTest/java/org/opensearch/security/UserBruteForceAttacksPreventionTests.java b/src/integrationTest/java/org/opensearch/security/UserBruteForceAttacksPreventionTests.java index 9293f5ecc1..cb2bea42b2 100644 --- a/src/integrationTest/java/org/opensearch/security/UserBruteForceAttacksPreventionTests.java +++ b/src/integrationTest/java/org/opensearch/security/UserBruteForceAttacksPreventionTests.java @@ -11,6 +11,7 @@ import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; @@ -39,6 +40,7 @@ public class UserBruteForceAttacksPreventionTests { public static final int ALLOWED_TRIES = 3; public static final int TIME_WINDOW_SECONDS = 3; + public static final int LOCK_RELEASE_TIMEOUT_SECONDS = TIME_WINDOW_SECONDS * 3; private static final AuthFailureListeners listener = new AuthFailureListeners().addRateLimit( new RateLimiting("internal_authentication_backend_limiting").type("username") .authenticationBackend("intern") @@ -74,9 +76,8 @@ public void shouldAuthenticateUserWhenBlockadeIsNotActive() { public void shouldBlockUserWhenNumberOfFailureLoginAttemptIsEqualToLimit() { authenticateUserWithIncorrectPassword(USER_2, ALLOWED_TRIES); try (TestRestClient client = cluster.getRestClient(USER_2)) { - HttpResponse response = client.getAuthInfo(); - - response.assertStatusCode(SC_UNAUTHORIZED); + Awaitility.await("user " + USER_2.getName() + " blocked after " + ALLOWED_TRIES + " failed attempts") + .until(() -> client.getAuthInfo().getStatusCode() == SC_UNAUTHORIZED); } // Rejecting REST request because of blocked user: logsRule.assertThatContain("Rejecting request because of blocked user: " + USER_2.getName()); @@ -86,9 +87,8 @@ public void shouldBlockUserWhenNumberOfFailureLoginAttemptIsEqualToLimit() { public void shouldBlockUserWhenNumberOfFailureLoginAttemptIsGreaterThanLimit() { authenticateUserWithIncorrectPassword(USER_3, ALLOWED_TRIES * 2); try (TestRestClient client = cluster.getRestClient(USER_3)) { - HttpResponse response = client.getAuthInfo(); - - response.assertStatusCode(SC_UNAUTHORIZED); + Awaitility.await("user " + USER_3.getName() + " blocked after " + (ALLOWED_TRIES * 2) + " failed attempts") + .until(() -> client.getAuthInfo().getStatusCode() == SC_UNAUTHORIZED); } logsRule.assertThatContain("Rejecting request because of blocked user: " + USER_3.getName()); } @@ -104,16 +104,14 @@ public void shouldNotBlockUserWhenNumberOfLoginAttemptIsBelowLimit() { } @Test - public void shouldReleaseLock() throws InterruptedException { + public void shouldReleaseLock() { authenticateUserWithIncorrectPassword(USER_5, ALLOWED_TRIES); try (TestRestClient client = cluster.getRestClient(USER_5)) { - HttpResponse response = client.getAuthInfo(); - response.assertStatusCode(SC_UNAUTHORIZED); - TimeUnit.SECONDS.sleep(TIME_WINDOW_SECONDS); - - response = client.getAuthInfo(); - - response.assertStatusCode(SC_OK); + Awaitility.await("user " + USER_5.getName() + " initially blocked") + .until(() -> client.getAuthInfo().getStatusCode() == SC_UNAUTHORIZED); + Awaitility.await("block released for user " + USER_5.getName()) + .atMost(LOCK_RELEASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .until(() -> client.getAuthInfo().getStatusCode() == SC_OK); } logsRule.assertThatContain("Rejecting request because of blocked user: " + USER_5.getName()); } diff --git a/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java index 8978926370..195ffe3622 100644 --- a/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java @@ -25,6 +25,7 @@ import org.opensearch.security.hasher.PasswordHasher; import org.opensearch.security.hasher.PasswordHasherFactory; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.cluster.ClusterManager; import org.opensearch.test.framework.cluster.LocalCluster; @@ -87,12 +88,22 @@ private static String randomAsciiAlphanumOfLength(final int length) { */ public static final TestSecurityConfig.User NEW_USER = new TestSecurityConfig.User("new-user"); - public static final String DEFAULT_PASSWORD = "secret"; + public static final String DEFAULT_PASSWORD = TestSecurityConfig.DEFAULT_TEST_PASSWORD; + + /** + * BC FIPS requires PBKDF2 passwords to be at least 112 bits; with ASCII chars that is 14 bytes. + */ + public static final int FIPS_MIN_PASSWORD_LENGTH = 14; public static final ToXContentObject EMPTY_BODY = (builder, params) -> builder.startObject().endObject(); public static final PasswordHasher passwordHasher = PasswordHasherFactory.createPasswordHasher( - Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.BCRYPT).build() + Settings.builder() + .put( + ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, + FipsMode.isEnabled() ? ConfigConstants.PBKDF2 : ConfigConstants.BCRYPT + ) + .build() ); protected static LocalCluster.Builder clusterBuilder() { diff --git a/src/integrationTest/java/org/opensearch/security/api/AccountRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/AccountRestApiIntegrationTest.java index 8af5910d19..e316e98cc4 100644 --- a/src/integrationTest/java/org/opensearch/security/api/AccountRestApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/AccountRestApiIntegrationTest.java @@ -38,9 +38,9 @@ public class AccountRestApiIntegrationTest extends AbstractApiIntegrationTest { private final static String HIDDEN_USERS = "hidden-user"; - public final static String TEST_USER_PASSWORD = randomAlphabetic(10); + public final static String TEST_USER_PASSWORD = randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH); - public final static String TEST_USER_NEW_PASSWORD = randomAlphabetic(10); + public final static String TEST_USER_NEW_PASSWORD = randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH); @ClassRule public static LocalCluster localCluster = clusterBuilder().users( @@ -69,11 +69,11 @@ public void accountInfo() throws Exception { assertThat(response.getBody(), account.get("tenants").isObject()); assertThat(response.getBody(), account.get("roles").isArray()); } - try (TestRestClient client = localCluster.getRestClient(NEW_USER.getName(), "a")) { + try (TestRestClient client = localCluster.getRestClient(NEW_USER.getName(), randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH))) { HttpResponse response = client.get(accountPath()); assertThat(response, isUnauthorized()); } - try (TestRestClient client = localCluster.getRestClient("a", "b")) { + try (TestRestClient client = localCluster.getRestClient("a", randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH))) { HttpResponse response = client.get(accountPath()); assertThat(response, isUnauthorized()); } @@ -90,18 +90,27 @@ public void changeAccountPassword() throws Exception { HttpResponse response = client.get(accountPath()); assertThat(response, isOk()); assertThat(response.getBooleanFromJsonBody("/is_reserved"), is(true)); - assertThat(client.putJson(accountPath(), changePasswordPayload(DEFAULT_PASSWORD, randomAlphabetic(10))), isForbidden()); + assertThat( + client.putJson(accountPath(), changePasswordPayload(DEFAULT_PASSWORD, randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH))), + isForbidden() + ); } try (TestRestClient client = localCluster.getRestClient(HIDDEN_USERS, DEFAULT_PASSWORD)) { HttpResponse response = client.get(accountPath()); assertThat(response, isOk()); assertThat(response.getBooleanFromJsonBody("/is_hidden"), is(true)); - assertThat(client.putJson(accountPath(), changePasswordPayload(DEFAULT_PASSWORD, randomAlphabetic(10))), isNotFound()); + assertThat( + client.putJson(accountPath(), changePasswordPayload(DEFAULT_PASSWORD, randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH))), + isNotFound() + ); } try (TestRestClient client = localCluster.getAdminCertRestClient()) { HttpResponse response = client.get(accountPath()); assertThat(response, isOk()); - assertThat(client.putJson(accountPath(), changePasswordPayload(DEFAULT_PASSWORD, randomAlphabetic(10))), isNotFound()); + assertThat( + client.putJson(accountPath(), changePasswordPayload(DEFAULT_PASSWORD, randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH))), + isNotFound() + ); } } @@ -127,7 +136,7 @@ private void verifyWrongPayload(final TestRestClient client) throws Exception { } private void verifyPasswordCanBeChanged() throws Exception { - final var newPassword = randomAlphabetic(10); + final var newPassword = randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH); try (TestRestClient client = localCluster.getRestClient(TEST_USER, TEST_USER_PASSWORD)) { HttpResponse resp = client.putJson( accountPath(), @@ -144,8 +153,8 @@ private void verifyPasswordCanBeChanged() throws Exception { @Test public void testPutAccountRetainsAccountInformation() throws Exception { final var username = "test"; - final String password = randomAlphabetic(10); - final String newPassword = randomAlphabetic(10); + final String password = randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH); + final String newPassword = randomAlphabetic(FIPS_MIN_PASSWORD_LENGTH); try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { assertThat( client.putJson( diff --git a/src/integrationTest/java/org/opensearch/security/api/InternalUsersRegExpPasswordRulesRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/InternalUsersRegExpPasswordRulesRestApiIntegrationTest.java index 76dd413454..9bfad5aab4 100644 --- a/src/integrationTest/java/org/opensearch/security/api/InternalUsersRegExpPasswordRulesRestApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/InternalUsersRegExpPasswordRulesRestApiIntegrationTest.java @@ -79,7 +79,7 @@ public void canNotCreateUsersWithPassword() throws Exception { final var r = client.patch( internalUsers(), patch( - addOp("testuser1", internalUserWithPassword("$aA123456789")), + addOp("testuser1", internalUserWithPassword("$aA1234567890ab")), addOp("testuser2", internalUserWithPassword("testpassword2")) ) ); @@ -93,12 +93,12 @@ public void canNotCreateUsersWithPassword() throws Exception { @Test public void canCreateUsersWithPassword() throws Exception { try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { - assertThat(client.putJson(internalUsers("ok1"), internalUserWithPassword("$aA123456789")), isCreated()); - assertThat(client.putJson(internalUsers("ok2"), internalUserWithPassword("$Aa123456789")), isCreated()); - assertThat(client.putJson(internalUsers("ok3"), internalUserWithPassword("$1aAAAAAAAAA")), isCreated()); - assertThat(client.putJson(internalUsers("ok3"), internalUserWithPassword("$1aAAAAAAAAC")), isOk()); - assertThat(client.patch(internalUsers(), patch(addOp("ok3", internalUserWithPassword("$1aAAAAAAAAB")))), isOk()); - assertThat(client.putJson(internalUsers("ok1"), internalUserWithPassword("Admin_123")), isOk()); + assertThat(client.putJson(internalUsers("ok1"), internalUserWithPassword("$aA1234567890ab")), isCreated()); + assertThat(client.putJson(internalUsers("ok2"), internalUserWithPassword("$Aa1234567890ab")), isCreated()); + assertThat(client.putJson(internalUsers("ok3"), internalUserWithPassword("$1aAAAAAAAAAAab")), isCreated()); + assertThat(client.putJson(internalUsers("ok3"), internalUserWithPassword("$1aAAAAAAAACab")), isOk()); + assertThat(client.patch(internalUsers(), patch(addOp("ok3", internalUserWithPassword("$1aAAAAAAAABab")))), isOk()); + assertThat(client.putJson(internalUsers("ok1"), internalUserWithPassword("Admin_1234567890")), isOk()); } } diff --git a/src/integrationTest/java/org/opensearch/security/api/InternalUsersRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/InternalUsersRestApiIntegrationTest.java index 427d33803f..a580328b67 100644 --- a/src/integrationTest/java/org/opensearch/security/api/InternalUsersRestApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/InternalUsersRestApiIntegrationTest.java @@ -235,7 +235,7 @@ void verifyBadRequestOperations(TestRestClient client) throws Exception { assertThat( client.putJson( apiPath(predefinedUserName), - internalUser(randomAsciiAlphanumOfLength(10), configJsonArray(generateArrayValues(false)), null, null) + internalUser(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), configJsonArray(generateArrayValues(false)), null, null) ), isCreated() ); @@ -381,7 +381,7 @@ void verifyCrudOperationsForCombination( final var newUserJsonPut = internalUser( hidden, reserved, - randomAsciiAlphanumOfLength(10), + randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), randomConfigArray(false), attributes, securityRoles @@ -396,7 +396,7 @@ void verifyCrudOperationsForCombination( final var updatedUserJsonPut = internalUser( hidden, reserved, - randomAsciiAlphanumOfLength(10), + randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), randomConfigArray(false), attributes, securityRoles @@ -416,7 +416,7 @@ void verifyCrudOperationsForCombination( final var newUserJsonPatch = internalUser( hidden, reserved, - randomAsciiAlphanumOfLength(10), + randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), configJsonArray("a", "b"), (builder, params) -> builder.startObject().endObject(), configJsonArray() @@ -513,7 +513,9 @@ public void userApiWithDotsInName() throws Exception { assertThat( client.putJson( apiPath(dottedUserName), - (builder, params) -> builder.startObject().field("password", randomAsciiAlphanumOfLength(10)).endObject() + (builder, params) -> builder.startObject() + .field("password", randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH)) + .endObject() ), isCreated() ); @@ -523,7 +525,7 @@ public void userApiWithDotsInName() throws Exception { client.putJson( apiPath(dottedUserName), (builder, params) -> builder.startObject() - .field("hash", passwordHasher.hash(randomAsciiAlphanumOfLength(10).toCharArray())) + .field("hash", passwordHasher.hash(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH).toCharArray())) .endObject() ), isCreated() @@ -537,7 +539,7 @@ public void userApiWithDotsInName() throws Exception { addOp( dottedUserName, (ToXContentObject) (builder, params) -> builder.startObject() - .field("password", randomAsciiAlphanumOfLength(10)) + .field("password", randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH)) .endObject() ) ) @@ -553,7 +555,7 @@ public void userApiWithDotsInName() throws Exception { addOp( dottedUserName, (ToXContentObject) (builder, params) -> builder.startObject() - .field("hash", passwordHasher.hash(randomAsciiAlphanumOfLength(10).toCharArray())) + .field("hash", passwordHasher.hash(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH).toCharArray())) .endObject() ) ) @@ -592,7 +594,7 @@ public void noPasswordChange() throws Exception { apiPath("user1"), (builder, params) -> builder.startObject() .field("hash", "$2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m") - .field("password", randomAsciiAlphanumOfLength(10)) + .field("password", randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH)) .field("backend_roles", configJsonArray("admin", "role_a")) .endObject() ), @@ -603,7 +605,7 @@ public void noPasswordChange() throws Exception { apiPath("user2"), (builder, params) -> builder.startObject() .field("hash", "$2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m") - .field("password", randomAsciiAlphanumOfLength(10)) + .field("password", randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH)) .endObject() ), isCreated() @@ -622,7 +624,7 @@ public void noPasswordChange() throws Exception { client.putJson( apiPath("user2"), (builder, params) -> builder.startObject() - .field("password", randomAsciiAlphanumOfLength(10)) + .field("password", randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH)) .field("backend_roles", configJsonArray("admin", "role_b")) .endObject() ), @@ -634,7 +636,7 @@ public void noPasswordChange() throws Exception { @Test public void securityRoles() throws Exception { final var userWithSecurityRoles = randomAsciiAlphanumOfLength(15); - final var userWithSecurityRolesPassword = randomAsciiAlphanumOfLength(10); + final var userWithSecurityRolesPassword = randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH); try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { assertThat( client.patch(apiPath(), patch(addOp(userWithSecurityRoles, internalUser(userWithSecurityRolesPassword, null, null, null)))), @@ -696,7 +698,7 @@ void impossibleToSetHiddenRoleIsNotAllowed(final String predefinedUserName, fina assertThat( client.putJson( apiPath(randomAsciiAlphanumOfLength(10)), - internalUser(randomAsciiAlphanumOfLength(10), null, null, configJsonArray(HIDDEN_ROLE)) + internalUser(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), null, null, configJsonArray(HIDDEN_ROLE)) ), isNotFound().withAttribute("/message", "Resource 'hidden-role' is not available.") ); @@ -707,7 +709,7 @@ void impossibleToSetHiddenRoleIsNotAllowed(final String predefinedUserName, fina patch( addOp( randomAsciiAlphanumOfLength(10), - internalUser(randomAsciiAlphanumOfLength(10), null, null, configJsonArray(HIDDEN_ROLE)) + internalUser(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), null, null, configJsonArray(HIDDEN_ROLE)) ) ) ), @@ -723,7 +725,10 @@ void impossibleToSetHiddenRoleIsNotAllowed(final String predefinedUserName, fina void canAssignedHiddenRole(final TestRestClient client) throws Exception { final var userNamePut = randomAsciiAlphanumOfLength(4); assertThat( - client.putJson(apiPath(userNamePut), internalUser(randomAsciiAlphanumOfLength(10), null, null, configJsonArray(HIDDEN_ROLE))), + client.putJson( + apiPath(userNamePut), + internalUser(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), null, null, configJsonArray(HIDDEN_ROLE)) + ), isCreated() ); } @@ -732,7 +737,7 @@ void settingOfUnknownRoleIsNotAllowed(final String predefinedUserName, final Tes assertThat( client.putJson( apiPath(randomAsciiAlphanumOfLength(10)), - internalUser(randomAsciiAlphanumOfLength(10), null, null, configJsonArray("unknown-role")) + internalUser(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), null, null, configJsonArray("unknown-role")) ), isNotFound().withAttribute("/message", "role 'unknown-role' not found.") ); @@ -742,7 +747,7 @@ void settingOfUnknownRoleIsNotAllowed(final String predefinedUserName, final Tes patch( addOp( randomAsciiAlphanumOfLength(4), - internalUser(randomAsciiAlphanumOfLength(10), null, null, configJsonArray("unknown-role")) + internalUser(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH), null, null, configJsonArray("unknown-role")) ) ) ), @@ -767,7 +772,9 @@ public void parallelPutRequests() throws Exception { executorService.submit( () -> client.putJson( apiPath(userName), - (builder, params) -> builder.startObject().field("password", randomAsciiAlphanumOfLength(10)).endObject() + (builder, params) -> builder.startObject() + .field("password", randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH)) + .endObject() ) ) ); @@ -857,12 +864,12 @@ public void serviceUsers() throws Exception { assertThat( client.putJson( apiPath(randomAsciiAlphanumOfLength(10)), - serviceUserWithHash(true, passwordHasher.hash(randomAsciiAlphanumOfLength(10).toCharArray())) + serviceUserWithHash(true, passwordHasher.hash(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH).toCharArray())) ), isBadRequest() ); // Add Service account with password & Hash -- should fail - final var password = randomAsciiAlphanumOfLength(10); + final var password = randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH); assertThat( client.putJson( apiPath(randomAsciiAlphanumOfLength(10)), diff --git a/src/integrationTest/java/org/opensearch/security/api/InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest.java index 06101426ad..dc95c04118 100644 --- a/src/integrationTest/java/org/opensearch/security/api/InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest.java @@ -79,10 +79,10 @@ public void canNotCreateUsersWithPassword() throws Exception { @Test public void canCreateUserWithPassword() throws Exception { try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { - final var createdResp = client.putJson(internalUsers("str1234567"), internalUserWithPassword("s5tRx2r4bwex")); + final var createdResp = client.putJson(internalUsers("str1234567"), internalUserWithPassword("s5tRx2r4bwexYZ")); assertThat(createdResp, isCreated()); - final var patchResp = client.patch(internalUsers(), patch(addOp("str1234567", internalUserWithPassword("s5tRx2r4bwex")))); + final var patchResp = client.patch(internalUsers(), patch(addOp("str1234567", internalUserWithPassword("s5tRx2r4bwexYZ")))); assertThat(patchResp, isOk()); } } diff --git a/src/integrationTest/java/org/opensearch/security/api/RollbackVersionApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/RollbackVersionApiIntegrationTest.java index 82fda7c18f..d53228589c 100644 --- a/src/integrationTest/java/org/opensearch/security/api/RollbackVersionApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/RollbackVersionApiIntegrationTest.java @@ -11,6 +11,9 @@ package org.opensearch.security.api; +import java.util.concurrent.TimeUnit; + +import org.awaitility.Awaitility; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -49,6 +52,14 @@ private String RollbackVersion(String versionId) { public void setupConfigVersionsIndex() throws Exception { try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { assertThat(client.createUser(USER.getName(), USER), anyOf(isOk(), isCreated())); + // Version save triggered by user creation is async; wait until at least 2 versions + // exist so that rollback tests can find a previous version to roll back to. + Awaitility.await("at least 2 config versions persisted").atMost(15, TimeUnit.SECONDS).until(() -> { + var resp = client.get(ENDPOINT_PREFIX + "/versions"); + if (resp.getStatusCode() != 200) return false; + var versions = resp.bodyAsJsonNode().get("versions"); + return versions != null && versions.isArray() && versions.size() >= 2; + }); } } diff --git a/src/integrationTest/java/org/opensearch/security/api/ViewVersionApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/ViewVersionApiIntegrationTest.java index 13274cb20c..9aa3bd0a8d 100644 --- a/src/integrationTest/java/org/opensearch/security/api/ViewVersionApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/ViewVersionApiIntegrationTest.java @@ -34,8 +34,10 @@ public class ViewVersionApiIntegrationTest extends AbstractApiIntegrationTest { + private static final String LIMITED_USER_PASSWORD = "limitedPass1234!"; + @Rule - public LocalCluster localCluster = clusterBuilder().users(new TestSecurityConfig.User("limitedUser").password("limitedPass")) + public LocalCluster localCluster = clusterBuilder().users(new TestSecurityConfig.User("limitedUser").password(LIMITED_USER_PASSWORD)) .nodeSetting(EXPERIMENTAL_SECURITY_CONFIGURATIONS_VERSIONS_ENABLED, true) .build(); @@ -108,7 +110,7 @@ public void testViewSpecificVersionNotFound() throws Exception { @Test public void testViewAllVersions_forbiddenWithoutAdminCert() throws Exception { - try (TestRestClient client = localCluster.getRestClient("limitedUser", "limitedPass")) { + try (TestRestClient client = localCluster.getRestClient("limitedUser", LIMITED_USER_PASSWORD)) { var response = client.get(viewVersionBase()); assertThat(response, anyOf(isUnauthorized(), isForbidden())); } diff --git a/src/integrationTest/java/org/opensearch/security/hash/Argon2DefaultConfigHashingTests.java b/src/integrationTest/java/org/opensearch/security/hash/Argon2DefaultConfigHashingTests.java index 20cba1aa61..3da335a7fa 100644 --- a/src/integrationTest/java/org/opensearch/security/hash/Argon2DefaultConfigHashingTests.java +++ b/src/integrationTest/java/org/opensearch/security/hash/Argon2DefaultConfigHashingTests.java @@ -15,10 +15,14 @@ import java.util.Map; import org.apache.http.HttpStatus; +import org.junit.Assume; import org.junit.ClassRule; import org.junit.Test; +import org.junit.rules.ExternalResource; +import org.junit.rules.RuleChain; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.cluster.ClusterManager; import org.opensearch.test.framework.cluster.LocalCluster; @@ -31,7 +35,7 @@ public class Argon2DefaultConfigHashingTests extends HashingTests { private static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS) .hash( generateArgon2Hash( - "secret", + TestSecurityConfig.DEFAULT_TEST_PASSWORD, ConfigConstants.SECURITY_PASSWORD_HASHING_ARGON2_MEMORY_DEFAULT, ConfigConstants.SECURITY_PASSWORD_HASHING_ARGON2_ITERATIONS_DEFAULT, ConfigConstants.SECURITY_PASSWORD_HASHING_ARGON2_PARALLELISM_DEFAULT, @@ -41,8 +45,7 @@ public class Argon2DefaultConfigHashingTests extends HashingTests { ) ); - @ClassRule - public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + private static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) .authc(AUTHC_HTTPBASIC_INTERNAL) .users(ADMIN_USER) .anonymousAuth(false) @@ -56,6 +59,14 @@ public class Argon2DefaultConfigHashingTests extends HashingTests { ) .build(); + @ClassRule + public static final RuleChain rules = RuleChain.outerRule(new ExternalResource() { + @Override + protected void before() { + Assume.assumeFalse("Skipping Argon2 hashing tests: Argon2 is (yet) not FIPS-compliant", FipsMode.isEnabled()); + } + }).around(cluster); + @Test public void shouldAuthenticateWithCorrectPassword() { String hash = generateArgon2Hash( diff --git a/src/integrationTest/java/org/opensearch/security/hash/BCryptCustomConfigHashingTests.java b/src/integrationTest/java/org/opensearch/security/hash/BCryptCustomConfigHashingTests.java index 344b4ad37d..5ee5608e69 100644 --- a/src/integrationTest/java/org/opensearch/security/hash/BCryptCustomConfigHashingTests.java +++ b/src/integrationTest/java/org/opensearch/security/hash/BCryptCustomConfigHashingTests.java @@ -19,12 +19,15 @@ import org.apache.http.HttpStatus; import org.awaitility.Awaitility; import org.junit.After; +import org.junit.Assume; import org.junit.Before; +import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.cluster.ClusterManager; import org.opensearch.test.framework.cluster.LocalCluster; @@ -53,10 +56,15 @@ public static Collection data() { return Arrays.asList(new Object[][] { { "A", 4 }, { "B", 6 }, { "Y", 10 }, { "A", 10 }, { "B", 4 }, { "Y", 6 } }); } + @BeforeClass + public static void skipInFipsMode() { + Assume.assumeFalse("Skipping BCrypt hashing tests: BCrypt is not FIPS-compliant", FipsMode.isEnabled()); + } + @Before public void startCluster() { TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS) - .hash(generateBCryptHash("secret", minor, rounds)); + .hash(generateBCryptHash(TestSecurityConfig.DEFAULT_TEST_PASSWORD, minor, rounds)); cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) .authc(AUTHC_HTTPBASIC_INTERNAL) .users(ADMIN_USER) @@ -76,7 +84,7 @@ public void startCluster() { .build(); cluster.before(); - try (TestRestClient client = cluster.getRestClient(ADMIN_USER.getName(), "secret")) { + try (TestRestClient client = cluster.getRestClient(ADMIN_USER.getName(), TestSecurityConfig.DEFAULT_TEST_PASSWORD)) { Awaitility.await() .alias("Load default configuration") .until(() -> client.securityHealth().getTextFromJsonBody("/status"), equalTo("UP")); diff --git a/src/integrationTest/java/org/opensearch/security/hash/BCryptDefaultConfigHashingTests.java b/src/integrationTest/java/org/opensearch/security/hash/BCryptDefaultConfigHashingTests.java index 86adc728ea..00c3b15ce4 100644 --- a/src/integrationTest/java/org/opensearch/security/hash/BCryptDefaultConfigHashingTests.java +++ b/src/integrationTest/java/org/opensearch/security/hash/BCryptDefaultConfigHashingTests.java @@ -15,10 +15,14 @@ import java.util.Map; import org.apache.http.HttpStatus; +import org.junit.Assume; import org.junit.ClassRule; import org.junit.Test; +import org.junit.rules.ExternalResource; +import org.junit.rules.RuleChain; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.cluster.ClusterManager; import org.opensearch.test.framework.cluster.LocalCluster; @@ -30,8 +34,7 @@ public class BCryptDefaultConfigHashingTests extends HashingTests { private static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS); - @ClassRule - public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + private static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) .authc(AUTHC_HTTPBASIC_INTERNAL) .users(ADMIN_USER) .anonymousAuth(false) @@ -40,6 +43,14 @@ public class BCryptDefaultConfigHashingTests extends HashingTests { ) .build(); + @ClassRule + public static final RuleChain rules = RuleChain.outerRule(new ExternalResource() { + @Override + protected void before() { + Assume.assumeFalse("Skipping BCrypt hashing tests: BCrypt is not FIPS-compliant", FipsMode.isEnabled()); + } + }).around(cluster); + @Test public void shouldAuthenticateWithCorrectPassword() { String hash = generateBCryptHash( diff --git a/src/integrationTest/java/org/opensearch/security/hash/PBKDF2CustomConfigHashingTests.java b/src/integrationTest/java/org/opensearch/security/hash/PBKDF2CustomConfigHashingTests.java index e402db2293..51cec2d12a 100644 --- a/src/integrationTest/java/org/opensearch/security/hash/PBKDF2CustomConfigHashingTests.java +++ b/src/integrationTest/java/org/opensearch/security/hash/PBKDF2CustomConfigHashingTests.java @@ -39,8 +39,6 @@ public class PBKDF2CustomConfigHashingTests extends HashingTests { private LocalCluster cluster; - private static final String PASSWORD = "top$ecret1234!"; - private final String function; private final int iterations; private final int length; @@ -65,9 +63,9 @@ public static Collection data() { @Before public void startCluster() { - TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS) - .hash(generatePBKDF2Hash("secret", function, iterations, length)); + .password(TestSecurityConfig.DEFAULT_TEST_PASSWORD) + .hash(generatePBKDF2Hash(TestSecurityConfig.DEFAULT_TEST_PASSWORD, function, iterations, length)); cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) .authc(AUTHC_HTTPBASIC_INTERNAL) .users(ADMIN_USER) @@ -89,7 +87,7 @@ public void startCluster() { .build(); cluster.before(); - try (TestRestClient client = cluster.getRestClient(ADMIN_USER.getName(), "secret")) { + try (TestRestClient client = cluster.getRestClient(ADMIN_USER.getName(), TestSecurityConfig.DEFAULT_TEST_PASSWORD)) { Awaitility.await() .alias("Load default configuration") .until(() -> client.securityHealth().getTextFromJsonBody("/status"), equalTo("UP")); diff --git a/src/integrationTest/java/org/opensearch/security/hash/PBKDF2DefaultConfigHashingTests.java b/src/integrationTest/java/org/opensearch/security/hash/PBKDF2DefaultConfigHashingTests.java index 2becee1f36..e1c551c53a 100644 --- a/src/integrationTest/java/org/opensearch/security/hash/PBKDF2DefaultConfigHashingTests.java +++ b/src/integrationTest/java/org/opensearch/security/hash/PBKDF2DefaultConfigHashingTests.java @@ -31,12 +31,13 @@ public class PBKDF2DefaultConfigHashingTests extends HashingTests { private static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS) .hash( generatePBKDF2Hash( - "secret", + TestSecurityConfig.DEFAULT_TEST_PASSWORD, ConfigConstants.SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION_DEFAULT, ConfigConstants.SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS_DEFAULT, ConfigConstants.SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH_DEFAULT ) - ); + ) + .password(TestSecurityConfig.DEFAULT_TEST_PASSWORD); @ClassRule public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) diff --git a/src/integrationTest/java/org/opensearch/security/http/BasicAuthTests.java b/src/integrationTest/java/org/opensearch/security/http/BasicAuthTests.java index 3bc6107ca6..c2cefde95c 100644 --- a/src/integrationTest/java/org/opensearch/security/http/BasicAuthTests.java +++ b/src/integrationTest/java/org/opensearch/security/http/BasicAuthTests.java @@ -33,10 +33,10 @@ import static org.hamcrest.Matchers.notNullValue; public class BasicAuthTests { - static final User TEST_USER = new User("test_user").password("s3cret"); + static final User TEST_USER = new User("test_user").password("s3cr3t-test-user-pw"); public static final String CUSTOM_ATTRIBUTE_NAME = "superhero"; - static final User SUPER_USER = new User("super-user").password("super-password").attr(CUSTOM_ATTRIBUTE_NAME, "true"); + static final User SUPER_USER = new User("super-user").password("super-secret-password").attr(CUSTOM_ATTRIBUTE_NAME, "true"); public static final String NOT_EXISTING_USER = "not-existing-user"; public static final String INVALID_PASSWORD = "secret-password"; diff --git a/src/integrationTest/java/org/opensearch/security/http/BasicWithAnonymousAuthTests.java b/src/integrationTest/java/org/opensearch/security/http/BasicWithAnonymousAuthTests.java index 4b5fd51477..c84194e4bd 100644 --- a/src/integrationTest/java/org/opensearch/security/http/BasicWithAnonymousAuthTests.java +++ b/src/integrationTest/java/org/opensearch/security/http/BasicWithAnonymousAuthTests.java @@ -28,10 +28,10 @@ import static org.hamcrest.Matchers.notNullValue; public class BasicWithAnonymousAuthTests { - static final User TEST_USER = new User("test_user").password("s3cret"); + static final User TEST_USER = new User("test_user").password("s3cr3t-test-user-pw"); public static final String CUSTOM_ATTRIBUTE_NAME = "superhero"; - static final User SUPER_USER = new User("super-user").password("super-password").attr(CUSTOM_ATTRIBUTE_NAME, "true"); + static final User SUPER_USER = new User("super-user").password("super-secret-password").attr(CUSTOM_ATTRIBUTE_NAME, "true"); public static final String NOT_EXISTING_USER = "not-existing-user"; public static final String INVALID_PASSWORD = "secret-password"; diff --git a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java index b3732685cb..eb7bfeb9a5 100644 --- a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java +++ b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java @@ -72,11 +72,11 @@ public class OnBehalfOfJwtAuthenticationTest { "alternativeSigningKeyalternativeSigningKeyalternativeSigningKeyalternativeSigningKey".getBytes(StandardCharsets.UTF_8) ); - private static final String encryptionKey = Base64.getEncoder().encodeToString("encryptionKey".getBytes(StandardCharsets.UTF_8)); + private static final String encryptionKey = Base64.getEncoder().encodeToString("encryptionKey!!".getBytes(StandardCharsets.UTF_8)); public static final String ADMIN_USER_NAME = "admin"; public static final String OBO_USER_NAME_WITH_PERM = "obo_user"; public static final String OBO_USER_NAME_NO_PERM = "obo_user_no_perm"; - public static final String DEFAULT_PASSWORD = "secret"; + public static final String DEFAULT_PASSWORD = TestSecurityConfig.DEFAULT_TEST_PASSWORD; public static final String NEW_PASSWORD = "testPassword123!!"; public static final String OBO_TOKEN_REASON = "{\"description\":\"Test generation\"}"; public static final String OBO_ENDPOINT_PREFIX = "_plugins/_security/api/obo/token"; diff --git a/src/integrationTest/java/org/opensearch/security/http/RolesMappingTests.java b/src/integrationTest/java/org/opensearch/security/http/RolesMappingTests.java index 1a930ae0bb..1b9bb5aa09 100644 --- a/src/integrationTest/java/org/opensearch/security/http/RolesMappingTests.java +++ b/src/integrationTest/java/org/opensearch/security/http/RolesMappingTests.java @@ -27,8 +27,11 @@ import static org.hamcrest.Matchers.notNullValue; public class RolesMappingTests { - static final TestSecurityConfig.User USER_A = new TestSecurityConfig.User("userA").password("s3cret").backendRoles("mapsToRoleA"); - static final TestSecurityConfig.User USER_B = new TestSecurityConfig.User("userB").password("P@ssw0rd").backendRoles("mapsToRoleB"); + // passwords must be >=14 chars (112 bits) to satisfy FIPS PBKDF2 minimum + static final TestSecurityConfig.User USER_A = new TestSecurityConfig.User("userA").password("s3cr3t-user-a-pw") + .backendRoles("mapsToRoleA"); + static final TestSecurityConfig.User USER_B = new TestSecurityConfig.User("userB").password("P@ssw0rd-user-b-pw") + .backendRoles("mapsToRoleB"); private static final TestSecurityConfig.Role ROLE_A = new TestSecurityConfig.Role("roleA").clusterPermissions("cluster_all"); diff --git a/src/integrationTest/java/org/opensearch/security/http/UntrustedLdapServerCertificateTest.java b/src/integrationTest/java/org/opensearch/security/http/UntrustedLdapServerCertificateTest.java index 9a29151830..d4a37ab674 100644 --- a/src/integrationTest/java/org/opensearch/security/http/UntrustedLdapServerCertificateTest.java +++ b/src/integrationTest/java/org/opensearch/security/http/UntrustedLdapServerCertificateTest.java @@ -16,6 +16,7 @@ import org.junit.Test; import org.junit.rules.RuleChain; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.LdapAuthenticationConfigBuilder; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.TestSecurityConfig.AuthcDomain; @@ -94,7 +95,11 @@ public void shouldNotAuthenticateUserWithLdap() { response.assertStatusCode(401); } - logsRule.assertThatStackTraceContain("javax.net.ssl.SSLHandshakeException"); + if (FipsMode.isEnabled()) { + logsRule.assertThatStackTraceContain("org.bouncycastle.tls.TlsFatalAlert"); + } else { + logsRule.assertThatStackTraceContain("javax.net.ssl.SSLHandshakeException"); + } } } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java b/src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java index d20dda198d..9fa593e730 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java @@ -56,10 +56,7 @@ public class ApiTokenTest { private static final String OBO_DESCRIPTION = "{\"description\":\"Testing\", \"service\":\"self-issued\"}"; private static final String signingKey = Base64.getEncoder() .encodeToString("jwt signing key for an on behalf of token authentication backend for testing".getBytes(StandardCharsets.UTF_8)); - private static final String encryptionKey = Base64.getEncoder().encodeToString("encryptionKey".getBytes(StandardCharsets.UTF_8)); - public static final String ADMIN_USER_NAME = "admin"; - public static final String REGULAR_USER_NAME = "regular_user"; - public static final String DEFAULT_PASSWORD = "secret"; + private static final String encryptionKey = Base64.getEncoder().encodeToString("encryptionKey!!".getBytes(StandardCharsets.UTF_8)); public static final String NEW_PASSWORD = "testPassword123!!"; public static final String TEST_TOKEN_PAYLOAD = """ { @@ -99,7 +96,7 @@ public class ApiTokenTest { """; public static final String CURRENT_AND_NEW_PASSWORDS = "{ \"current_password\": \"" - + DEFAULT_PASSWORD + + TestSecurityConfig.DEFAULT_TEST_PASSWORD + "\", \"password\": \"" + NEW_PASSWORD + "\" }"; diff --git a/src/integrationTest/java/org/opensearch/security/ssl/util/SSLRequestHelperTests.java b/src/integrationTest/java/org/opensearch/security/ssl/util/SSLRequestHelperTests.java index 71c0642291..5f7c57455f 100644 --- a/src/integrationTest/java/org/opensearch/security/ssl/util/SSLRequestHelperTests.java +++ b/src/integrationTest/java/org/opensearch/security/ssl/util/SSLRequestHelperTests.java @@ -39,13 +39,13 @@ import org.junit.rules.TemporaryFolder; import org.bouncycastle.asn1.x509.CRLReason; import org.bouncycastle.cert.jcajce.JcaX509v2CRLBuilder; -import org.bouncycastle.crypto.CryptoServicesRegistrar; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.opensearch.common.settings.Settings; import org.opensearch.rest.RestRequest.Method; import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.PemKeyReader; import org.opensearch.test.framework.certificate.CertificateData; import org.opensearch.test.framework.certificate.TestCertificates; @@ -59,8 +59,8 @@ public class SSLRequestHelperTests { - private static final String STORE_NAME = CryptoServicesRegistrar.isInApprovedOnlyMode() ? "truststore.bcfks" : "truststore.jks"; - private static final String STORE_TYPE = CryptoServicesRegistrar.isInApprovedOnlyMode() ? "BCFKS" : "JKS"; + private static final String STORE_NAME = FipsMode.isEnabled() ? "truststore.bcfks" : "truststore.jks"; + private static final String STORE_TYPE = FipsMode.isEnabled() ? "BCFKS" : "JKS"; private static final char[] INTERNAL_STORE_PASSWORD = DEFAULT_STORE_PASSWORD.toCharArray(); /** Minimum TLS packet buffer size used when the engine reports a smaller value. */ diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java index d24a20656a..ba6bc44ce9 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java @@ -31,8 +31,10 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; +import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -70,6 +72,7 @@ import org.opensearch.security.securityconf.impl.v7.RoleMappingsV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.cluster.OpenSearchClientProvider.UserCredentialsHolder; import org.opensearch.test.framework.data.TestIndex; import org.opensearch.transport.client.Client; @@ -95,10 +98,23 @@ */ public class TestSecurityConfig { + public static final String DEFAULT_TEST_PASSWORD; + + static { + byte[] bytes = new byte[16]; // satisfies BC FIPS 112-bit minimum + new SecureRandom().nextBytes(bytes); + DEFAULT_TEST_PASSWORD = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + private static final Logger log = LogManager.getLogger(TestSecurityConfig.class); private static final PasswordHasher passwordHasher = PasswordHasherFactory.createPasswordHasher( - Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.BCRYPT).build() + Settings.builder() + .put( + ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, + FipsMode.isEnabled() ? ConfigConstants.PBKDF2 : ConfigConstants.BCRYPT + ) + .build() ); private Config config = new Config(); @@ -500,7 +516,7 @@ public static final class User implements UserCredentialsHolder, ToXContentObjec public User(String name) { this.name = name; - this.password = "secret"; + this.password = DEFAULT_TEST_PASSWORD; } public User description(String description) { @@ -1314,7 +1330,7 @@ public SecurityDynamicConfiguration geActionGroupsConfiguration( } } - static String hashPassword(final String clearTextPassword) { + public static String hashPassword(final String clearTextPassword) { return passwordHasher.hash(clearTextPassword.toCharArray()); } diff --git a/src/integrationTest/java/org/opensearch/test/framework/certificate/AlgorithmKit.java b/src/integrationTest/java/org/opensearch/test/framework/certificate/AlgorithmKit.java index 60ae56410c..5d516443af 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/certificate/AlgorithmKit.java +++ b/src/integrationTest/java/org/opensearch/test/framework/certificate/AlgorithmKit.java @@ -77,7 +77,7 @@ private static void notEmptyAlgorithmName(String signatureAlgorithmName) { */ public static AlgorithmKit ecdsaSha256withEcdsa(Provider securityProvider, String ellipticCurve) { notEmptyAlgorithmName(ellipticCurve); - Supplier supplier = ecdsaKeyPairSupplier(requireNonNull(securityProvider, "Security provider is required"), ellipticCurve); + Supplier supplier = ecdsaKeyPairSupplier(securityProvider, ellipticCurve); return new AlgorithmKit(SIGNATURE_ALGORITHM_SHA_256_WITH_ECDSA, supplier); } @@ -119,7 +119,12 @@ public KeyPair generateKeyPair() { private static Supplier rsaKeyPairSupplier(Provider securityProvider, int keySize) { try { - KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", securityProvider); + KeyPairGenerator generator; + if (securityProvider == null) { + generator = KeyPairGenerator.getInstance("RSA"); + } else { + generator = KeyPairGenerator.getInstance("RSA", securityProvider); + } log.info("Initialize key pair generator with keySize: {}", keySize); generator.initialize(keySize); return generator::generateKeyPair; @@ -132,7 +137,12 @@ private static Supplier rsaKeyPairSupplier(Provider securityProvider, i private static Supplier ecdsaKeyPairSupplier(Provider securityProvider, String ellipticCurve) { try { - KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", securityProvider); + KeyPairGenerator generator; + if (securityProvider == null) { + generator = KeyPairGenerator.getInstance("EC"); + } else { + generator = KeyPairGenerator.getInstance("EC", securityProvider); + } log.info("Initialize key pair generator with elliptic curve: {}", ellipticCurve); ECGenParameterSpec ecsp = new ECGenParameterSpec(ellipticCurve); generator.initialize(ecsp); diff --git a/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificatesIssuer.java b/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificatesIssuer.java index e55d9e2735..276a7e73df 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificatesIssuer.java +++ b/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificatesIssuer.java @@ -30,7 +30,6 @@ import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; -import java.security.Provider; import java.security.PublicKey; import java.util.Calendar; import java.util.Date; @@ -86,12 +85,10 @@ class CertificatesIssuer { private static final AtomicLong ID_COUNTER = new AtomicLong(System.currentTimeMillis()); - private final Provider securityProvider; private final AlgorithmKit algorithmKit; private final JcaX509ExtensionUtils extUtils; - CertificatesIssuer(Provider securityProvider, AlgorithmKit algorithmKit) { - this.securityProvider = securityProvider; + CertificatesIssuer(AlgorithmKit algorithmKit) { this.algorithmKit = algorithmKit; this.extUtils = getExtUtils(); } @@ -164,7 +161,7 @@ private X509CertificateHolder buildCertificateHolder( } private ContentSigner createContentSigner(PrivateKey privateKey) throws OperatorCreationException { - return new JcaContentSignerBuilder(algorithmKit.getSignatureAlgorithmName()).setProvider(securityProvider).build(privateKey); + return new JcaContentSignerBuilder(algorithmKit.getSignatureAlgorithmName()).build(privateKey); } private void addExtendedKeyUsageExtension(X509v3CertificateBuilder builder, CertificateMetadata certificateMetadata) diff --git a/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificatesIssuerFactory.java b/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificatesIssuerFactory.java index 8d7c43c69e..45ae8f6cb2 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificatesIssuerFactory.java +++ b/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificatesIssuerFactory.java @@ -10,9 +10,6 @@ package org.opensearch.test.framework.certificate; import java.security.Provider; -import java.util.Optional; - -import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import static org.opensearch.test.framework.certificate.AlgorithmKit.ecdsaSha256withEcdsa; import static org.opensearch.test.framework.certificate.AlgorithmKit.rsaSha256withRsa; @@ -30,8 +27,6 @@ private CertificatesIssuerFactory() { } - private static final Provider DEFAULT_SECURITY_PROVIDER = new BouncyCastleFipsProvider();; - /** * @see {@link #rsaBaseCertificateIssuer(Provider)} */ @@ -45,8 +40,7 @@ public static CertificatesIssuer rsaBaseCertificateIssuer() { * @return new instance of {@link CertificatesIssuer} */ public static CertificatesIssuer rsaBaseCertificateIssuer(Provider securityProvider) { - Provider provider = Optional.ofNullable(securityProvider).orElse(DEFAULT_SECURITY_PROVIDER); - return new CertificatesIssuer(provider, rsaSha256withRsa(provider, KEY_SIZE)); + return new CertificatesIssuer(rsaSha256withRsa(securityProvider, KEY_SIZE)); } /** @@ -62,7 +56,6 @@ public static CertificatesIssuer ecdsaBaseCertificatesIssuer() { * @return new instance of {@link CertificatesIssuer} */ public static CertificatesIssuer ecdsaBaseCertificatesIssuer(Provider securityProvider) { - Provider provider = Optional.ofNullable(securityProvider).orElse(DEFAULT_SECURITY_PROVIDER); - return new CertificatesIssuer(provider, ecdsaSha256withEcdsa(securityProvider, "P-384")); + return new CertificatesIssuer(ecdsaSha256withEcdsa(securityProvider, "P-384")); } } diff --git a/src/integrationTest/java/org/opensearch/test/framework/certificate/PemConverter.java b/src/integrationTest/java/org/opensearch/test/framework/certificate/PemConverter.java index 749ab232bc..4f1b84f7bc 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/certificate/PemConverter.java +++ b/src/integrationTest/java/org/opensearch/test/framework/certificate/PemConverter.java @@ -109,7 +109,7 @@ private static PemObject createPkcs8PrivateKeyPem(PrivateKey privateKey, String private static OutputEncryptor getPasswordEncryptor(String password) throws OperatorCreationException { if (!Strings.isNullOrEmpty(password)) { - JceOpenSSLPKCS8EncryptorBuilder encryptorBuilder = new JceOpenSSLPKCS8EncryptorBuilder(PKCS8Generator.PBE_SHA1_3DES); + JceOpenSSLPKCS8EncryptorBuilder encryptorBuilder = new JceOpenSSLPKCS8EncryptorBuilder(PKCS8Generator.AES_256_CBC); encryptorBuilder.setRandom(secureRandom); encryptorBuilder.setPassword(password.toCharArray()); return encryptorBuilder.build(); diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/CloseableHttpClientFactory.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/CloseableHttpClientFactory.java index 0fd75b08a1..0fff83ffc5 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/CloseableHttpClientFactory.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/CloseableHttpClientFactory.java @@ -24,6 +24,8 @@ import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; import org.apache.hc.core5.http.io.SocketConfig; +import static org.opensearch.security.ssl.util.SSLConfigConstants.ALLOWED_SSL_PROTOCOLS; + class CloseableHttpClientFactory { private final SSLContext sslContext; @@ -52,7 +54,7 @@ public CloseableHttpClient getHTTPClient() { final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory( this.sslContext, - null, + ALLOWED_SSL_PROTOCOLS, supportedCipherSuites, NoopHostnameVerifier.INSTANCE ); diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/MinimumSecuritySettingsSupplierFactory.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/MinimumSecuritySettingsSupplierFactory.java index 34a105ea39..fb7760fac4 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/MinimumSecuritySettingsSupplierFactory.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/MinimumSecuritySettingsSupplierFactory.java @@ -32,6 +32,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.certificate.TestCertificates; public class MinimumSecuritySettingsSupplierFactory { @@ -83,6 +84,9 @@ private Settings.Builder minimumOpenSearchSettingsBuilder(int node, boolean sslO testCertificates.getNodeKey(node, PRIVATE_KEY_HTTP_PASSWORD).getAbsolutePath() ); builder.put("plugins.security.ssl.http.pemkey_password", PRIVATE_KEY_HTTP_PASSWORD); + if (FipsMode.isEnabled()) { + builder.put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.PBKDF2); + } if (sslOnly == false) { builder.put(ConfigConstants.SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST, false); builder.putList("plugins.security.authcz.admin_dn", testCertificates.getAdminDNs()); diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java index 37005a0272..15d3fb659c 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java @@ -66,12 +66,11 @@ import org.opensearch.client.RestClientBuilder; import org.opensearch.client.RestHighLevelClient; import org.opensearch.common.settings.Settings; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.PemKeyReader; import org.opensearch.test.framework.certificate.CertificateData; import org.opensearch.test.framework.certificate.TestCertificates; -import reactor.netty.http.HttpProtocol; - import static org.opensearch.security.ssl.util.SSLConfigConstants.DEFAULT_STORE_TYPE; import static org.opensearch.test.framework.cluster.TestRestClientConfiguration.getBasicAuthHeader; @@ -233,7 +232,7 @@ default TestRestClient getRestClient(CertificateData useCertificateData, Header. /** * Returns a generic HTTP/1.1/HTTP 2.0/HTTP 3.0 client. */ - default ReactorHttpClient getGenericClient(HttpProtocol protocol, boolean secure, Settings settings) { + default ReactorHttpClient getGenericClient(Settings settings) { return new ReactorHttpClient(true, true, settings, getHttpAddress()); } @@ -294,7 +293,12 @@ private SSLContext getSSLContext(CertificateData useCertificateData) { if (useCertificateData != null) { Certificate[] chainOfTrust = { useCertificateData.certificate() }; ks.setKeyEntry("admin-certificate", useCertificateData.getKey(), null, chainOfTrust); - KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + KeyManagerFactory keyManagerFactory; + if (FipsMode.isEnabled()) { + keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE"); + } else { + keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + } keyManagerFactory.init(ks, null); keyManagers = keyManagerFactory.getKeyManagers(); } diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/ReactorHttpClient.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/ReactorHttpClient.java index 3f91b1f36f..584168f2bf 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/ReactorHttpClient.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/ReactorHttpClient.java @@ -19,10 +19,15 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.function.Consumer; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; import org.opensearch.http.netty4.http3.Http3Utils; +import org.opensearch.security.support.FipsMode; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; @@ -38,7 +43,14 @@ import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http2.HttpConversionUtil; +import io.netty.handler.ssl.ApplicationProtocolConfig; +import io.netty.handler.ssl.ApplicationProtocolConfig.Protocol; +import io.netty.handler.ssl.ApplicationProtocolConfig.SelectedListenerFailureBehavior; +import io.netty.handler.ssl.ApplicationProtocolConfig.SelectorFailureBehavior; +import io.netty.handler.ssl.ApplicationProtocolNames; import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslProvider; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import io.netty.resolver.DefaultAddressResolverGroup; import reactor.core.publisher.Flux; @@ -50,13 +62,13 @@ import reactor.netty.http.HttpProtocol; import reactor.netty.http.client.HttpClient; -import static org.opensearch.http.HttpTransportSettings.SETTING_HTTP_HTTP3_ENABLED; import static org.opensearch.http.HttpTransportSettings.SETTING_HTTP_MAX_CONTENT_LENGTH; /** * Tiny helper to send http requests over netty. */ public class ReactorHttpClient implements Closeable { + private static final Logger LOG = LogManager.getLogger(ReactorHttpClient.class); private static final java.util.Random RAND = new java.util.Random(); private final boolean compression; @@ -66,9 +78,19 @@ public class ReactorHttpClient implements Closeable { private final InetSocketAddress remoteAddress; public ReactorHttpClient(boolean compression, boolean secure, Settings settings, InetSocketAddress remoteAddress) { + this(compression, secure, selectSupportedProtocol(secure), settings, remoteAddress); + } + + public ReactorHttpClient( + boolean compression, + boolean secure, + HttpProtocol protocol, + Settings settings, + InetSocketAddress remoteAddress + ) { this.compression = compression; this.secure = secure; - this.protocol = randomProtocol(secure, settings); + this.protocol = protocol; this.settings = settings; this.remoteAddress = remoteAddress; } @@ -127,6 +149,7 @@ private List sendRequests( ) ) ) + .doOnError(e -> LOG.warn("Request failed [protocol={}]: {}", protocol, e.getMessage(), e)) ) .toArray(Mono[]::new); @@ -150,22 +173,47 @@ private HttpClient createClient(final InetSocketAddress remoteAddress, final Eve if (secure) { if (protocol == HttpProtocol.HTTP11) { + Consumer http11Configure = s -> { + if (FipsMode.isEnabled()) { + s.sslProvider(SslProvider.JDK); + } + s.clientAuth(ClientAuth.NONE).trustManager(InsecureTrustManagerFactory.INSTANCE); + }; return client.protocol(protocol) .secure( - spec -> spec.sslContext( - Http11SslContextSpec.forClient() - .configure(s -> s.clientAuth(ClientAuth.NONE).trustManager(InsecureTrustManagerFactory.INSTANCE)) - ).handshakeTimeout(Duration.ofSeconds(30)) + spec -> spec.sslContext(Http11SslContextSpec.forClient().configure(http11Configure)) + .handshakeTimeout(Duration.ofSeconds(30)) ); } else if (protocol == HttpProtocol.H2) { - return client.protocol(new HttpProtocol[] { HttpProtocol.HTTP11, HttpProtocol.H2 }) + Consumer h2Configure = s -> { + if (FipsMode.isEnabled()) { + s.sslProvider(SslProvider.JDK) + .applicationProtocolConfig( + new ApplicationProtocolConfig( + Protocol.ALPN, + SelectorFailureBehavior.NO_ADVERTISE, + SelectedListenerFailureBehavior.ACCEPT, + ApplicationProtocolNames.HTTP_2 + ) + ); + } + s.clientAuth(ClientAuth.NONE).trustManager(InsecureTrustManagerFactory.INSTANCE); + }; + HttpProtocol[] h2Protocols = FipsMode.isEnabled() + ? new HttpProtocol[] { HttpProtocol.H2 } + : new HttpProtocol[] { HttpProtocol.HTTP11, HttpProtocol.H2 }; + return client.protocol(h2Protocols) .secure( - spec -> spec.sslContext( - Http2SslContextSpec.forClient() - .configure(s -> s.clientAuth(ClientAuth.NONE).trustManager(InsecureTrustManagerFactory.INSTANCE)) - ).handshakeTimeout(Duration.ofSeconds(30)) + spec -> spec.sslContext(Http2SslContextSpec.forClient().configure(h2Configure)) + .handshakeTimeout(Duration.ofSeconds(30)) ); } else { + if (FipsMode.isEnabled()) { + throw new IllegalStateException( + "HTTP/3 requires BoringSSL which is not built from the FIPS-validated branch in this distribution; " + + "substitute a FIPS-certified BoringSSL build to enable HTTP/3 in FIPS mode" + ); + } return client.protocol(protocol) .secure( spec -> spec.sslContext( @@ -181,6 +229,11 @@ private HttpClient createClient(final InetSocketAddress remoteAddress, final Eve ); } } else { + if (FipsMode.isEnabled()) { + throw new IllegalStateException( + "Plaintext connections are not permitted in FIPS mode; TLS is required to engage the FIPS security providers" + ); + } if (protocol == HttpProtocol.HTTP11) { return client.protocol(protocol); } else { @@ -198,15 +251,22 @@ public HttpProtocol protocol() { return protocol; } - private static HttpProtocol randomProtocol(boolean secure, Settings settings) { - HttpProtocol[] values = null; + private static HttpProtocol selectSupportedProtocol(boolean secure) { + if (FipsMode.isEnabled() && !secure) { + throw new IllegalStateException( + "Plaintext connections are not permitted in FIPS mode; TLS is required to engage the FIPS security providers" + ); + } + + HttpProtocol[] values; if (secure) { - if (Http3Utils.isHttp3Available() && SETTING_HTTP_HTTP3_ENABLED.get(settings).booleanValue() == true) { - values = new HttpProtocol[] { HttpProtocol.HTTP11, HttpProtocol.H2, HttpProtocol.HTTP3 }; - } else { - values = new HttpProtocol[] { HttpProtocol.HTTP11, HttpProtocol.H2 }; - } + // In FIPS mode, exclude HTTP/3: the bundled BoringSSL (used by QUIC) is not built from the + // FIPS-validated branch and bypasses JSSE/BC FIPS entirely. This is a build-level constraint — + // a FIPS-certified BoringSSL substituted at the OS level would re-enable HTTP/3 in FIPS mode. + values = Http3Utils.isHttp3Available() && !FipsMode.isEnabled() + ? new HttpProtocol[] { HttpProtocol.HTTP11, HttpProtocol.H2, HttpProtocol.HTTP3 } + : new HttpProtocol[] { HttpProtocol.HTTP11, HttpProtocol.H2 }; } else { values = new HttpProtocol[] { HttpProtocol.HTTP11, HttpProtocol.H2C }; } diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 973721ebdf..92d87df46d 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -46,6 +46,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; +import java.util.function.BooleanSupplier; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; @@ -60,6 +61,7 @@ import org.apache.logging.log4j.Logger; import org.apache.lucene.search.QueryCachingPolicy; import org.apache.lucene.search.Weight; +import org.bouncycastle.crypto.CryptoServicesRegistrar; import org.bouncycastle.util.encoders.Hex; import org.opensearch.OpenSearchException; @@ -220,6 +222,7 @@ import org.opensearch.security.ssl.util.SSLConfigConstants; import org.opensearch.security.state.SecurityMetadata; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.GuardedSearchOperationWrapper; import org.opensearch.security.support.HeaderHelper; import org.opensearch.security.support.ModuleInfo; @@ -362,21 +365,40 @@ private boolean sslCertificatesHotReloadEnabled(final Settings settings) { return settings.getAsBoolean(SECURITY_SSL_CERTIFICATES_HOT_RELOAD_ENABLED, false); } - static void validateFipsMode(final String fipsModeEnvValue, final Settings settings) { - if ("true".equalsIgnoreCase(fipsModeEnvValue)) { + static void validateFipsMode(final Settings settings) { + validateFipsMode(settings, FipsMode.isEnabled(), CryptoServicesRegistrar::isInApprovedOnlyMode); + } + + static void validateFipsMode(final Settings settings, final boolean isFipsMode, final BooleanSupplier isInApprovedOnlyMode) { + if (isFipsMode) { + List violations = new ArrayList<>(); + + if (!isInApprovedOnlyMode.getAsBoolean()) { + violations.add( + "BCFIPS security provider is not running in FIPS approved-only mode. Ensure cluster is starting with '-Dorg.bouncycastle.fips.approved_only=true'" + ); + } + String hashingAlgorithm = settings.get( ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM_DEFAULT ); if (!ConfigConstants.PBKDF2.equalsIgnoreCase(hashingAlgorithm)) { - throw new IllegalStateException( - "FIPS mode is enabled (OPENSEARCH_FIPS_MODE=true) but password hashing algorithm is set to '" + violations.add( + "Password hashing algorithm is set to '" + hashingAlgorithm + "'. Only PBKDF2 is allowed in FIPS mode. Set '" + ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM + "' to 'pbkdf2'. Note: changing the hashing algorithm requires all existing passwords to be rehashed." ); } + + if (!violations.isEmpty()) { + throw new IllegalStateException( + "FIPS mode is enabled (OPENSEARCH_FIPS_MODE=true) but the following configuration issues were found:\n- " + + String.join("\n- ", violations) + ); + } } } @@ -512,7 +534,7 @@ public OpenSearchSecurityPlugin(final Settings settings, final Path configPath) ); } - validateFipsMode(System.getenv("OPENSEARCH_FIPS_MODE"), settings); + validateFipsMode(settings); if (!client && !settings.getAsBoolean(ConfigConstants.SECURITY_ALLOW_UNSAFE_DEMOCERTIFICATES, false)) { // check for demo certificates diff --git a/src/main/java/org/opensearch/security/auditlog/sink/ExternalOpenSearchSink.java b/src/main/java/org/opensearch/security/auditlog/sink/ExternalOpenSearchSink.java index 744e2ab46e..f3546433c3 100644 --- a/src/main/java/org/opensearch/security/auditlog/sink/ExternalOpenSearchSink.java +++ b/src/main/java/org/opensearch/security/auditlog/sink/ExternalOpenSearchSink.java @@ -155,7 +155,7 @@ public ExternalOpenSearchSink( ); } - effectiveKeyPassword = PemKeyReader.randomChars(12); + effectiveKeyPassword = SSLConfigConstants.DEFAULT_STORE_PASSWORD.toCharArray(); effectiveKeyAlias = "al"; effectiveTruststore = PemKeyReader.toTruststore(effectiveKeyAlias, trustCertificates); effectiveKeystore = PemKeyReader.toKeystore( diff --git a/src/main/java/org/opensearch/security/auth/http/jwt/keybyoidc/JwtVerifier.java b/src/main/java/org/opensearch/security/auth/http/jwt/keybyoidc/JwtVerifier.java index de2f47a3f3..c42e68c4e9 100644 --- a/src/main/java/org/opensearch/security/auth/http/jwt/keybyoidc/JwtVerifier.java +++ b/src/main/java/org/opensearch/security/auth/http/jwt/keybyoidc/JwtVerifier.java @@ -115,7 +115,7 @@ private JWSVerifier getInitializedSignatureVerifier(JWK key, SignedJWT jwt) thro } if (result == null) { - throw new BadCredentialsException("Cannot verify JWT"); + throw new BadCredentialsException("Cannot verify JWT - unsupported key type: " + key.getClass().getName()); } else { return result; } diff --git a/src/main/java/org/opensearch/security/auth/http/kerberos/HTTPSpnegoAuthenticator.java b/src/main/java/org/opensearch/security/auth/http/kerberos/HTTPSpnegoAuthenticator.java index ec10c74a1f..0fb5c8fbb5 100644 --- a/src/main/java/org/opensearch/security/auth/http/kerberos/HTTPSpnegoAuthenticator.java +++ b/src/main/java/org/opensearch/security/auth/http/kerberos/HTTPSpnegoAuthenticator.java @@ -19,6 +19,7 @@ import java.security.PrivilegedAction; import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.HashMap; @@ -27,6 +28,7 @@ import java.util.Optional; import java.util.Set; import javax.security.auth.Subject; +import javax.security.auth.login.LoginContext; import javax.security.auth.login.LoginException; import com.google.common.base.Strings; @@ -78,12 +80,7 @@ public HTTPSpnegoAuthenticator(final Settings settings, final Path configPath) { try { if (settings.getAsBoolean("krb_debug", false)) { JaasKrbUtil.setDebug(true); - System.setProperty("sun.security.krb5.debug", "true"); - System.setProperty("java.security.debug", "gssloginconfig,logincontext,configparser,configfile"); - System.setProperty("sun.security.spnego.debug", "true"); - log.info("Kerberos debug is enabled on stdout"); - } else { - log.debug("Kerberos debug is NOT enabled"); + log.info("Kerberos debug is enabled"); } } catch (Throwable e) { log.error("Unable to enable krb_debug due to ", e); @@ -181,14 +178,16 @@ private AuthCredentials extractCredentials0(final SecurityRequest request) { log.warn("No 'Negotiate Authorization' header, send 401 and 'WWW-Authenticate Negotiate'"); return null; } else { - final byte[] decodedNegotiateHeader = Base64.getDecoder().decode(authorizationHeader.substring(10)); - + byte[] decodedNegotiateHeader = null; GSSContext gssContext = null; + LoginContext loginContext = null; byte[] outToken = null; try { + decodedNegotiateHeader = Base64.getDecoder().decode(authorizationHeader.substring(10)); - final Subject subject = JaasKrbUtil.loginUsingKeytab(acceptorPrincipal, acceptorKeyTabPath, false); + loginContext = JaasKrbUtil.loginUsingKeytabWithContext(acceptorPrincipal, acceptorKeyTabPath, false); + final Subject subject = loginContext.getSubject(); final GSSManager manager = GSSManager.getInstance(); final int credentialLifetime = GSSCredential.INDEFINITE_LIFETIME; @@ -225,6 +224,9 @@ public GSSCredential run() throws GSSException { } return null; } finally { + if (decodedNegotiateHeader != null) { + Arrays.fill(decodedNegotiateHeader, (byte) 0); + } if (gssContext != null) { try { gssContext.dispose(); @@ -232,6 +234,7 @@ public GSSCredential run() throws GSSException { // Ignore } } + JaasKrbUtil.logout(loginContext); } if (principal == null) { @@ -241,10 +244,7 @@ public GSSCredential run() throws GSSException { final String username = ((SimpleUserPrincipal) principal).getName(); if (username == null || username.length() == 0) { - log.error( - "Got empty or null user from kerberos. Normally this means that you acceptor principal {} does not match the server hostname", - acceptorPrincipal - ); + log.error("Got empty or null user from kerberos. The configured acceptor principal may not match the server hostname"); } return new AuthCredentials(username, (Object) outToken).markComplete(); diff --git a/src/main/java/org/opensearch/security/auth/http/kerberos/util/JaasKrbUtil.java b/src/main/java/org/opensearch/security/auth/http/kerberos/util/JaasKrbUtil.java index 182eafe999..35c68900d5 100644 --- a/src/main/java/org/opensearch/security/auth/http/kerberos/util/JaasKrbUtil.java +++ b/src/main/java/org/opensearch/security/auth/http/kerberos/util/JaasKrbUtil.java @@ -27,11 +27,16 @@ import javax.security.auth.login.LoginContext; import javax.security.auth.login.LoginException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + /** * JAAS utilities for Kerberos login. */ public final class JaasKrbUtil { + private static final Logger log = LogManager.getLogger(JaasKrbUtil.class); + private static boolean debug = false; private JaasKrbUtil() {} @@ -40,8 +45,11 @@ public static void setDebug(final boolean debug) { JaasKrbUtil.debug = debug; } - public static Subject loginUsingKeytab(final Set principalAsStrings, final Path keytabPath, final boolean initiator) - throws LoginException { + public static LoginContext loginUsingKeytabWithContext( + final Set principalAsStrings, + final Path keytabPath, + final boolean initiator + ) throws LoginException { final Set principals = new HashSet(); for (String p : principalAsStrings) { @@ -54,7 +62,23 @@ public static Subject loginUsingKeytab(final Set principalAsStrings, fin final String confName = "KeytabConf"; final LoginContext loginContext = new LoginContext(confName, subject, null, conf); loginContext.login(); - return loginContext.getSubject(); + return loginContext; + } + + /** + * Safely logout and clean up a LoginContext. + * This method should be called in a 'finally' block to ensure credentials are cleared. + * + * @param loginContext The LoginContext to logout + */ + public static void logout(final LoginContext loginContext) { + if (loginContext != null) { + try { + loginContext.logout(); + } catch (LoginException e) { + log.debug("Logout failed, credentials may not have been fully cleared", e); + } + } } public static Configuration useKeytab(final String principal, final Path keytabPath, final boolean initiator) { diff --git a/src/main/java/org/opensearch/security/auth/internal/InternalAuthenticationBackend.java b/src/main/java/org/opensearch/security/auth/internal/InternalAuthenticationBackend.java index 6ccead820e..0d39ef030c 100644 --- a/src/main/java/org/opensearch/security/auth/internal/InternalAuthenticationBackend.java +++ b/src/main/java/org/opensearch/security/auth/internal/InternalAuthenticationBackend.java @@ -110,8 +110,7 @@ public User authenticate(AuthenticationContext context) { if (!internalUsersModel.exists(credentials.getUsername())) { userExists = false; password = credentials.getPassword(); - hash = "$2y$12$NmKhjNssNgSIj8iXT7SYxeXvMA1E95a9tCt4cySY9FrQ4fB18xEc2"; // Ensure the same cryptographic complexity for users not - // found and invalid password + hash = passwordHasher.getDummyHash(); // Ensure the same cryptographic complexity for users not found and invalid password } else { userExists = true; password = credentials.getPassword(); diff --git a/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthenticationBackend.java b/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthenticationBackend.java index 17000c7eb2..8f78528cfa 100755 --- a/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthenticationBackend.java +++ b/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthenticationBackend.java @@ -309,7 +309,7 @@ private static LdapEntry existsSearchingAllBases( ); if (isDebugEnabled) { - log.debug("Results for LDAP search for " + user + " in base " + entry.getKey() + ":\n" + result); + log.debug("Results for LDAP search for {} in base {}:\n{}", user, entry.getKey(), foundEntries); } if (foundEntries != null) { diff --git a/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthorizationBackend.java b/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthorizationBackend.java index 0dbf183fa4..fdd1540b8c 100755 --- a/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthorizationBackend.java +++ b/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthorizationBackend.java @@ -13,6 +13,8 @@ import java.io.FileNotFoundException; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.security.KeyStore; @@ -36,6 +38,8 @@ import javax.naming.ldap.LdapName; import com.google.common.collect.HashMultimap; +import com.google.common.collect.ImmutableList; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -48,7 +52,9 @@ import org.opensearch.security.auth.ldap.util.ConfigConstants; import org.opensearch.security.auth.ldap.util.LdapHelper; import org.opensearch.security.auth.ldap.util.Utils; +import org.opensearch.security.auth.ldap2.SNISettingTLSSocketFactory; import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.PemKeyReader; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; @@ -68,6 +74,7 @@ import org.ldaptive.SearchFilter; import org.ldaptive.SearchScope; import org.ldaptive.control.RequestControl; +import org.ldaptive.provider.ProviderConfig; import org.ldaptive.provider.ProviderConnection; import org.ldaptive.sasl.Mechanism; import org.ldaptive.sasl.SaslConfig; @@ -75,6 +82,7 @@ import org.ldaptive.ssl.AllowAnyTrustManager; import org.ldaptive.ssl.CredentialConfig; import org.ldaptive.ssl.CredentialConfigFactory; +import org.ldaptive.ssl.DefaultHostnameVerifier; import org.ldaptive.ssl.SslConfig; import org.ldaptive.ssl.ThreadLocalTLSSocketFactory; @@ -86,7 +94,9 @@ public class LDAPAuthorizationBackend implements AuthorizationBackend { private static final AtomicInteger CONNECTION_COUNTER = new AtomicInteger(); private static final String COM_SUN_JNDI_LDAP_OBJECT_DISABLE_ENDPOINT_IDENTIFICATION = "com.sun.jndi.ldap.object.disableEndpointIdentification"; - private static final List DEFAULT_TLS_PROTOCOLS = Arrays.asList("TLSv1.2", "TLSv1.1"); + private static final List DEFAULT_TLS_PROTOCOLS = FipsMode.isEnabled() + ? ImmutableList.of("TLSv1.2") + : ImmutableList.of("TLSv1.2", "TLSv1.1"); static final int ONE_PLACEHOLDER = 1; static final int TWO_PLACEHOLDER = 2; static final String DEFAULT_ROLEBASE = ""; @@ -183,7 +193,7 @@ private static void checkConnection0( byte[] password, final ClassLoader cl, final boolean needRestore - ) throws KeyStoreException, NoSuchAlgorithmException, CertificateException, FileNotFoundException, IOException, LdapException { + ) throws LdapException { Connection connection = null; @@ -201,9 +211,31 @@ private static void checkConnection0( config.setConnectionInitializer(new BindConnectionInitializer(bindDn, new Credential(password))); DefaultConnectionFactory connFactory = new DefaultConnectionFactory(config); - connection = connFactory.getConnection(); - connection.open(); + // Register custom socket factory for SNI hostname verification if SSL is enabled + String sniHostname = null; + boolean verifyHostname = shouldVerifyHostname(config.getSslConfig()); + if (config.getLdapUrl() != null && config.getLdapUrl().startsWith("ldaps:")) { + configureSNISocketFactory(connFactory); + String ldapUrl = config.getLdapUrl(); + try { + sniHostname = new URI(ldapUrl).getHost(); + if (StringUtils.isBlank(sniHostname)) { + log.warn("Could not extract hostname from LDAP URL '{}'; proceeding without SNI configuration", ldapUrl); + } + } catch (URISyntaxException e) { + log.warn("Malformed LDAP URL '{}'; proceeding without SNI configuration: {}", ldapUrl, e.getMessage()); + } + } + + try ( + SNISettingTLSSocketFactory.SniContext ignored = StringUtils.isBlank(sniHostname) + ? null + : SNISettingTLSSocketFactory.configure(sniHostname, verifyHostname) + ) { + connection = connFactory.getConnection(); + connection.open(); + } } finally { Utils.unbindAndCloseSilently(connection); connection = null; @@ -302,9 +334,21 @@ private static Connection getConnection0( } DefaultConnectionFactory connFactory = new DefaultConnectionFactory(config); - connection = connFactory.getConnection(); - connection.open(); + // Register custom socket factory for SNI hostname verification with BouncyCastle FIPS + // This addresses a known issue where JNDI LDAP doesn't pass hostname to SSLSocketFactory + // See: https://github.com/bcgit/bc-java/issues/460 + if (enableSSL) { + boolean verifyHostname = shouldVerifyHostname(config.getSslConfig()); + configureSNISocketFactory(connFactory); + try (var ignored = SNISettingTLSSocketFactory.configure(split[0], verifyHostname)) { + connection = connFactory.getConnection(); + connection.open(); + } + } else { + connection = connFactory.getConnection(); + connection.open(); + } if (connection != null && connection.isOpen()) { break; @@ -462,6 +506,19 @@ private static void restoreClassLoader0(final ClassLoader cl) { } } + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static boolean shouldVerifyHostname(SslConfig sslConf) { + return sslConf != null && !(sslConf.getHostnameVerifier() instanceof AllowAnyHostnameVerifier); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static void configureSNISocketFactory(DefaultConnectionFactory connFactory) { + Map props = new HashMap<>(); + props.put("java.naming.ldap.factory.socket", "org.opensearch.security.auth.ldap2.SNISettingTLSSocketFactory"); + final ProviderConfig providerConfig = connFactory.getProvider().getProviderConfig(); + providerConfig.setProperties(props); + } + private static void configureSSL(final ConnectionConfig config, final Settings settings, final Path configPath) throws Exception { final boolean isDebugEnabled = log.isDebugEnabled(); @@ -528,7 +585,21 @@ private static void configureSSL(final ConnectionConfig config, final Settings s ); } - cc = CredentialConfigFactory.createX509CredentialConfig(trustCertificates, authenticationCertificate, authenticationKey); + final KeyStore pemTruststore = PemKeyReader.toTruststore("al", trustCertificates); + final char[] pemInMemoryPassword = SSLConfigConstants.DEFAULT_STORE_PASSWORD.toCharArray(); + final KeyStore pemKeystore = PemKeyReader.toKeystore( + "al", + pemInMemoryPassword, + authenticationCertificate != null ? new X509Certificate[] { authenticationCertificate } : null, + authenticationKey + ); + cc = CredentialConfigFactory.createKeyStoreCredentialConfig( + pemTruststore, + null, + pemKeystore, + pemKeystore != null ? new String(pemInMemoryPassword) : null, + null + ); if (isDebugEnabled) { log.debug("Use PEM to secure communication with LDAP server (client auth is {})", authenticationKey != null); @@ -587,7 +658,9 @@ private static void configureSSL(final ConnectionConfig config, final Settings s sslConfig.setTrustManagers(new AllowAnyTrustManager()); } - if (!verifyHostnames) { + if (verifyHostnames) { + sslConfig.setHostnameVerifier(new DefaultHostnameVerifier()); + } else { sslConfig.setHostnameVerifier(new AllowAnyHostnameVerifier()); final String deiProp = System.getProperty(COM_SUN_JNDI_LDAP_OBJECT_DISABLE_ENDPOINT_IDENTIFICATION); @@ -606,7 +679,6 @@ private static void configureSSL(final ConnectionConfig config, final Settings s } System.setProperty(COM_SUN_JNDI_LDAP_OBJECT_DISABLE_ENDPOINT_IDENTIFICATION, "true"); - } final List enabledCipherSuites = settings.getAsList(ConfigConstants.LDAPS_ENABLED_SSL_CIPHERS, Collections.emptyList()); @@ -1173,12 +1245,14 @@ public Java9CL(ClassLoader parent) { @Override public Class loadClass(String name) throws ClassNotFoundException { - if (!name.equalsIgnoreCase("org.ldaptive.ssl.ThreadLocalTLSSocketFactory")) { - return super.loadClass(name); + if (name.equals("org.opensearch.security.auth.ldap2.SNISettingTLSSocketFactory")) { + return SNISettingTLSSocketFactory.class; + } + if (name.equalsIgnoreCase("org.ldaptive.ssl.ThreadLocalTLSSocketFactory")) { + return clazz; } - return clazz; + return super.loadClass(name); } - } } diff --git a/src/main/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactory.java b/src/main/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactory.java new file mode 100644 index 0000000000..1c1d9e11dd --- /dev/null +++ b/src/main/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactory.java @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import org.ldaptive.Connection; +import org.ldaptive.ConnectionFactory; +import org.ldaptive.LdapException; +import org.ldaptive.LdapURL; + +/** + * Wrapper around ConnectionFactory that extracts the hostname from the LDAP URL + * and stores it in ThreadLocal for use by SNISettingTLSSocketFactory. + * + *

This is necessary because JNDI LDAP resolves hostnames to IP addresses before + * creating SSL sockets, making the hostname unavailable for SNI configuration. + */ +public record HostnameAwareConnectionFactory(ConnectionFactory delegate, String ldapUrl, boolean verifyHostname) + implements + ConnectionFactory { + + @Override + public Connection getConnection() throws LdapException { + String hostname = new LdapURL(ldapUrl).getEntry().getHostname(); + try (var ignored = SNISettingTLSSocketFactory.configure(hostname, verifyHostname)) { + return delegate.getConnection(); + } + } +} diff --git a/src/main/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactory.java b/src/main/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactory.java index 124fc92da9..732239f38e 100644 --- a/src/main/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactory.java +++ b/src/main/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactory.java @@ -12,11 +12,13 @@ package org.opensearch.security.auth.ldap2; import java.nio.file.Path; +import java.security.GeneralSecurityException; import java.time.Duration; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.net.ssl.TrustManagerFactory; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -37,6 +39,7 @@ import org.ldaptive.Credential; import org.ldaptive.DefaultConnectionFactory; import org.ldaptive.LdapAttribute; +import org.ldaptive.LdapURL; import org.ldaptive.RandomConnectionStrategy; import org.ldaptive.ReturnAttributes; import org.ldaptive.RoundRobinConnectionStrategy; @@ -77,11 +80,58 @@ public LDAPConnectionFactoryFactory(Settings settings, Path configPath) throws S } public ConnectionFactory createConnectionFactory(ConnectionPool connectionPool) { + ConnectionFactory factory; if (connectionPool != null) { - return new PooledConnectionFactory(connectionPool); + factory = new PooledConnectionFactory(connectionPool); } else { - return createBasicConnectionFactory(); + factory = createHostnameAwareConnectionFactory(); } + + // PooledConnectionFactory wraps a pool whose connections are already hostname-aware + // Non-pooled factory is already wrapped by createHostnameAwareConnectionFactory() + return factory; + } + + /** + * Creates a connection factory wrapped with HostnameAwareConnectionFactory + * to ensure SNI hostname is set for each connection. + * This must be used for both pooled and non-pooled connections. + */ + private ConnectionFactory createHostnameAwareConnectionFactory() { + DefaultConnectionFactory basicFactory = createBasicConnectionFactory(); + String ldapUrl = getLdapUrlString(); + boolean verifyHostname = sslConfig != null && sslConfig.isHostnameVerificationEnabled(); + return new HostnameAwareConnectionFactory(basicFactory, ldapUrl, verifyHostname); + } + + /** + * Creates a DefaultConnectionFactory that sets hostname in ThreadLocal before each connection. + * This is needed for connection pools which require DefaultConnectionFactory (not just ConnectionFactory). + */ + private DefaultConnectionFactory createHostnameAwareDefaultConnectionFactory() { + final String ldapUrl = getLdapUrlString(); + + // Create a custom DefaultConnectionFactory that sets hostname before creating connections + DefaultConnectionFactory factory = new DefaultConnectionFactory(getConnectionConfig()) { + @Override + public Connection getConnection() { + String hostname = new LdapURL(ldapUrl).getEntry().getHostname(); + boolean verifyHostname = sslConfig != null && sslConfig.isHostnameVerificationEnabled(); + try (var ignored = SNISettingTLSSocketFactory.configure(hostname, verifyHostname)) { + return super.getConnection(); + } + } + }; + + @SuppressWarnings("unchecked") + Provider provider = (Provider) factory.getProvider(); + factory.setProvider(new PrivilegedProvider(provider)); + + if (this.sslConfig != null) { + configureSSLinConnectionFactory(factory); + } + + return factory; } @SuppressWarnings("unchecked") @@ -90,8 +140,6 @@ public DefaultConnectionFactory createBasicConnectionFactory() { result.setProvider(new PrivilegedProvider((Provider) result.getProvider())); - JndiProviderConfig jndiProviderConfig = (JndiProviderConfig) result.getProvider().getProviderConfig(); - if (this.sslConfig != null) { configureSSLinConnectionFactory(result); } @@ -120,10 +168,14 @@ public ConnectionPool createConnectionPool() { AbstractConnectionPool result; + // Use hostname-aware DefaultConnectionFactory for pool to ensure SNI is set for all connections + // including those created during pool initialization + DefaultConnectionFactory hostnameAwareFactory = createHostnameAwareDefaultConnectionFactory(); + if ("blocking".equals(this.settings.get(ConfigConstants.LDAP_POOL_TYPE))) { - result = new BlockingConnectionPool(poolConfig, createBasicConnectionFactory()); + result = new BlockingConnectionPool(poolConfig, hostnameAwareFactory); } else { - result = new SoftLimitConnectionPool(poolConfig, createBasicConnectionFactory()); + result = new SoftLimitConnectionPool(poolConfig, hostnameAwareFactory); } result.setValidator(getConnectionValidator()); @@ -301,7 +353,7 @@ private void configureSSL(ConnectionConfig config) { this.sslConfig.getEffectiveTruststoreAliasesArray(), this.sslConfig.getEffectiveKeystore(), this.sslConfig.getEffectiveKeyPasswordString(), - this.sslConfig.getEffectiveKeyAliasesArray() + null ); ldaptiveSslConfig.setCredentialConfig(cc); @@ -330,6 +382,14 @@ private void configureSSL(ConnectionConfig config) { if (this.sslConfig.isTrustAllEnabled()) { ldaptiveSslConfig.setTrustManagers(new AllowAnyTrustManager()); + } else { + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance("PKIX"); + tmf.init(this.sslConfig.getEffectiveTruststore()); + ldaptiveSslConfig.setTrustManagers(tmf.getTrustManagers()[0]); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to initialize PKIX TrustManager for LDAPS", e); + } } config.setSslConfig(ldaptiveSslConfig); @@ -352,6 +412,13 @@ private void configureSSLinConnectionFactory(DefaultConnectionFactory connection props.put("jndi.starttls.allowAnyHostname", "true"); } + // Register the custom socket factory with JNDI for SSL/TLS connections + // SNISettingTLSSocketFactory uses BouncyCastle's CustomSSLSocketFactory to properly + // set SNI hostname and endpoint identification for hostname verification. + // This addresses a known issue where JNDI LDAP doesn't pass hostname to SSLSocketFactory. + // See: https://github.com/bcgit/bc-java/issues/460 + props.put("java.naming.ldap.factory.socket", "org.opensearch.security.auth.ldap2.SNISettingTLSSocketFactory"); + connectionFactory.getProvider().getProviderConfig().setProperties(props); } diff --git a/src/main/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactory.java b/src/main/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactory.java new file mode 100644 index 0000000000..e7826ce688 --- /dev/null +++ b/src/main/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactory.java @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.util.Collections; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bouncycastle.util.IPAddress; + +import org.ldaptive.ssl.ThreadLocalTLSSocketFactory; + +/** + * Custom socket factory for LDAP connections that ensures SNI hostname is properly set + * for BouncyCastle JSSE provider hostname verification. + * + *

This addresses a known issue where JNDI LDAP's socket creation doesn't pass hostname + * information to the SSLSocketFactory, causing BouncyCastle's hostname verification to fail. + * @see https://github.com/bcgit/bc-java/issues/460 + * + *

The solution wraps the delegate SSLSocketFactory and intercepts all socket creation + * methods to set SNI parameters after socket creation but before the socket is returned to + * the caller. The hostname is provided via ThreadLocal by the connection factory before + * establishing the connection. + */ +public class SNISettingTLSSocketFactory extends SSLSocketFactory { + + private static final Logger log = LogManager.getLogger(SNISettingTLSSocketFactory.class); + + // ThreadLocal to store the hostname for the current LDAP connection + private static final ThreadLocal hostnameThreadLocal = new ThreadLocal<>(); + + // ThreadLocal to control whether endpoint identification should be enabled + private static final ThreadLocal verifyHostnameThreadLocal = ThreadLocal.withInitial(() -> false); + + private final SSLSocketFactory delegate; + + /** + * Required by JNDI to get the socket factory instance. + * This method is called by JNDI when java.naming.ldap.factory.socket is set. + */ + public static SSLSocketFactory getDefault() { + log.debug("SNISettingTLSSocketFactory.getDefault() called by JNDI"); + // Get the configured SSL socket factory from ldaptive's ThreadLocal + SSLSocketFactory delegate = (SSLSocketFactory) ThreadLocalTLSSocketFactory.getDefault(); + log.debug("Wrapping delegate factory: {}", delegate.getClass().getName()); + return new SNISettingTLSSocketFactory(delegate); + } + + /** + * A no-throw {@link AutoCloseable} returned by {@link #configure} for use in try-with-resources. + */ + @FunctionalInterface + public interface SniContext extends AutoCloseable { + @Override + void close(); + } + + /** + * Sets SNI hostname and endpoint identification flag for the current thread, returning an + * {@link SniContext} that clears both on close. Intended for use in try-with-resources: + * + *

{@code
+     * try (var ignored = SNISettingTLSSocketFactory.configure(hostname, verifyHostname)) {
+     *     connection.open();
+     * }
+     * }
+ */ + public static SniContext configure(String hostname, boolean verifyHostname) { + hostnameThreadLocal.set(hostname); + verifyHostnameThreadLocal.set(verifyHostname); + log.debug("Configured SNI context: hostname={}, verifyHostname={}", hostname, verifyHostname); + return SNISettingTLSSocketFactory::clearContext; + } + + static String getHostname() { + return hostnameThreadLocal.get(); + } + + static void clearContext() { + hostnameThreadLocal.remove(); + verifyHostnameThreadLocal.remove(); + } + + SNISettingTLSSocketFactory(SSLSocketFactory delegate) { + this.delegate = delegate; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException { + log.debug("createSocket(Socket, host={}, port={}) called", host, port); + Socket result = delegate.createSocket(socket, host, port, autoClose); + return configureSocket(result); + } + + @Override + public Socket createSocket(String host, int port) throws IOException { + log.debug("createSocket(host={}, port={}) called", host, port); + Socket result = delegate.createSocket(host, port); + return configureSocket(result); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { + log.debug("createSocket(host={}, port={}, localHost={}, localPort={}) called", host, port, localHost, localPort); + Socket result = delegate.createSocket(host, port, localHost, localPort); + return configureSocket(result); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + log.debug("createSocket(host={}, port={}) called", host, port); + Socket result = delegate.createSocket(host, port); + return configureSocket(result); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { + log.debug("createSocket(host={}, port={}, localAddress={}, localPort={}) called", address, port, localAddress, localPort); + Socket result = delegate.createSocket(address, port, localAddress, localPort); + return configureSocket(result); + } + + /** + * Configures SNI and hostname verification on a newly created socket. + * + * @param socket the created socket + * @return the configured socket + */ + protected Socket configureSocket(Socket socket) { + if (!(socket instanceof SSLSocket sslSocket)) { + log.debug("Socket is not an SSLSocket, skipping SNI configuration"); + return socket; + } + + String hostname = getHostname(); + + log.debug("Configuring SNI for socket, hostname: {}", hostname); + + if (hostname == null) { + log.warn("No hostname available for SNI configuration on socket: {}", socket.getClass().getName()); + return socket; + } + + SSLParameters params = sslSocket.getSSLParameters(); + if (verifyHostnameThreadLocal.get()) { + params.setEndpointIdentificationAlgorithm("LDAPS"); + } + + if (!IPAddress.isValid(hostname)) { + log.debug("Configuring SNI for hostname: {} on socket: {}", hostname, socket.getClass().getName()); + params.setServerNames(Collections.singletonList(new SNIHostName(hostname))); + } + + sslSocket.setSSLParameters(params); + log.debug("Successfully configured socket for: {}", hostname); + + return socket; + } + +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java index 4cd2ddab2a..13c68bb759 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java @@ -12,60 +12,73 @@ package org.opensearch.security.authtoken.jwt; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; import java.util.Arrays; import java.util.Base64; import javax.crypto.Cipher; import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; +import org.bouncycastle.crypto.KDFCalculator; +import org.bouncycastle.crypto.fips.FipsKDF; + public class EncryptionDecryptionUtil { - private final Cipher encryptCipher; - private final Cipher decryptCipher; + private static final byte[] HKDF_INFO = "opensearch-obo-jwt-encryption".getBytes(StandardCharsets.UTF_8); + private static final int GCM_NONCE_LENGTH = 12; // 96 bits, recommended for AES-GCM + private static final int GCM_TAG_LENGTH = 128; // bits + private static final String AES_GCM_NO_PADDING = "AES/GCM/NoPadding"; + + private final SecretKey aesKey; + private final SecureRandom secureRandom = new SecureRandom(); public EncryptionDecryptionUtil(final String secret) { - this.encryptCipher = createCipherFromSecret(secret, CipherMode.ENCRYPT); - this.decryptCipher = createCipherFromSecret(secret, CipherMode.DECRYPT); + this.aesKey = deriveKey(secret); } public String encrypt(final String data) { - byte[] encryptedBytes = processWithCipher(data.getBytes(StandardCharsets.UTF_8), encryptCipher); - return Base64.getEncoder().encodeToString(encryptedBytes); - } - - public String decrypt(final String encryptedString) { - byte[] decodedBytes = Base64.getDecoder().decode(encryptedString); - return new String(processWithCipher(decodedBytes, decryptCipher), StandardCharsets.UTF_8); - } - - private static Cipher createCipherFromSecret(final String secret, final CipherMode mode) { + byte[] plaintext = data.getBytes(StandardCharsets.UTF_8); try { - final byte[] decodedKey = Base64.getDecoder().decode(secret); - final Cipher cipher = Cipher.getInstance("AES"); - final SecretKey originalKey = new SecretKeySpec(Arrays.copyOf(decodedKey, 16), "AES"); - cipher.init(mode.opmode, originalKey); - return cipher; + byte[] nonce = new byte[GCM_NONCE_LENGTH]; + secureRandom.nextBytes(nonce); + Cipher cipher = Cipher.getInstance(AES_GCM_NO_PADDING); + cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH, nonce)); + byte[] ciphertext = cipher.doFinal(plaintext); + byte[] output = new byte[GCM_NONCE_LENGTH + ciphertext.length]; + System.arraycopy(nonce, 0, output, 0, GCM_NONCE_LENGTH); + System.arraycopy(ciphertext, 0, output, GCM_NONCE_LENGTH, ciphertext.length); + return Base64.getEncoder().encodeToString(output); } catch (final Exception e) { - throw new RuntimeException("Error creating cipher from secret in mode " + mode.name(), e); + throw new RuntimeException("Error processing data with cipher", e); } } - private static byte[] processWithCipher(final byte[] data, final Cipher cipher) { + public String decrypt(final String encryptedString) { + byte[] decodedBytes = Base64.getDecoder().decode(encryptedString); try { - return cipher.doFinal(data); + byte[] nonce = Arrays.copyOfRange(decodedBytes, 0, GCM_NONCE_LENGTH); + byte[] ciphertext = Arrays.copyOfRange(decodedBytes, GCM_NONCE_LENGTH, decodedBytes.length); + Cipher cipher = Cipher.getInstance(AES_GCM_NO_PADDING); + cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH, nonce)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); } catch (final Exception e) { throw new RuntimeException("Error processing data with cipher", e); } } - private enum CipherMode { - ENCRYPT(Cipher.ENCRYPT_MODE), - DECRYPT(Cipher.DECRYPT_MODE); - - private final int opmode; - - private CipherMode(final int opmode) { - this.opmode = opmode; + private static SecretKey deriveKey(final String secret) { + try { + final byte[] secretBytes = Base64.getDecoder().decode(secret); + FipsKDF.AgreementKDFParameters hkdfParams = FipsKDF.HKDF.withPRF(FipsKDF.AgreementKDFPRF.SHA256_HMAC) + .using(secretBytes) + .withIV(HKDF_INFO); // BC FIPS maps withIV → HKDF info (expand-phase context binding per RFC 5869) + KDFCalculator kdf = new FipsKDF.AgreementOperatorFactory().createKDFCalculator(hkdfParams); + byte[] derivedKey = new byte[32]; // 256 bits for AES-256 + kdf.generateBytes(derivedKey); + return new SecretKeySpec(derivedKey, "AES"); + } catch (final Exception e) { + throw new RuntimeException("Error deriving key from secret", e); } } } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index f1e493a7e3..420fd9365b 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -14,6 +14,7 @@ import java.text.ParseException; import java.util.Base64; import java.util.Date; +import javax.crypto.SecretKey; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -23,6 +24,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.secure_sm.AccessController; import org.opensearch.security.authtoken.jwt.claims.JwtClaimsBuilder; +import org.opensearch.security.util.KeyUtils; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -36,6 +38,7 @@ import com.nimbusds.jwt.SignedJWT; public class JwtVendor { + public static final String SIGNING_KEY_PROPERTY_KEY = "signing_key"; private static final Logger logger = LogManager.getLogger(JwtVendor.class); private final JWK signingKey; @@ -55,8 +58,12 @@ public JwtVendor(Settings settings) { * */ static Tuple createJwkFromSettings(final Settings settings) { final OctetSequenceKey key; - if (settings.get("signing_key") != null) { - final String signingKey = settings.get("signing_key"); + + final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, SIGNING_KEY_PROPERTY_KEY); + if (keystoreKey != null) { + key = new OctetSequenceKey.Builder(keystoreKey.getEncoded()).algorithm(JWSAlgorithm.HS512).keyUse(KeyUse.SIGNATURE).build(); + } else if (settings.get(SIGNING_KEY_PROPERTY_KEY) != null) { + final String signingKey = settings.get(SIGNING_KEY_PROPERTY_KEY); key = new OctetSequenceKey.Builder(Base64.getDecoder().decode(signingKey)).algorithm(JWSAlgorithm.HS512) .keyUse(KeyUse.SIGNATURE) .build(); diff --git a/src/main/java/org/opensearch/security/hasher/AbstractPasswordHasher.java b/src/main/java/org/opensearch/security/hasher/AbstractPasswordHasher.java index 151f657de1..5a33f84152 100644 --- a/src/main/java/org/opensearch/security/hasher/AbstractPasswordHasher.java +++ b/src/main/java/org/opensearch/security/hasher/AbstractPasswordHasher.java @@ -30,6 +30,20 @@ abstract class AbstractPasswordHasher implements PasswordHasher { */ HashingFunction hashingFunction; + private volatile String dummyHash; + + @Override + public String getDummyHash() { + if (dummyHash == null) { + synchronized (this) { + if (dummyHash == null) { + dummyHash = hash(new char[] { 'd', 'u', 'm', 'm', 'y', 'p', 'a', 's', 's', 'w', 'o', 'r', 'd', 'h', 'a', 's', 'h' }); + } + } + } + return dummyHash; + } + /** * {@inheritDoc} */ diff --git a/src/main/java/org/opensearch/security/hasher/PBKDF2PasswordHasher.java b/src/main/java/org/opensearch/security/hasher/PBKDF2PasswordHasher.java index d28d2a919a..0b311872ac 100644 --- a/src/main/java/org/opensearch/security/hasher/PBKDF2PasswordHasher.java +++ b/src/main/java/org/opensearch/security/hasher/PBKDF2PasswordHasher.java @@ -19,6 +19,8 @@ class PBKDF2PasswordHasher extends AbstractPasswordHasher { + static final String DEFAULT_ADMIN_PASSWORD = "admin@OpenSearch"; + private static final int DEFAULT_SALT_LENGTH = 128; PBKDF2PasswordHasher(String function, int iterations, int length) { @@ -40,14 +42,22 @@ public String hash(char[] password) { public boolean check(char[] password, String hash) { checkPasswordNotNullOrEmpty(password); checkHashNotNullOrEmpty(hash); + CharBuffer passwordBuffer = CharBuffer.wrap(password); try { return Password.check(passwordBuffer, hash).with(getPBKDF2FunctionFromHash(hash)); + } catch (Throwable e) { + return false; } finally { cleanup(passwordBuffer); } } + @Override + public String defaultAdminPassword() { + return DEFAULT_ADMIN_PASSWORD; + } + private HashingFunction getPBKDF2FunctionFromHash(String hash) { return CompressedPBKDF2Function.getInstanceFromHash(hash); } diff --git a/src/main/java/org/opensearch/security/hasher/PasswordHasher.java b/src/main/java/org/opensearch/security/hasher/PasswordHasher.java index b115feac58..b7276e4303 100644 --- a/src/main/java/org/opensearch/security/hasher/PasswordHasher.java +++ b/src/main/java/org/opensearch/security/hasher/PasswordHasher.java @@ -33,4 +33,24 @@ public interface PasswordHasher { * @return true if the password matches the hashed password, false otherwise */ boolean check(char[] password, String hashedPassword); + + /** + * Returns a dummy hash used for constant-time comparison when a user is not found, + * preventing timing-based user enumeration attacks. The hash is in the same format + * and uses the same parameters as hashes produced by this hasher. + * + * @return a valid dummy hash string for this hasher's algorithm + */ + String getDummyHash(); + + /** + * Returns the default admin password used to detect whether the shipped + * internal_users.yml has been left unmodified. Each implementation declares + * a password appropriate for its algorithm's security requirements. + * + * @return the default admin password for this hasher + */ + default String defaultAdminPassword() { + return "admin"; + } } diff --git a/src/main/java/org/opensearch/security/ssl/DefaultSecurityKeyStore.java b/src/main/java/org/opensearch/security/ssl/DefaultSecurityKeyStore.java index 867a40f3b4..8818c84599 100644 --- a/src/main/java/org/opensearch/security/ssl/DefaultSecurityKeyStore.java +++ b/src/main/java/org/opensearch/security/ssl/DefaultSecurityKeyStore.java @@ -88,11 +88,10 @@ import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_TRANSPORT_SERVER_KEYSTORE_KEYPASSWORD; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_TRANSPORT_SERVER_PEMKEY_PASSWORD; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_TRANSPORT_TRUSTSTORE_PASSWORD; +import static org.opensearch.security.ssl.util.SSLConfigConstants.DEFAULT_STORE_TYPE; public class DefaultSecurityKeyStore implements SecurityKeyStore { - private static final String DEFAULT_STORE_TYPE = "JKS"; - private void printJCEWarnings() { try { final int aesMaxKeyLength = Cipher.getMaxAllowedKeyLength("AES"); diff --git a/src/main/java/org/opensearch/security/ssl/OpenSearchSecuritySSLPlugin.java b/src/main/java/org/opensearch/security/ssl/OpenSearchSecuritySSLPlugin.java index 5a4780f875..393353e4d6 100644 --- a/src/main/java/org/opensearch/security/ssl/OpenSearchSecuritySSLPlugin.java +++ b/src/main/java/org/opensearch/security/ssl/OpenSearchSecuritySSLPlugin.java @@ -18,7 +18,6 @@ package org.opensearch.security.ssl; import java.nio.file.Path; -import java.security.Security; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -32,7 +31,6 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.opensearch.OpenSearchException; import org.opensearch.Version; @@ -224,8 +222,6 @@ protected OpenSearchSecuritySSLPlugin(final Settings settings, final Path config log.error("SSL not activated for http and/or transport."); } - tryAddSecurityProvider(); - this.sslSettingsManager = new SslSettingsManager(new Environment(settings, configPath)); } @@ -742,14 +738,4 @@ protected Settings migrateSettings(Settings settings) { public ThreadPool getThreadPool() { return this.threadPool; } - - private void tryAddSecurityProvider() { - AccessController.doPrivileged(() -> { - if (Security.getProvider("BCFIPS") == null) { - Security.addProvider(new BouncyCastleFipsProvider()); - log.debug("Bouncy Castle FIPS Provider added"); - } - return null; - }); - } } diff --git a/src/main/java/org/opensearch/security/ssl/config/KeyStoreConfiguration.java b/src/main/java/org/opensearch/security/ssl/config/KeyStoreConfiguration.java index aed7c9b4c6..2713c1a2e1 100644 --- a/src/main/java/org/opensearch/security/ssl/config/KeyStoreConfiguration.java +++ b/src/main/java/org/opensearch/security/ssl/config/KeyStoreConfiguration.java @@ -119,17 +119,19 @@ public List loadCertificates() { } final var list = listBuilder.build(); if (list.isEmpty()) { - throw new OpenSearchException("The file " + path + " does not contain any certificates"); + throw new OpenSearchException( + "The keystore " + (path != null ? path : "(PKCS#11 token)") + " does not contain any certificates" + ); } return listBuilder.build(); } catch (GeneralSecurityException e) { - throw new OpenSearchException("Couldn't load certificates from file " + path, e); + throw new OpenSearchException("Couldn't load certificates from " + (path != null ? path : "PKCS#11 token"), e); } } @Override public List files() { - return List.of(path); + return path != null ? List.of(path) : List.of(); } @Override diff --git a/src/main/java/org/opensearch/security/ssl/config/KeyStoreUtils.java b/src/main/java/org/opensearch/security/ssl/config/KeyStoreUtils.java index 5f7e6c0fe8..2af2c0aadb 100644 --- a/src/main/java/org/opensearch/security/ssl/config/KeyStoreUtils.java +++ b/src/main/java/org/opensearch/security/ssl/config/KeyStoreUtils.java @@ -212,14 +212,18 @@ public static void validateKeyStoreCertificates(final KeyStore keyStore, Set loadConfiguration(final Environment environment) { final var settings = environment.settings(); final var sslConfigSettings = settings.getByPrefix(fullSslConfigSuffix); - if (settings.hasValue(sslConfigSuffix + KEYSTORE_FILEPATH)) { + final boolean isPkcs11Keystore = PemKeyReader.PKCS11.equalsIgnoreCase(environment.settings().get(sslConfigSuffix + KEYSTORE_TYPE)); + final boolean isPkcs11Truststore = PemKeyReader.PKCS11.equalsIgnoreCase( + environment.settings().get(sslConfigSuffix + TRUSTSTORE_TYPE) + ); + if (settings.hasValue(sslConfigSuffix + KEYSTORE_FILEPATH) || isPkcs11Keystore) { final var keyStorePassword = resolvePassword(sslConfigSuffix + KEYSTORE_PASSWORD, settings, DEFAULT_STORE_PASSWORD); return Tuple.tuple( - environment.settings().hasValue(sslConfigSuffix + TRUSTSTORE_FILEPATH) + environment.settings().hasValue(sslConfigSuffix + TRUSTSTORE_FILEPATH) || isPkcs11Truststore ? buildJdkTrustStoreConfiguration( sslConfigSettings, environment, @@ -126,9 +130,10 @@ private KeyStoreConfiguration.JdkKeyStoreConfiguration buildJdkKeyStoreConfigura final char[] keyStorePassword, final char[] keyPassword ) { - final Path path = resolvePath(environment.settings().get(sslConfigSuffix + KEYSTORE_FILEPATH), environment); final String explicitType = environment.settings().get(sslConfigSuffix + KEYSTORE_TYPE); - final String resolvedType = PemKeyReader.extractStoreType(path.toString(), explicitType); + final boolean isPkcs11 = PemKeyReader.PKCS11.equalsIgnoreCase(explicitType); + final Path path = isPkcs11 ? null : resolvePath(environment.settings().get(sslConfigSuffix + KEYSTORE_FILEPATH), environment); + final String resolvedType = isPkcs11 ? PemKeyReader.PKCS11 : PemKeyReader.extractStoreType(path.toString(), explicitType); return new KeyStoreConfiguration.JdkKeyStoreConfiguration( path, resolvedType, @@ -143,9 +148,10 @@ private TrustStoreConfiguration.JdkTrustStoreConfiguration buildJdkTrustStoreCon final Environment environment, final char[] trustStorePassword ) { - final Path path = resolvePath(environment.settings().get(sslConfigSuffix + TRUSTSTORE_FILEPATH), environment); final String explicitType = environment.settings().get(sslConfigSuffix + TRUSTSTORE_TYPE); - final String resolvedType = PemKeyReader.extractStoreType(path.toString(), explicitType); + final boolean isPkcs11 = PemKeyReader.PKCS11.equalsIgnoreCase(explicitType); + final Path path = isPkcs11 ? null : resolvePath(environment.settings().get(sslConfigSuffix + TRUSTSTORE_FILEPATH), environment); + final String resolvedType = isPkcs11 ? PemKeyReader.PKCS11 : PemKeyReader.extractStoreType(path.toString(), explicitType); return new TrustStoreConfiguration.JdkTrustStoreConfiguration( path, resolvedType, diff --git a/src/main/java/org/opensearch/security/ssl/config/TrustStoreConfiguration.java b/src/main/java/org/opensearch/security/ssl/config/TrustStoreConfiguration.java index 62b4a707a7..7c3d42f350 100644 --- a/src/main/java/org/opensearch/security/ssl/config/TrustStoreConfiguration.java +++ b/src/main/java/org/opensearch/security/ssl/config/TrustStoreConfiguration.java @@ -112,11 +112,13 @@ public List loadCertificates() { } final var list = listBuilder.build(); if (list.isEmpty()) { - throw new OpenSearchException("The file " + path + " does not contain any certificates"); + throw new OpenSearchException( + "The truststore " + (path != null ? path : "(PKCS#11 token)") + " does not contain any certificates" + ); } return listBuilder.build(); } catch (GeneralSecurityException e) { - throw new OpenSearchException("Couldn't load certificates from file " + path, e); + throw new OpenSearchException("Couldn't load certificates from " + (path != null ? path : "PKCS#11 token"), e); } } diff --git a/src/main/java/org/opensearch/security/ssl/util/SSLConfigConstants.java b/src/main/java/org/opensearch/security/ssl/util/SSLConfigConstants.java index 689c41238e..f1dfbbb741 100644 --- a/src/main/java/org/opensearch/security/ssl/util/SSLConfigConstants.java +++ b/src/main/java/org/opensearch/security/ssl/util/SSLConfigConstants.java @@ -17,18 +17,15 @@ package org.opensearch.security.ssl.util; -import java.security.KeyStore; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Locale; import java.util.function.Function; -import org.bouncycastle.crypto.CryptoServicesRegistrar; - import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Settings; import org.opensearch.security.ssl.config.CertType; +import org.opensearch.security.support.FipsMode; import io.netty.handler.ssl.ClientAuth; @@ -38,7 +35,9 @@ public final class SSLConfigConstants { */ public static final String DEFAULT_STORE_PASSWORD = "changeit"; // #16 public static final String JDK_TLS_REJECT_CLIENT_INITIATED_RENEGOTIATION = "jdk.tls.rejectClientInitiatedRenegotiation"; - public static final String[] ALLOWED_SSL_PROTOCOLS = { "TLSv1.3", "TLSv1.2", "TLSv1.1" }; + public static final String[] ALLOWED_SSL_PROTOCOLS = FipsMode.isEnabled() + ? new String[] { "TLSv1.3", "TLSv1.2" } + : new String[] { "TLSv1.3", "TLSv1.2", "TLSv1.1" }; /** * Shared settings prefixes/postfixes @@ -46,9 +45,9 @@ public final class SSLConfigConstants { public static final String ENABLED = "enabled"; public static final String CLIENT_AUTH_MODE = "clientauth_mode"; public static final String ENFORCE_CERT_RELOAD_DN_VERIFICATION = "enforce_cert_reload_dn_verification"; - public static final String DEFAULT_STORE_TYPE = CryptoServicesRegistrar.isInApprovedOnlyMode() - ? "BCFKS" - : KeyStore.getDefaultType().toUpperCase(Locale.ROOT); + public static final String DEFAULT_STORE_TYPE = FipsMode.isEnabled() + ? "BCFKS" // PKCS#11 must remain opt-in via explicit config + : "JKS"; public static final String SSL_PREFIX = "plugins.security.ssl."; public static final String KEYSTORE_TYPE = "keystore_type"; diff --git a/src/main/java/org/opensearch/security/ssl/util/SSLRequestHelper.java b/src/main/java/org/opensearch/security/ssl/util/SSLRequestHelper.java index 2dd4bef9c4..a0b855b15c 100644 --- a/src/main/java/org/opensearch/security/ssl/util/SSLRequestHelper.java +++ b/src/main/java/org/opensearch/security/ssl/util/SSLRequestHelper.java @@ -35,6 +35,7 @@ import java.util.Date; import java.util.Enumeration; import java.util.HashSet; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.net.ssl.SSLEngine; @@ -43,7 +44,6 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.bouncycastle.crypto.CryptoServicesRegistrar; import org.opensearch.OpenSearchException; import org.opensearch.common.settings.Settings; @@ -55,6 +55,7 @@ import org.opensearch.security.ssl.transport.PrincipalExtractor.Type; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_HTTP_TRUSTSTORE_PASSWORD; +import static org.opensearch.security.ssl.util.SSLConfigConstants.DEFAULT_STORE_TYPE; public class SSLRequestHelper { @@ -245,12 +246,19 @@ static CertificateValidator buildValidatorFromTruststore( final Collection crls, final String truststore ) throws IOException, GeneralSecurityException { - final String defaultStoreType = CryptoServicesRegistrar.isInApprovedOnlyMode() ? "BCFKS" : "JKS"; + final String defaultStoreType = DEFAULT_STORE_TYPE; final String truststoreType = settings.get(SSLConfigConstants.SECURITY_SSL_HTTP_TRUSTSTORE_TYPE, defaultStoreType); - final String truststorePassword = SECURITY_SSL_HTTP_TRUSTSTORE_PASSWORD.getSetting(settings); + final char[] truststorePassword = Optional.ofNullable(SECURITY_SSL_HTTP_TRUSTSTORE_PASSWORD.getSetting(settings)) + .filter(p -> !p.isEmpty()) + .map(String::toCharArray) + .orElse(null); final KeyStore ts = KeyStore.getInstance(truststoreType); - try (FileInputStream fin = new FileInputStream(env.configDir().resolve(truststore).toAbsolutePath().toString())) { - ts.load(fin, (truststorePassword == null || truststorePassword.isEmpty()) ? null : truststorePassword.toCharArray()); + if ("PKCS11".equalsIgnoreCase(truststoreType)) { + ts.load(null, truststorePassword); + } else { + try (final var fin = new FileInputStream(env.configDir().resolve(truststore).toAbsolutePath().toString())) { + ts.load(fin, truststorePassword); + } } return new CertificateValidator(trustAnchorsFrom(ts), crls); } diff --git a/src/main/java/org/opensearch/security/support/FipsMode.java b/src/main/java/org/opensearch/security/support/FipsMode.java new file mode 100644 index 0000000000..b612cfc163 --- /dev/null +++ b/src/main/java/org/opensearch/security/support/FipsMode.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.support; + +/** + * Single source of truth for FIPS mode detection. + * Set {@code OPENSEARCH_FIPS_MODE=true} in the environment to enable. + */ +public final class FipsMode { + + static java.util.function.Supplier envSupplier = () -> System.getenv("OPENSEARCH_FIPS_MODE"); + + public static boolean isEnabled() { + return "true".equalsIgnoreCase(envSupplier.get()); + } + + private FipsMode() { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/org/opensearch/security/support/PemKeyReader.java b/src/main/java/org/opensearch/security/support/PemKeyReader.java index e99ee5161c..0057c8e4a2 100644 --- a/src/main/java/org/opensearch/security/support/PemKeyReader.java +++ b/src/main/java/org/opensearch/security/support/PemKeyReader.java @@ -39,27 +39,19 @@ import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; +import java.security.GeneralSecurityException; +import java.security.Key; import java.security.KeyException; -import java.security.KeyFactory; import java.security.KeyStore; -import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; -import java.security.SecureRandom; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; import java.util.Collection; import java.util.Locale; -import javax.crypto.Cipher; -import javax.crypto.EncryptedPrivateKeyInfo; -import javax.crypto.NoSuchPaddingException; +import java.util.Optional; +import java.util.stream.Stream; import javax.crypto.SecretKey; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -67,9 +59,15 @@ import org.bouncycastle.asn1.ASN1InputStream; import org.bouncycastle.asn1.ASN1Integer; import org.bouncycastle.asn1.ASN1Sequence; -import org.bouncycastle.crypto.CryptoServicesRegistrar; -import org.bouncycastle.util.io.pem.PemObject; -import org.bouncycastle.util.io.pem.PemReader; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMException; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; +import org.bouncycastle.pkcs.PKCSException; import org.opensearch.OpenSearchException; import org.opensearch.common.settings.Settings; @@ -82,89 +80,58 @@ public final class PemKeyReader { private static final Logger log = LogManager.getLogger(PemKeyReader.class); public static final String JKS = "JKS"; + public static final String JCEKS = "JCEKS"; public static final String PKCS12 = "PKCS12"; public static final String BCFKS = "BCFKS"; + public static final String PKCS11 = "PKCS11"; - private static byte[] readPrivateKey(File file) throws KeyException { - try (final InputStream in = new FileInputStream(file)) { - return readPrivateKey(in); - } catch (final IOException e) { - throw new KeyException("could not fine key file: " + file); - } - } - - private static byte[] readPrivateKey(final InputStream in) throws KeyException { - try (final PemReader pemReader = new PemReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { - final PemObject pemObject = pemReader.readPemObject(); - if (pemObject == null) { - throw new KeyException( - "could not find a PKCS #8 private key in input stream" - + " (see http://netty.io/wiki/sslcontextbuilder-and-private-key.html for more information)" - ); - } - return pemObject.getContent(); - } catch (final IOException ioe) { - throw new KeyException( - "could not find a PKCS #8 private key in input stream" - + " (see http://netty.io/wiki/sslcontextbuilder-and-private-key.html for more information)", - ioe - ); - } - } - - public static PrivateKey toPrivateKey(File keyFile, String keyPassword) throws NoSuchAlgorithmException, NoSuchPaddingException, - InvalidKeySpecException, InvalidAlgorithmParameterException, KeyException, IOException { + public static PrivateKey toPrivateKey(File keyFile, String keyPassword) throws KeyException, IOException { if (keyFile == null) { return null; } - return getPrivateKeyFromByteBuffer(PemKeyReader.readPrivateKey(keyFile), keyPassword); + try (InputStream in = new FileInputStream(keyFile)) { + return toPrivateKey(in, keyPassword); + } } - public static PrivateKey toPrivateKey(InputStream in, String keyPassword) throws NoSuchAlgorithmException, NoSuchPaddingException, - InvalidKeySpecException, InvalidAlgorithmParameterException, KeyException, IOException { + public static PrivateKey toPrivateKey(InputStream in, String keyPassword) throws KeyException, IOException { if (in == null) { return null; } - return getPrivateKeyFromByteBuffer(PemKeyReader.readPrivateKey(in), keyPassword); - } - - private static PrivateKey getPrivateKeyFromByteBuffer(byte[] encodedKey, String keyPassword) throws NoSuchAlgorithmException, - NoSuchPaddingException, InvalidKeySpecException, InvalidAlgorithmParameterException, KeyException, IOException { - - PKCS8EncodedKeySpec encodedKeySpec = generateKeySpec(keyPassword == null ? null : keyPassword.toCharArray(), encodedKey); - try { - return KeyFactory.getInstance("RSA").generatePrivate(encodedKeySpec); - } catch (InvalidKeySpecException ignore) { - try { - return KeyFactory.getInstance("DSA").generatePrivate(encodedKeySpec); - } catch (InvalidKeySpecException ignore2) { - try { - return KeyFactory.getInstance("EC").generatePrivate(encodedKeySpec); - } catch (InvalidKeySpecException e) { - throw new InvalidKeySpecException("Neither RSA, DSA nor EC worked", e); + try (PEMParser parser = new PEMParser(new InputStreamReader(in, StandardCharsets.UTF_8))) { + Object obj = parser.readObject(); + if (obj == null) { + throw new KeyException( + "could not find a PKCS #8 private key in input stream" + + " (see http://netty.io/wiki/sslcontextbuilder-and-private-key.html for more information)" + ); + } + PrivateKeyInfo pki; + switch (obj) { + case PKCS8EncryptedPrivateKeyInfo pkcs8EncryptedPrivateKeyInfo -> { + if (keyPassword == null) { + throw new KeyException("private key is encrypted but no password was provided"); + } + try { + pki = pkcs8EncryptedPrivateKeyInfo.decryptPrivateKeyInfo( + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(keyPassword.toCharArray()) + ); + } catch (PKCSException | OperatorCreationException e) { + throw new KeyException("Failed to decrypt private key", e); + } } + case PrivateKeyInfo privateKeyInfo -> pki = privateKeyInfo; + case PEMKeyPair pemKeyPair -> pki = pemKeyPair.getPrivateKeyInfo(); + default -> throw new KeyException("Unexpected PEM object type: " + obj.getClass().getName()); + } + try { + return new JcaPEMKeyConverter().getPrivateKey(pki); + } catch (PEMException e) { + throw new KeyException("Failed to convert private key", e); } } } - private static PKCS8EncodedKeySpec generateKeySpec(char[] password, byte[] key) throws IOException, NoSuchAlgorithmException, - NoSuchPaddingException, InvalidKeySpecException, InvalidKeyException, InvalidAlgorithmParameterException { - - if (password == null) { - return new PKCS8EncodedKeySpec(key); - } - - EncryptedPrivateKeyInfo encryptedPrivateKeyInfo = new EncryptedPrivateKeyInfo(key); - SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(encryptedPrivateKeyInfo.getAlgName()); - PBEKeySpec pbeKeySpec = new PBEKeySpec(password); - SecretKey pbeKey = keyFactory.generateSecret(pbeKeySpec); - - Cipher cipher = Cipher.getInstance(encryptedPrivateKeyInfo.getAlgName()); - cipher.init(Cipher.DECRYPT_MODE, pbeKey, encryptedPrivateKeyInfo.getAlgParameters()); - - return encryptedPrivateKeyInfo.getKeySpec(cipher); - } - public static X509Certificate loadCertificateFromFile(String file) throws Exception { if (file == null) { return null; @@ -186,16 +153,81 @@ public static X509Certificate loadCertificateFromStream(InputStream in) throws E } public static KeyStore loadKeyStore(final String storePath, final String keyStorePassword, final String type) throws Exception { - if (storePath == null) { + if (storePath == null && !PKCS11.equalsIgnoreCase(type)) { return null; } String storeType = extractStoreType(storePath, type); - - final KeyStore store = KeyStore.getInstance(storeType); - store.load(new FileInputStream(storePath), keyStorePassword == null ? null : keyStorePassword.toCharArray()); + final char[] password = keyStorePassword == null ? null : keyStorePassword.toCharArray(); + final KeyStore store; + if (PKCS11.equalsIgnoreCase(storeType)) { + try { + store = KeyStore.getInstance(storeType); + store.load(null, password); + } catch (Exception e) { + throw new OpenSearchException( + "Failed to initialize PKCS#11 keystore. Ensure a PKCS#11 provider is registered and configured " + + "(e.g. SunPKCS11, IBMPKCS11Impl, or your HSM vendor's provider).", + e + ); + } + } else { + store = KeyStore.getInstance(storeType); + try (final var in = new FileInputStream(storePath)) { + store.load(in, password); + } + } return store; } + /** + * Loads a {@link SecretKey} entry from a keystore by alias. + * Delegates to {@link #loadKeyStore}, so FIPS-mode type enforcement (BCFKS / PKCS11 only) applies automatically. + * + * @param keyPassword key-level password; falls back to {@code keyStorePassword} when {@code null}, + * consistent with {@code keytool} convention where both passwords default to the same value + * @throws IllegalArgumentException for any failure: keystore I/O error, missing alias, or wrong entry type + */ + public static SecretKey loadSecretKeyFromKeystore( + final String storePath, + final String keyStorePassword, + final String type, + final String alias, + final String keyPassword + ) { + final KeyStore store; + try { + store = loadKeyStore(storePath, keyStorePassword, type); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to load secret key from keystore-type '%s' at path '%s'".formatted(type, storePath), + e + ); + } + if (store == null) { + throw new IllegalArgumentException("Failed to load secret key from keystore-type '%s' at path '%s'".formatted(type, storePath)); + } + try { + final char[] kp = keyPassword != null + ? keyPassword.toCharArray() + : (keyStorePassword != null ? keyStorePassword.toCharArray() : null); + final Key key = store.getKey(alias, kp); + if (key == null) { + throw new IllegalArgumentException("No key found at alias '" + alias + "' in keystore"); + } + if (!(key instanceof SecretKey secretKey)) { + throw new IllegalArgumentException( + "Entry at alias '" + alias + "' is not a SecretKey (found " + key.getClass().getName() + ")" + ); + } + return secretKey; + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException( + "Failed to load secret key from keystore-type '%s' at path '%s'".formatted(type, storePath), + e + ); + } + } + public static PrivateKey loadKeyFromFile(String password, String keyFile) throws Exception { if (keyFile == null) { @@ -345,25 +377,16 @@ public static KeyStore toKeystore( } - public static char[] randomChars(int len) { - final SecureRandom r = new SecureRandom(); - final char[] ret = new char[len]; - for (int i = 0; i < len; i++) { - ret[i] = (char) (r.nextInt(26) + 'a'); - } - return ret; - } - public static String extractStoreType(String storePath, String storeType) { - if (null == storeType) { - storeType = detectStoreType(storePath); - } - if (CryptoServicesRegistrar.isInApprovedOnlyMode() && !PemKeyReader.BCFKS.equalsIgnoreCase(storeType)) { + var finalStoreType = Optional.ofNullable(storeType).orElseGet(() -> detectStoreType(storePath)); + var isFipsSupportedStoreType = Stream.of(PKCS11, BCFKS).anyMatch(it -> it.equalsIgnoreCase(finalStoreType)); + + if (FipsMode.isEnabled() && !isFipsSupportedStoreType) { throw new IllegalArgumentException( - storeType.toUpperCase(Locale.ROOT) + " keystores / truststores are not supported in FIPS mode - use BCFKS." + finalStoreType.toUpperCase(Locale.ROOT) + " keystores / truststores are not supported in FIPS mode - use BCFKS or PKCS#11" ); } - return storeType; + return finalStoreType; } private static String detectStoreType(String path) { @@ -381,6 +404,14 @@ private static String detectStoreType(String path) { ) { return PemKeyReader.JKS; } + // JCEKS: 0xCECECECE + if ((magic[0] & 0xFF) == 0xCE // + && (magic[1] & 0xFF) == 0xCE // + && (magic[2] & 0xFF) == 0xCE // + && (magic[3] & 0xFF) == 0xCE // + ) { + return PemKeyReader.JCEKS; + } // ASN.1: distinguish BCFKS from PKCS12 by outer structure // PKCS12 (RFC 7292): outer SEQUENCE starts with INTEGER (version = 3) // BCFKS: outer SEQUENCE starts with SEQUENCE (encrypted content envelope) diff --git a/src/main/java/org/opensearch/security/tools/SecurityAdmin.java b/src/main/java/org/opensearch/security/tools/SecurityAdmin.java index 066ff3d547..c90cd3b981 100644 --- a/src/main/java/org/opensearch/security/tools/SecurityAdmin.java +++ b/src/main/java/org/opensearch/security/tools/SecurityAdmin.java @@ -41,7 +41,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Paths; import java.security.KeyStore; +import java.security.Principal; import java.security.PrivateKey; +import java.security.Provider; +import java.security.Security; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.text.SimpleDateFormat; @@ -51,8 +54,12 @@ import java.util.Locale; import java.util.Map; import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedKeyManager; import com.google.common.base.Charsets; import com.google.common.base.Joiner; @@ -191,17 +198,27 @@ public static int execute(final String[] args) throws Exception { Options options = new Options(); options.addOption("nhnv", "disable-host-name-verification", false, "Disable hostname verification"); options.addOption( - Option.builder("ts").longOpt("truststore").hasArg().argName("file").desc("Path to truststore (JKS/PKCS12 format)").build() + Option.builder("ts") + .longOpt("truststore") + .hasArg() + .argName("file") + .desc("Path to truststore (JKS/PKCS12/BCFKS/PKCS11 format)") + .build() ); options.addOption( - Option.builder("ks").longOpt("keystore").hasArg().argName("file").desc("Path to keystore (JKS/PKCS12 format").build() + Option.builder("ks") + .longOpt("keystore") + .hasArg() + .argName("file") + .desc("Path to keystore (JKS/PKCS12/BCFKS/PKCS11 format") + .build() ); options.addOption( Option.builder("tst") .longOpt("truststore-type") .hasArg() .argName("type") - .desc("JKS or PKCS12, if not given we use the file extension to detect the type") + .desc("JKS, PKCS12, BCFKS, or PKCS11; if not given the type is auto-detected from the file") .build() ); options.addOption( @@ -209,7 +226,7 @@ public static int execute(final String[] args) throws Exception { .longOpt("keystore-type") .hasArg() .argName("type") - .desc("JKS or PKCS12, if not given we use the file extension to detect the type") + .desc("JKS, PKCS12, BCFKS, or PKCS11; if not given the type is auto-detected from the file") .build() ); options.addOption( @@ -507,6 +524,8 @@ public static int execute(final String[] args) throws Exception { if (kspass == null && promptForPassword) { kspass = promptForPassword("Keystore", "kspass", OPENDISTRO_SECURITY_KS_PASS); } + } else if ("PKCS11".equalsIgnoreCase(kst) && kspass == null && promptForPassword) { + kspass = promptForPassword("PKCS#11 token PIN", "kspass", OPENDISTRO_SECURITY_KS_PASS); } if (ts != null) { @@ -514,6 +533,8 @@ public static int execute(final String[] args) throws Exception { if (tspass == null && promptForPassword) { tspass = promptForPassword("Truststore", "tspass", OPENDISTRO_SECURITY_TS_PASS); } + } else if ("PKCS11".equalsIgnoreCase(tst) && tspass == null && promptForPassword) { + tspass = promptForPassword("PKCS#11 token PIN", "tspass", OPENDISTRO_SECURITY_TS_PASS); } if (key != null) { @@ -1223,12 +1244,34 @@ private static void validate(CommandLine line) throws ParseException { throw new ParseException("Specify at least -cd or -f together with vc"); } - if (!line.hasOption("vc") && !line.hasOption("ks") && !line.hasOption("cert") /*&& !line.hasOption("simple-auth")*/) { - throw new ParseException("Specify at least -ks or -cert"); + boolean pkcs11Keystore = "PKCS11".equalsIgnoreCase(line.getOptionValue("kst")); + if (!line.hasOption("vc") && !line.hasOption("ks") && !line.hasOption("cert") && !pkcs11Keystore) { + throw new ParseException("Specify at least -ks, -cert, or -kst PKCS11"); + } + if (pkcs11Keystore && line.hasOption("ks")) { + throw new ParseException( + "Do not specify -ks together with -kst PKCS11; PKCS11 keystores are token-based and have no file path" + ); + } + if (pkcs11Keystore && Security.getProviders("KeyStore.PKCS11") == null) { + throw new ParseException("No PKCS#11 provider is registered in the JVM; cannot use -kst PKCS11"); } - if (!line.hasOption("vc") && !line.hasOption("mo") && !line.hasOption("ts") && !line.hasOption("cacert")) { - throw new ParseException("Specify at least -ts or -cacert"); + boolean pkcs11Truststore = "PKCS11".equalsIgnoreCase(line.getOptionValue("tst")); + if (!line.hasOption("vc") && !line.hasOption("ts") && !line.hasOption("cacert") && !pkcs11Truststore) { + throw new ParseException("Specify at least -ts, -cacert, or -tst PKCS11"); + } + if (pkcs11Truststore && line.hasOption("ts")) { + throw new ParseException( + "Do not specify -ts together with -tst PKCS11; PKCS11 truststores are token-based and have no file path" + ); + } + if (pkcs11Truststore && Security.getProviders("KeyStore.PKCS11") == null) { + throw new ParseException("No PKCS#11 provider is registered in the JVM; cannot use -tst PKCS11"); + } + + if (line.hasOption("cert") != line.hasOption("key")) { + throw new ParseException("-cert and -key must both be specified together"); } // TODO add more validation rules @@ -1515,32 +1558,37 @@ private static SSLContext sslContext( String keypass ) throws Exception { + if ("PKCS11".equalsIgnoreCase(keyStoreType)) { + final char[] kspassChars = kspass == null ? null : kspass.toCharArray(); + return buildPkcs11SslContext( + loadStore(ks, keyStoreType, kspassChars), + kspassChars, + ksAlias, + cacert, + ts, + tspass, + trustStoreType + ); + } + final SSLContextBuilder sslContextBuilder = SSLContexts.custom(); if (ks != null) { - File keyStoreFile = Paths.get(ks).toFile(); - - KeyStore keyStore = KeyStore.getInstance(keyStoreType.toUpperCase()); - keyStore.load(new FileInputStream(keyStoreFile), kspass.toCharArray()); - sslContextBuilder.loadKeyMaterial(keyStore, kspass.toCharArray(), (aliases, socket) -> { + final char[] kspassChars = kspass == null ? null : kspass.toCharArray(); + final KeyStore keyStore = loadStore(ks, keyStoreType, kspassChars); + sslContextBuilder.loadKeyMaterial(keyStore, kspassChars, (aliases, socket) -> { if (aliases == null || aliases.isEmpty()) { return ksAlias; } - if (ksAlias == null || ksAlias.isEmpty()) { return aliases.keySet().iterator().next(); } - return ksAlias; }); } - if (ts != null) { - File trustStoreFile = Paths.get(ts).toFile(); - - KeyStore trustStore = KeyStore.getInstance(trustStoreType.toUpperCase()); - trustStore.load(new FileInputStream(trustStoreFile), tspass == null ? null : tspass.toCharArray()); - sslContextBuilder.loadTrustMaterial(trustStore, null); + if (ts != null || "PKCS11".equalsIgnoreCase(trustStoreType)) { + sslContextBuilder.loadTrustMaterial(loadStore(ts, trustStoreType, tspass == null ? null : tspass.toCharArray()), null); } if (cacert != null) { @@ -1585,6 +1633,100 @@ private static SSLContext sslContext( return sslContextBuilder.build(); } + private static SSLContext buildPkcs11SslContext( + KeyStore keyStore, + char[] pin, + String ksAlias, + String cacert, + String ts, + String tspass, + String trustStoreType + ) throws Exception { + Provider sunJSSE = Security.getProvider("SunJSSE"); + if (sunJSSE == null) { + throw new IllegalStateException("SunJSSE provider not available; required for PKCS11 key store support"); + } + SSLContext ctx = SSLContext.getInstance("TLS", sunJSSE); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, pin); + KeyManager[] kms = Arrays.stream(kmf.getKeyManagers()) + .map(km -> km instanceof X509ExtendedKeyManager ? new AliasSelectingKeyManager((X509ExtendedKeyManager) km, ksAlias) : km) + .toArray(KeyManager[]::new); + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + if (cacert != null) { + try (FileInputStream in = new FileInputStream(Paths.get(cacert).toFile())) { + tmf.init(PemKeyReader.toTruststore("ca", PemKeyReader.loadCertificatesFromStream(in))); + } + } else if (ts != null || "PKCS11".equalsIgnoreCase(trustStoreType)) { + tmf.init(loadStore(ts, trustStoreType, tspass == null ? null : tspass.toCharArray())); + } else { + tmf.init((KeyStore) null); + } + + ctx.init(kms, tmf.getTrustManagers(), null); + return ctx; + } + + private static final class AliasSelectingKeyManager extends X509ExtendedKeyManager { + private final X509ExtendedKeyManager delegate; + private final String alias; + + AliasSelectingKeyManager(X509ExtendedKeyManager delegate, String alias) { + this.delegate = delegate; + this.alias = alias; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, java.net.Socket socket) { + return alias != null ? alias : delegate.chooseClientAlias(keyType, issuers, socket); + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return alias != null ? alias : delegate.chooseEngineClientAlias(keyType, issuers, engine); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, java.net.Socket socket) { + return delegate.chooseServerAlias(keyType, issuers, socket); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return delegate.getClientAliases(keyType, issuers); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return delegate.getServerAliases(keyType, issuers); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return delegate.getPrivateKey(alias); + } + } + + private static KeyStore loadStore(String path, String type, char[] password) throws Exception { + KeyStore ks = KeyStore.getInstance(type.toUpperCase()); + + if ("PKCS11".equalsIgnoreCase(type)) { + ks.load(null, password); + } else { + try (final var fin = new FileInputStream(Paths.get(path).toFile())) { + ks.load(fin, password); + } + } + return ks; + } + private static String responseToString(Response response, boolean prettyJson) { ByteSource byteSource = new ByteSource() { @Override diff --git a/src/main/java/org/opensearch/security/tools/democonfig/SecuritySettingsConfigurer.java b/src/main/java/org/opensearch/security/tools/democonfig/SecuritySettingsConfigurer.java index 180426dcdc..d88acf3f81 100644 --- a/src/main/java/org/opensearch/security/tools/democonfig/SecuritySettingsConfigurer.java +++ b/src/main/java/org/opensearch/security/tools/democonfig/SecuritySettingsConfigurer.java @@ -30,6 +30,7 @@ import org.opensearch.security.hasher.PasswordHasher; import org.opensearch.security.hasher.PasswordHasherFactory; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.Yaml; @@ -57,8 +58,9 @@ public class SecuritySettingsConfigurer { public SecuritySettingsConfigurer(Installer installer) { this.installer = installer; + String hashingAlgorithm = FipsMode.isEnabled() ? ConfigConstants.PBKDF2 : ConfigConstants.BCRYPT; this.passwordHasher = PasswordHasherFactory.createPasswordHasher( - Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.BCRYPT).build() + Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, hashingAlgorithm).build() ); } @@ -248,8 +250,9 @@ void updateAdminPassword() throws IOException { */ private boolean isAdminPasswordSetToAdmin(String internalUsersFile) throws IOException { JsonNode internalUsers = yamlMapper().readTree(new FileInputStream(internalUsersFile)); - return internalUsers.has("admin") - && passwordHasher.check(DEFAULT_ADMIN_PASSWORD.toCharArray(), internalUsers.get("admin").get("hash").asString()); + if (!internalUsers.has("admin")) return false; + String hash = internalUsers.get("admin").get("hash").asString(); + return passwordHasher.check(passwordHasher.defaultAdminPassword().toCharArray(), hash); } /** diff --git a/src/main/java/org/opensearch/security/user/UserService.java b/src/main/java/org/opensearch/security/user/UserService.java index bc3bd9301c..778fcb0ae2 100644 --- a/src/main/java/org/opensearch/security/user/UserService.java +++ b/src/main/java/org/opensearch/security/user/UserService.java @@ -13,25 +13,28 @@ import java.io.IOException; import java.net.URLDecoder; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Random; import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.bouncycastle.crypto.fips.FipsDRBG; import org.opensearch.ExceptionsHelper; import org.opensearch.action.index.IndexRequest; import org.opensearch.action.support.WriteRequest; import org.opensearch.cluster.service.ClusterService; -import org.opensearch.common.Randomness; import org.opensearch.common.inject.Inject; import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentHelper; @@ -156,9 +159,13 @@ public SecurityDynamicConfiguration createOrUpdateAccount(ObjectNode contentA if (!attributeNode.get("service").isNull() && attributeNode.get("service").asString().equalsIgnoreCase("true")) { // If this is a // service account verifyServiceAccount(securityJsonNode, accountName); - String password = generatePassword(); - contentAsNode.put("hash", passwordHasher.hash(password.toCharArray())); - contentAsNode.put("service", "true"); + char[] password = generatePassword(); + try { + contentAsNode.put("hash", passwordHasher.hash(password)); + contentAsNode.put("service", "true"); + } finally { + Arrays.fill(password, '\0'); + } } else { contentAsNode.put("service", "false"); } @@ -234,13 +241,25 @@ private void verifyServiceAccount(SecurityJsonNode securityJsonNode, String acco private static final String ALL_CHARS = LOWERCASE + UPPERCASE + DIGITS; /** - * Generate an 8 - 16 character password with 1+ lowercase, 1+ uppercase, 1+ digit. + * Generate a 20 - 27 character password with 1+ lowercase, 1+ uppercase, 1+ digit. + * Uses a FIPS SP 800-90A HMAC-SHA-256 DRBG; the returned {@code char[]} must be zeroed by the caller after use. * - * @return A password for a service account. + * @return A password for a service account exceeding the FIPS 112-bit entropy requirement. + */ + public static char[] generatePassword() { + SecureRandom seed = new SecureRandom(); + SecureRandom drbg = FipsDRBG.SHA256_HMAC // + .fromEntropySource(seed, false) // + .build(seed.generateSeed(32), false); + return generatePassword(drbg); + } + + /** + * Generate a password using the supplied {@link SecureRandom}. + * Exposed so callers can inject a seeded instance (e.g. in tests). */ - public static String generatePassword() { - Random random = Randomness.get(); - int length = random.nextInt(8) + 8; + public static char[] generatePassword(SecureRandom random) { + int length = random.nextInt(8) + 20; // 20-27 chars → ≥119 bits with 62-char alphabet char[] password = new char[length]; // Guarantee at least one of each required type @@ -261,7 +280,7 @@ public static String generatePassword() { password[j] = tmp; } - return new String(password); + return password; } /** @@ -297,22 +316,41 @@ public AuthToken generateAuthToken(String accountName) throws IOException { .orElseThrow(() -> new UserServiceException(AUTH_TOKEN_GENERATION_MESSAGE)); // Generate a new password for the account and store the hash of it - String plainTextPassword = generatePassword(); - contentAsNode.put("hash", passwordHasher.hash(plainTextPassword.toCharArray())); - contentAsNode.put("enabled", "true"); - contentAsNode.put("service", "true"); - - // Update the internal user associated with the auth token - internalUsersConfiguration.remove(accountName); - contentAsNode.remove("name"); - internalUsersConfiguration.putCObject( - accountName, - DefaultObjectMapper.readTree(contentAsNode, internalUsersConfiguration.getImplementingClass()) - ); - saveAndUpdateConfigs(getUserConfigName().toString(), client, CType.INTERNALUSERS, internalUsersConfiguration); - - authToken = Base64.getUrlEncoder().encodeToString((accountName + ":" + plainTextPassword).getBytes(StandardCharsets.UTF_8)); - return new BasicAuthToken("Basic " + authToken); + char[] plainTextPassword = generatePassword(); + byte[] credentialBytes = null; + try { + contentAsNode.put("hash", passwordHasher.hash(plainTextPassword)); + contentAsNode.put("enabled", "true"); + contentAsNode.put("service", "true"); + + // Update the internal user associated with the auth token + internalUsersConfiguration.remove(accountName); + contentAsNode.remove("name"); + internalUsersConfiguration.putCObject( + accountName, + DefaultObjectMapper.readTree(contentAsNode, internalUsersConfiguration.getImplementingClass()) + ); + saveAndUpdateConfigs(getUserConfigName().toString(), client, CType.INTERNALUSERS, internalUsersConfiguration); + + byte[] accountBytes = accountName.getBytes(StandardCharsets.UTF_8); + ByteBuffer passwordByteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(plainTextPassword)); + byte[] passwordBytes = new byte[passwordByteBuffer.remaining()]; + passwordByteBuffer.get(passwordBytes); + Arrays.fill(passwordByteBuffer.array(), (byte) 0); + credentialBytes = new byte[accountBytes.length + 1 + passwordBytes.length]; + System.arraycopy(accountBytes, 0, credentialBytes, 0, accountBytes.length); + credentialBytes[accountBytes.length] = ':'; + System.arraycopy(passwordBytes, 0, credentialBytes, accountBytes.length + 1, passwordBytes.length); + Arrays.fill(passwordBytes, (byte) 0); + + authToken = Base64.getUrlEncoder().encodeToString(credentialBytes); + return new BasicAuthToken("Basic " + authToken); + } finally { + Arrays.fill(plainTextPassword, '\0'); + if (credentialBytes != null) { + Arrays.fill(credentialBytes, (byte) 0); + } + } } catch (JacksonException ex) { throw new UserServiceException(FAILED_ACCOUNT_RETRIEVAL_MESSAGE); diff --git a/src/main/java/org/opensearch/security/util/KeyUtils.java b/src/main/java/org/opensearch/security/util/KeyUtils.java index e69f51e5a1..44a1a36cbe 100644 --- a/src/main/java/org/opensearch/security/util/KeyUtils.java +++ b/src/main/java/org/opensearch/security/util/KeyUtils.java @@ -18,12 +18,15 @@ import java.security.spec.X509EncodedKeySpec; import java.util.Base64; import java.util.Objects; +import javax.crypto.SecretKey; import org.apache.logging.log4j.Logger; import org.opensearch.OpenSearchSecurityException; +import org.opensearch.common.settings.Settings; import org.opensearch.core.common.Strings; import org.opensearch.secure_sm.AccessController; +import org.opensearch.security.support.PemKeyReader; import io.jsonwebtoken.JwtParserBuilder; import io.jsonwebtoken.Jwts; @@ -31,6 +34,42 @@ public class KeyUtils { + public static final String KEYSTORE_ALIAS = "_keystore_alias"; + public static final String KEYSTORE_PATH = "_keystore_path"; + public static final String KEYSTORE_TYPE = "_keystore_type"; + public static final String KEYSTORE_PASSWORD = "_keystore_password"; + public static final String KEYSTORE_KEY_PASSWORD = "_keystore_key_password"; + + /** + * Loads a {@link SecretKey} from a keystore configured via the given settings prefix. + *

+ * Expected settings: + *

    + *
  • {@code }{@value #KEYSTORE_ALIAS} — required; absence returns {@code null} (acts as an opt-in flag)
  • + *
  • {@code }{@value #KEYSTORE_PATH} — required for file-based keystores; may be omitted only for PKCS11
  • + *
  • {@code }{@value #KEYSTORE_TYPE} — optional; auto-detected from file content when absent
  • + *
  • {@code }{@value #KEYSTORE_PASSWORD} — optional; absent or {@code null} means the keystore has no password
  • + *
  • {@code }{@value #KEYSTORE_KEY_PASSWORD} — optional; falls back to {@code KEYSTORE_PASSWORD} when absent; + * both may be {@code null} for keystores and entries that carry no password
  • + *
+ * + * @return the loaded {@link SecretKey}, or {@code null} if the alias setting is absent + * @throws IllegalArgumentException if the alias is present but the path is missing, the keystore + * cannot be loaded, or the entry at the alias is not a {@link SecretKey} + */ + public static SecretKey loadKeyFromKeystore(final Settings settings, final String prefix) { + final String alias = settings.get(prefix + KEYSTORE_ALIAS); + if (alias == null) { + return null; + } + final String type = settings.get(prefix + KEYSTORE_TYPE); + final String ksPassword = settings.get(prefix + KEYSTORE_PASSWORD); + final String keyPassword = settings.get(prefix + KEYSTORE_KEY_PASSWORD); + final String pathStr = settings.get(prefix + KEYSTORE_PATH); + + return PemKeyReader.loadSecretKeyFromKeystore(pathStr, ksPassword, type, alias, keyPassword); + } + public static JwtParserBuilder createJwtParserBuilderFromSigningKey(final String signingKey, final Logger log) { JwtParserBuilder jwtParserBuilder = null; diff --git a/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfigurator.java b/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfigurator.java index 0ec161c64a..95b3fcbb03 100644 --- a/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfigurator.java +++ b/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfigurator.java @@ -45,6 +45,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.PemKeyReader; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_TRANSPORT_KEYSTORE_PASSWORD; @@ -78,7 +79,9 @@ public class SettingsBasedSSLConfigurator { public static final String VERIFY_HOSTNAMES = "verify_hostnames"; public static final String TRUST_ALL = "trust_all"; - private static final List DEFAULT_TLS_PROTOCOLS = ImmutableList.of("TLSv1.2", "TLSv1.1"); + private static final List DEFAULT_TLS_PROTOCOLS = FipsMode.isEnabled() + ? ImmutableList.of("TLSv1.3", "TLSv1.2") + : ImmutableList.of("TLSv1.2", "TLSv1.1"); private SSLContextBuilder sslContextBuilder; private final Settings settings; @@ -302,7 +305,7 @@ private void initFromPem() throws SSLConfigException { } try { - effectiveKeyPassword = PemKeyReader.randomChars(12); + effectiveKeyPassword = SSLConfigConstants.DEFAULT_STORE_PASSWORD.toCharArray(); effectiveKeyAlias = "al"; effectiveTruststore = PemKeyReader.toTruststore(effectiveKeyAlias, trustCertificates); effectiveKeystore = PemKeyReader.toKeystore( @@ -517,14 +520,6 @@ public String[] getEffectiveTruststoreAliasesArray() { } } - public String[] getEffectiveKeyAliasesArray() { - if (this.effectiveKeyAlias == null) { - return null; - } else { - return new String[] { this.effectiveKeyAlias }; - } - } - @Override public String toString() { return "SSLConfig [sslContext=" diff --git a/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfiguratorV4.java b/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfiguratorV4.java index ea170878f4..546f0ba6b9 100644 --- a/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfiguratorV4.java +++ b/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfiguratorV4.java @@ -303,7 +303,7 @@ private void initFromPem() throws SSLConfigException { } try { - effectiveKeyPassword = PemKeyReader.randomChars(12); + effectiveKeyPassword = SSLConfigConstants.DEFAULT_STORE_PASSWORD.toCharArray(); effectiveKeyAlias = "al"; effectiveTruststore = PemKeyReader.toTruststore(effectiveKeyAlias, trustCertificates); effectiveKeystore = PemKeyReader.toKeystore( @@ -531,14 +531,6 @@ public String[] getEffectiveTruststoreAliasesArray() { } } - public String[] getEffectiveKeyAliasesArray() { - if (this.effectiveKeyAlias == null) { - return null; - } else { - return new String[] { this.effectiveKeyAlias }; - } - } - @Override public String toString() { return "SSLConfig [sslContext=" diff --git a/src/test/java/org/opensearch/security/OpenSearchSecurityPluginFIPSValidationTest.java b/src/test/java/org/opensearch/security/OpenSearchSecurityPluginFIPSValidationTest.java index 8e8e93877b..65f8073d82 100644 --- a/src/test/java/org/opensearch/security/OpenSearchSecurityPluginFIPSValidationTest.java +++ b/src/test/java/org/opensearch/security/OpenSearchSecurityPluginFIPSValidationTest.java @@ -29,7 +29,7 @@ public void testFipsModeWithDefaultAlgorithmThrows() { IllegalStateException ex = assertThrows( IllegalStateException.class, - () -> OpenSearchSecurityPlugin.validateFipsMode("true", settings) + () -> OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> true) ); assertThat(ex.getMessage(), containsString("FIPS mode is enabled")); assertThat(ex.getMessage(), containsString("Only PBKDF2 is allowed in FIPS mode")); @@ -42,7 +42,7 @@ public void testFipsModeWithBcryptThrows() { IllegalStateException ex = assertThrows( IllegalStateException.class, - () -> OpenSearchSecurityPlugin.validateFipsMode("true", settings) + () -> OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> true) ); assertThat(ex.getMessage(), containsString("bcrypt")); assertThat(ex.getMessage(), containsString("FIPS mode is enabled")); @@ -54,7 +54,7 @@ public void testFipsModeWithArgon2Throws() { IllegalStateException ex = assertThrows( IllegalStateException.class, - () -> OpenSearchSecurityPlugin.validateFipsMode("true", settings) + () -> OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> true) ); assertThat(ex.getMessage(), containsString("argon2")); } @@ -64,7 +64,7 @@ public void testFipsModeWithPbkdf2Succeeds() { Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "pbkdf2").build(); // Should not throw - OpenSearchSecurityPlugin.validateFipsMode("true", settings); + OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> true); } @Test @@ -72,7 +72,7 @@ public void testFipsModeWithPbkdf2UpperCaseSucceeds() { Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "PBKDF2").build(); // Should not throw - OpenSearchSecurityPlugin.validateFipsMode("true", settings); + OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> true); } @Test @@ -80,14 +80,29 @@ public void testFipsModeDisabledAllowsAnyAlgorithm() { Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "bcrypt").build(); // Should not throw when FIPS mode is not enabled - OpenSearchSecurityPlugin.validateFipsMode("false", settings); + OpenSearchSecurityPlugin.validateFipsMode(settings, false, () -> false); } @Test - public void testFipsModeNullEnvAllowsAnyAlgorithm() { + public void testFipsModeEnabledWithoutApprovedOnlyModeThrows() { + Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "pbkdf2").build(); + + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> false) + ); + assertThat(ex.getMessage(), containsString("BCFIPS security provider is not running in FIPS approved-only mode")); + } + + @Test + public void testFipsModeEnabledWithBothViolationsThrows() { Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "bcrypt").build(); - // Should not throw when env var is null - OpenSearchSecurityPlugin.validateFipsMode(null, settings); + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> false) + ); + assertThat(ex.getMessage(), containsString("BCFIPS security provider is not running in FIPS approved-only mode")); + assertThat(ex.getMessage(), containsString("Only PBKDF2 is allowed in FIPS mode")); } } diff --git a/src/test/java/org/opensearch/security/UserServiceUnitTests.java b/src/test/java/org/opensearch/security/UserServiceUnitTests.java index b449629058..9157539cbf 100644 --- a/src/test/java/org/opensearch/security/UserServiceUnitTests.java +++ b/src/test/java/org/opensearch/security/UserServiceUnitTests.java @@ -14,6 +14,9 @@ import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; import java.util.Optional; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; @@ -23,6 +26,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.hasher.PasswordHasher; import org.opensearch.security.hasher.PasswordHasherFactory; @@ -40,7 +44,9 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; @RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) @ThreadLeakScope(ThreadLeakScope.Scope.NONE) @@ -121,19 +127,83 @@ public void restrictedFromUsername() { @Test public void testGeneratedPasswordContents() { - String password = UserService.generatePassword(); + char[] password = UserService.generatePassword(); + try { + assertThat(password.length >= 20 && password.length <= 27, is(true)); + assertThat(new String(password).chars().anyMatch(Character::isLowerCase), is(true)); + assertThat(new String(password).chars().anyMatch(Character::isUpperCase), is(true)); + assertThat(new String(password).chars().anyMatch(Character::isDigit), is(true)); + + char[] password2 = UserService.generatePassword(); + try { + assertNotEquals(new String(password), new String(password2)); + } finally { + Arrays.fill(password2, '\0'); + } + } finally { + Arrays.fill(password, '\0'); + } + } - // Verify length is 8-16 - assertThat(password.length() >= 8 && password.length() <= 16, is(true)); + @Test + public void testGeneratedPasswordWithInjectedRandom() { + SecureRandom seededRandom = new SecureRandom(new byte[] { 1, 2, 3, 4 }); + char[] passwordChars = UserService.generatePassword(seededRandom); + String password = new String(passwordChars); + try { + assertThat(password.length() >= 20 && password.length() <= 27, is(true)); + assertThat(password.chars().anyMatch(Character::isLowerCase), is(true)); + assertThat(password.chars().anyMatch(Character::isUpperCase), is(true)); + assertThat(password.chars().anyMatch(Character::isDigit), is(true)); + + // password bytes serve as IKM for BC FIPS HKDF → AES-256 key derivation + byte[] passwordBytes = password.getBytes(StandardCharsets.UTF_8); + String base64Secret = Base64.getEncoder().encodeToString(passwordBytes); + Arrays.fill(passwordBytes, (byte) 0); + + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(base64Secret); + String testData = "FIPS entropy test payload"; + assertEquals("AES round-trip failed with password-derived key", testData, util.decrypt(util.encrypt(testData))); + + SecureRandom seededRandom2 = new SecureRandom(new byte[] { 5, 6, 7, 8 }); + char[] password2Chars = UserService.generatePassword(seededRandom2); + try { + assertNotEquals(password, new String(password2Chars)); + } finally { + Arrays.fill(password2Chars, '\0'); + } + } finally { + Arrays.fill(passwordChars, '\0'); + } + } - // Verify at least 1 lowercase, 1 uppercase, 1 digit - assertThat(password.chars().anyMatch(Character::isLowerCase), is(true)); - assertThat(password.chars().anyMatch(Character::isUpperCase), is(true)); - assertThat(password.chars().anyMatch(Character::isDigit), is(true)); + @Test + public void testGeneratedPasswordEntropyMeetsFipsRequirement() { + // FIPS 140-2/3 requires minimum 112 bits of entropy for cryptographic keys + final double FIPS_MIN_ENTROPY_BITS = 112.0; + final int CHARSET_SIZE = 62; // a-z (26) + A-Z (26) + 0-9 (10) + final int MIN_PASSWORD_LENGTH = 20; + + double entropyPerChar = Math.log(CHARSET_SIZE) / Math.log(2); + double minEntropy = entropyPerChar * MIN_PASSWORD_LENGTH; + + assertTrue( + String.format("Password entropy %.2f bits must be >= %.2f bits (FIPS minimum)", minEntropy, FIPS_MIN_ENTROPY_BITS), + minEntropy >= FIPS_MIN_ENTROPY_BITS + ); - // Verify two generated passwords are different - String password2 = UserService.generatePassword(); - assertNotEquals(password, password2); + char[] password = UserService.generatePassword(); + try { + assertTrue("Generated password must be >= " + MIN_PASSWORD_LENGTH + " chars", password.length >= MIN_PASSWORD_LENGTH); + + double actualEntropy = entropyPerChar * password.length; + assertTrue( + String.format("Actual password entropy %.2f bits must be >= %.2f bits", actualEntropy, FIPS_MIN_ENTROPY_BITS), + actualEntropy >= FIPS_MIN_ENTROPY_BITS + ); + } finally { + Arrays.fill(password, '\0'); + } } } diff --git a/src/test/java/org/opensearch/security/UtilTests.java b/src/test/java/org/opensearch/security/UtilTests.java index f357e59221..433a1055a9 100644 --- a/src/test/java/org/opensearch/security/UtilTests.java +++ b/src/test/java/org/opensearch/security/UtilTests.java @@ -34,6 +34,7 @@ import org.opensearch.security.hasher.PasswordHasher; import org.opensearch.security.hasher.PasswordHasherFactory; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.SecurityUtils; import org.opensearch.security.support.WildcardMatcher; @@ -41,6 +42,7 @@ import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; public class UtilTests { @@ -156,6 +158,7 @@ public void testEnvReplace() { @Test public void testEnvReplacePBKDF2() { + assumeFalse("BCFIPS requires password to be at least 14 chars long", FipsMode.isEnabled()); Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.PBKDF2).build(); final PasswordHasher passwordHasherPBKDF2 = PasswordHasherFactory.createPasswordHasher(settings); assertThat(SecurityUtils.replaceEnvVars("abv${env.MYENV}xyz", settings), is("abv${env.MYENV}xyz")); @@ -192,6 +195,52 @@ public void testEnvReplacePBKDF2() { assertTrue(checked); } + @Test + public void testEnvReplacePBKDF2BCFips() { + // BCFIPS approved-only mode requires passwords of at least 14 characters (>= 112 bits of entropy). + Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.PBKDF2).build(); + final PasswordHasher passwordHasherPBKDF2 = PasswordHasherFactory.createPasswordHasher(settings); + assertThat(SecurityUtils.replaceEnvVars("abv${env.MYENV}xyz", settings), is("abv${env.MYENV}xyz")); + assertThat(SecurityUtils.replaceEnvVars("abv${envbc.MYENV}xyz", settings), is("abv${envbc.MYENV}xyz")); + assertThat(SecurityUtils.replaceEnvVars("abv${env.MYENV:-tTtFipsPassword}xyz", settings), is("abvtTtFipsPasswordxyz")); + assertTrue( + passwordHasherPBKDF2.check( + "tTtFipsPassword".toCharArray(), + SecurityUtils.replaceEnvVars("${envbc.MYENV:-tTtFipsPassword}", settings) + ) + ); + assertThat( + SecurityUtils.replaceEnvVars("abv${env.MYENV:-tTtFipsPassword}xyz${env.MYENV:-xxxFipsPassword}", settings), + is("abvtTtFipsPasswordxyzxxxFipsPassword") + ); + assertTrue( + SecurityUtils.replaceEnvVars("abv${env.MYENV:-tTtFipsPassword}xyz${envbc.MYENV:-xxxFipsPassword}", settings) + .startsWith("abvtTtFipsPasswordxyz$3$") + ); + assertThat(SecurityUtils.replaceEnvVars("abv${env.MYENV:tTtFipsPassword}xyz", settings), is("abv${env.MYENV:tTtFipsPassword}xyz")); + assertThat(SecurityUtils.replaceEnvVars("abv${env.MYENV-tTtFipsPassword}xyz", settings), is("abv${env.MYENV-tTtFipsPassword}xyz")); + + Map.Entry envEntry = System.getenv() + .entrySet() + .stream() + .filter(it -> it.getValue() != null) + .filter(it -> it.getValue().length() >= 14) + .findFirst() + .orElseThrow(() -> new AssertionError("No env var with value >= 14 chars found")); + + String k = envEntry.getKey(); + String val = envEntry.getValue(); + assertThat(SecurityUtils.replaceEnvVars("abv${env." + k + "}xyz", settings), is("abv" + val + "xyz")); + assertThat(SecurityUtils.replaceEnvVars("abv${" + k + "}xyz", settings), is("abv${" + k + "}xyz")); + assertThat(SecurityUtils.replaceEnvVars("abv${env." + k + ":-k182765gghFipsX}xyz", settings), is("abv" + val + "xyz")); + assertThat( + SecurityUtils.replaceEnvVars("abv${env." + k + "}xyzabv${env." + k + "}xyz", settings), + is("abv" + val + "xyzabv" + val + "xyz") + ); + assertThat(SecurityUtils.replaceEnvVars("abv${env." + k + ":-k182765gghFipsX}xyz", settings), is("abv" + val + "xyz")); + assertTrue(passwordHasherPBKDF2.check(val.toCharArray(), SecurityUtils.replaceEnvVars("${envbc." + k + "}", settings))); + } + @Test public void testNoEnvReplace() { Settings settings = Settings.builder().put(ConfigConstants.SECURITY_DISABLE_ENVVAR_REPLACEMENT, true).build(); diff --git a/src/test/java/org/opensearch/security/auth/InternalAuthBackendTests.java b/src/test/java/org/opensearch/security/auth/InternalAuthBackendTests.java index 21cb969522..5980746836 100644 --- a/src/test/java/org/opensearch/security/auth/InternalAuthBackendTests.java +++ b/src/test/java/org/opensearch/security/auth/InternalAuthBackendTests.java @@ -15,16 +15,20 @@ import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Collection; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; import org.opensearch.OpenSearchSecurityException; import org.opensearch.common.settings.Settings; import org.opensearch.security.auth.internal.InternalAuthenticationBackend; +import org.opensearch.security.hasher.PasswordHasher; import org.opensearch.security.hasher.PasswordHasherFactory; import org.opensearch.security.securityconf.InternalUsersModel; import org.opensearch.security.support.ConfigConstants; @@ -39,21 +43,51 @@ import static org.mockito.Mockito.when; import static org.mockito.internal.verification.VerificationModeFactory.times; +@RunWith(Parameterized.class) public class InternalAuthBackendTests { - private InternalUsersModel internalUsersModel; + private final Settings settings; + private InternalUsersModel internalUsersModel; private InternalAuthenticationBackend internalAuthenticationBackend; + private PasswordHasher passwordHasher; + private String storedHash; + + public InternalAuthBackendTests(String algorithmName, Settings settings) { + this.settings = settings; + } + + @Parameterized.Parameters(name = "{0}") + public static Collection hashingAlgorithms() { + return Arrays.asList( + new Object[][] { + { + "BCrypt", + Settings.builder() + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.BCRYPT) + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS, 4) + .build() }, + { + "PBKDF2", + Settings.builder() + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.PBKDF2) + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS, 1) + .build() }, + { + "Argon2", + Settings.builder() + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.ARGON2) + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_ARGON2_MEMORY, 8) + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_ARGON2_ITERATIONS, 1) + .build() } } + ); + } @Before public void internalAuthBackendTestsSetup() { - internalAuthenticationBackend = spy( - new InternalAuthenticationBackend( - PasswordHasherFactory.createPasswordHasher( - Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.BCRYPT).build() - ) - ) - ); + passwordHasher = PasswordHasherFactory.createPasswordHasher(settings); + storedHash = passwordHasher.hash("$adminpassword!".toCharArray()); + internalAuthenticationBackend = spy(new InternalAuthenticationBackend(passwordHasher)); internalUsersModel = mock(InternalUsersModel.class); internalAuthenticationBackend.onInternalUsersModelChanged(internalUsersModel); } @@ -71,16 +105,14 @@ private char[] createArrayFromPasswordBytes(byte[] password) { public void testHashActionWithValidUserValidPassword() { // Make authentication info for valid username with valid password - final String validPassword = "admin"; + final String validPassword = "$adminpassword!"; final byte[] validPasswordBytes = validPassword.getBytes(); - final AuthCredentials validUsernameAuth = new AuthCredentials("admin", validPasswordBytes); - - final String hash = "$2y$12$NmKhjNssNgSIj8iXT7SYxeXvMA1E95a9tCt4cySY9FrQ4fB18xEc2"; + final AuthCredentials validUsernameAuth = new AuthCredentials("$adminpassword!", validPasswordBytes); char[] array = createArrayFromPasswordBytes(validPasswordBytes); - when(internalUsersModel.getHash(validUsernameAuth.getUsername())).thenReturn(hash); + when(internalUsersModel.getHash(validUsernameAuth.getUsername())).thenReturn(storedHash); when(internalUsersModel.exists(validUsernameAuth.getUsername())).thenReturn(true); when(internalUsersModel.getAttributes(validUsernameAuth.getUsername())).thenReturn(ImmutableMap.of()); when(internalUsersModel.getSecurityRoles(validUsernameAuth.getUsername())).thenReturn(ImmutableSet.of()); @@ -91,7 +123,7 @@ public void testHashActionWithValidUserValidPassword() { // Act internalAuthenticationBackend.authenticate(new AuthenticationContext(validUsernameAuth)); - verify(internalAuthenticationBackend, times(1)).passwordMatchesHash(hash, array); + verify(internalAuthenticationBackend, times(1)).passwordMatchesHash(storedHash, array); verify(internalUsersModel, times(1)).getBackendRoles(validUsernameAuth.getUsername()); } @@ -101,32 +133,30 @@ public void testHashActionWithValidUserInvalidPassword() { // Make authentication info for valid with bad password final String gibberishPassword = "ajdhflkasdjfaklsdf"; final byte[] gibberishPasswordBytes = gibberishPassword.getBytes(); - final AuthCredentials validUsernameAuth = new AuthCredentials("admin", gibberishPasswordBytes); - - final String hash = "$2y$12$NmKhjNssNgSIj8iXT7SYxeXvMA1E95a9tCt4cySY9FrQ4fB18xEc2"; + final AuthCredentials validUsernameAuth = new AuthCredentials("$adminpassword!", gibberishPasswordBytes); char[] array = createArrayFromPasswordBytes(gibberishPasswordBytes); - when(internalUsersModel.getHash("admin")).thenReturn(hash); - when(internalUsersModel.exists("admin")).thenReturn(true); + when(internalUsersModel.getHash("$adminpassword!")).thenReturn(storedHash); + when(internalUsersModel.exists("$adminpassword!")).thenReturn(true); OpenSearchSecurityException ex = Assert.assertThrows( OpenSearchSecurityException.class, () -> internalAuthenticationBackend.authenticate(new AuthenticationContext(validUsernameAuth)) ); assert (ex.getMessage().contains("password does not match")); - verify(internalAuthenticationBackend, times(1)).passwordMatchesHash(hash, array); + verify(internalAuthenticationBackend, times(1)).passwordMatchesHash(storedHash, array); } @Test public void testHashActionWithInvalidUserValidPassword() { // Make authentication info for valid and invalid usernames both with bad passwords - final String validPassword = "admin"; + final String validPassword = "$adminpassword!"; final byte[] validPasswordBytes = validPassword.getBytes(); final AuthCredentials invalidUsernameAuth = new AuthCredentials("ertyuiykgjjfguyifdghc", validPasswordBytes); - final String hash = "$2y$12$NmKhjNssNgSIj8iXT7SYxeXvMA1E95a9tCt4cySY9FrQ4fB18xEc2"; + final String hash = passwordHasher.getDummyHash(); char[] array = createArrayFromPasswordBytes(validPasswordBytes); @@ -149,7 +179,7 @@ public void testHashActionWithInvalidUserInvalidPassword() { final byte[] gibberishPasswordBytes = gibberishPassword.getBytes(); final AuthCredentials invalidUsernameAuth = new AuthCredentials("ertyuiykgjjfguyifdghc", gibberishPasswordBytes); - final String hash = "$2y$12$NmKhjNssNgSIj8iXT7SYxeXvMA1E95a9tCt4cySY9FrQ4fB18xEc2"; + final String hash = passwordHasher.getDummyHash(); char[] array = createArrayFromPasswordBytes(gibberishPasswordBytes); diff --git a/src/test/java/org/opensearch/security/auth/http/saml/HTTPSamlAuthenticatorTest.java b/src/test/java/org/opensearch/security/auth/http/saml/HTTPSamlAuthenticatorTest.java index f79bcae38a..1e1d334b1f 100644 --- a/src/test/java/org/opensearch/security/auth/http/saml/HTTPSamlAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/auth/http/saml/HTTPSamlAuthenticatorTest.java @@ -35,6 +35,7 @@ import org.hamcrest.Matchers; import org.junit.After; import org.junit.Assert; +import org.junit.Assume; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -53,6 +54,7 @@ import org.opensearch.security.filter.SecurityRequest; import org.opensearch.security.filter.SecurityRequestFactory; import org.opensearch.security.filter.SecurityResponse; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.security.user.AuthCredentials; import org.opensearch.security.util.FakeRestRequest; @@ -107,6 +109,11 @@ public class HTTPSamlAuthenticatorTest { private static X509Certificate spSigningCertificate; private static PrivateKey spSigningPrivateKey; + @BeforeClass + public static void skipInFipsMode() { + Assume.assumeFalse("Skipping SAML tests: SAML frameworks are not FIPS-compliant", FipsMode.isEnabled()); + } + @Before public void setUp() throws Exception { mockSamlIdpServer = new MockSamlIdpServer(); diff --git a/src/test/java/org/opensearch/security/auth/ldap/LdapBackendTest.java b/src/test/java/org/opensearch/security/auth/ldap/LdapBackendTest.java index 0b7d5eec42..e9f8cd5e12 100755 --- a/src/test/java/org/opensearch/security/auth/ldap/LdapBackendTest.java +++ b/src/test/java/org/opensearch/security/auth/ldap/LdapBackendTest.java @@ -31,6 +31,7 @@ import org.opensearch.security.auth.ldap.util.ConfigConstants; import org.opensearch.security.auth.ldap.util.LdapHelper; import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.security.user.AuthCredentials; import org.opensearch.security.user.User; @@ -45,6 +46,7 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; +import static org.junit.Assume.assumeFalse; public class LdapBackendTest { @@ -174,7 +176,6 @@ public void testLdapAuthenticationSSL() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -194,7 +195,6 @@ public void testLdapAuthenticationSSLPEMFile() throws Exception { ConfigConstants.LDAPS_PEMTRUSTEDCAS_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("ldap/root-ca.pem").toFile().getName() ) - .put("verify_hostnames", false) .put("path.home", ".") .put("path.conf", FileHelper.getAbsoluteFilePathFromClassPath("ldap/root-ca.pem").getParent()) .build(); @@ -216,6 +216,7 @@ public void testLdapAuthenticationSSLPEMText() throws Exception { @Test public void testLdapAuthenticationSSLSSLv3() throws Exception { + assumeFalse("SSLv3 is not FIPS-approved", FipsMode.isEnabled()); final Settings settings = Settings.builder() .putList(ConfigConstants.LDAP_HOSTS, "localhost:" + ldapsPort) @@ -244,7 +245,6 @@ public void testLdapAuthenticationSSLUnknowCipher() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_ciphers", "AAA") .put("path.home", ".") .build(); @@ -266,7 +266,6 @@ public void testLdapAuthenticationSpecialCipherProtocol() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_protocols", "TLSv1.2") .putList("enabled_ssl_ciphers", "TLS_DHE_RSA_WITH_AES_128_CBC_SHA") .put("path.home", ".") @@ -286,7 +285,6 @@ public void testLdapAuthenticationSSLNoKeystore() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -721,7 +719,6 @@ public void testLdapAuthenticationStartTLS() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_START_TLS, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); diff --git a/src/test/java/org/opensearch/security/auth/ldap/LdapBackendTestNewStyleConfig.java b/src/test/java/org/opensearch/security/auth/ldap/LdapBackendTestNewStyleConfig.java index 7fc2c59f98..73d0b697fc 100644 --- a/src/test/java/org/opensearch/security/auth/ldap/LdapBackendTestNewStyleConfig.java +++ b/src/test/java/org/opensearch/security/auth/ldap/LdapBackendTestNewStyleConfig.java @@ -31,6 +31,7 @@ import org.opensearch.security.auth.ldap.util.ConfigConstants; import org.opensearch.security.auth.ldap.util.LdapHelper; import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.security.user.AuthCredentials; import org.opensearch.security.user.User; @@ -42,6 +43,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; +import static org.junit.Assume.assumeFalse; public class LdapBackendTestNewStyleConfig { @@ -173,7 +175,6 @@ public void testLdapAuthenticationSSL() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -193,7 +194,6 @@ public void testLdapAuthenticationSSLPEMFile() throws Exception { ConfigConstants.LDAPS_PEMTRUSTEDCAS_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("ldap/root-ca.pem").toFile().getName() ) - .put("verify_hostnames", false) .put("path.home", ".") .put("path.conf", FileHelper.getAbsoluteFilePathFromClassPath("ldap/root-ca.pem").getParent()) .build(); @@ -216,13 +216,13 @@ public void testLdapAuthenticationSSLPEMText() throws Exception { @Test public void testLdapAuthenticationSSLSSLv3() throws Exception { + assumeFalse("SSLv3 is not FIPS-approved", FipsMode.isEnabled()); final Settings settings = Settings.builder() .putList(ConfigConstants.LDAP_HOSTS, "localhost:" + ldapsPort) .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_protocols", "SSLv3") .put("path.home", ".") .build(); @@ -244,7 +244,6 @@ public void testLdapAuthenticationSSLUnknownCipher() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_ciphers", "AAA") .put("path.home", ".") .build(); @@ -266,7 +265,6 @@ public void testLdapAuthenticationSpecialCipherProtocol() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_protocols", "TLSv1.2") .putList("enabled_ssl_ciphers", "TLS_DHE_RSA_WITH_AES_128_CBC_SHA") .put("path.home", ".") @@ -286,7 +284,6 @@ public void testLdapAuthenticationSSLNoKeystore() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -570,7 +567,6 @@ public void testLdapAuthenticationStartTLS() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_START_TLS, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); diff --git a/src/test/java/org/opensearch/security/auth/ldap/srv/LdapServer.java b/src/test/java/org/opensearch/security/auth/ldap/srv/LdapServer.java index adeec6b06f..10d9aaccaa 100644 --- a/src/test/java/org/opensearch/security/auth/ldap/srv/LdapServer.java +++ b/src/test/java/org/opensearch/security/auth/ldap/srv/LdapServer.java @@ -18,6 +18,7 @@ import java.io.StringReader; import java.net.BindException; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; import java.util.concurrent.TimeUnit; @@ -25,6 +26,7 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Handler; import java.util.logging.LogRecord; +import javax.net.ssl.TrustManagerFactory; import com.google.common.io.CharStreams; import org.apache.commons.lang3.exception.ExceptionUtils; @@ -45,7 +47,6 @@ import com.unboundid.ldif.LDIFReader; import com.unboundid.util.ssl.KeyStoreKeyManager; import com.unboundid.util.ssl.SSLUtil; -import com.unboundid.util.ssl.TrustStoreTrustManager; final class LdapServer { private final static Logger LOG = LogManager.getLogger(LdapServer.class); @@ -115,11 +116,15 @@ private Collection getInMemoryListenerConfigs() throws E Collection listenerConfigs = new ArrayList(); String serverKeyStorePath = FileHelper.resolveStore("ldap/node-0-keystore").path().toFile().getAbsolutePath(); + + KeyStore trustStore = FileHelper.getKeystoreFromClassPath("ldap/truststore", "changeit"); + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + final SSLUtil serverSSLUtil = new SSLUtil( new KeyStoreKeyManager(serverKeyStorePath, "changeit".toCharArray()), - new TrustStoreTrustManager(serverKeyStorePath) + tmf.getTrustManagers()[0] ); - // final SSLUtil clientSSLUtil = new SSLUtil(new TrustStoreTrustManager(serverKeyStorePath)); ldapPort = SocketUtils.findAvailableTcpPort(); ldapsPort = SocketUtils.findAvailableTcpPort(); diff --git a/src/test/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactoryTest.java b/src/test/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactoryTest.java new file mode 100644 index 0000000000..24293d2145 --- /dev/null +++ b/src/test/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactoryTest.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import org.junit.After; +import org.junit.Test; + +import org.ldaptive.LdapException; + +import static org.junit.Assert.assertEquals; + +public class HostnameAwareConnectionFactoryTest { + + @After + public void clearThreadLocal() { + SNISettingTLSSocketFactory.clearContext(); + } + + @Test + public void getConnection_setsHostnameBeforeDelegating() throws LdapException { + String[] captured = { null }; + new HostnameAwareConnectionFactory(() -> { + captured[0] = SNISettingTLSSocketFactory.getHostname(); + return null; + }, "ldaps://example.com:636", true).getConnection(); + + assertEquals("example.com", captured[0]); + } + + @Test(expected = IllegalArgumentException.class) + public void getConnection_throwsOnUnparseableUrl() throws LdapException { + new HostnameAwareConnectionFactory(() -> null, "not-a-valid-url", false).getConnection(); + } +} diff --git a/src/test/java/org/opensearch/security/auth/ldap2/LdapBackendTestNewStyleConfig2.java b/src/test/java/org/opensearch/security/auth/ldap2/LdapBackendTestNewStyleConfig2.java index 1127fd80e8..f67d99bf6b 100644 --- a/src/test/java/org/opensearch/security/auth/ldap2/LdapBackendTestNewStyleConfig2.java +++ b/src/test/java/org/opensearch/security/auth/ldap2/LdapBackendTestNewStyleConfig2.java @@ -38,7 +38,7 @@ import org.opensearch.security.auth.ldap.util.ConfigConstants; import org.opensearch.security.auth.ldap.util.LdapHelper; import org.opensearch.security.ssl.util.SSLConfigConstants; -import org.opensearch.security.support.WildcardMatcher; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.security.user.AuthCredentials; import org.opensearch.security.user.User; @@ -51,6 +51,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; +import static org.junit.Assume.assumeFalse; @RunWith(Parameterized.class) public class LdapBackendTestNewStyleConfig2 { @@ -195,7 +196,6 @@ public void testLdapAuthenticationSSL() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -214,7 +214,6 @@ public void testLdapAuthenticationSSLPEMFile() throws Exception { ConfigConstants.LDAPS_PEMTRUSTEDCAS_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("ldap/root-ca.pem").toFile().getName() ) - .put("verify_hostnames", false) .put("path.home", ".") .put("path.conf", FileHelper.getAbsoluteFilePathFromClassPath("ldap/root-ca.pem").getParent()) .build(); @@ -237,6 +236,7 @@ public void testLdapAuthenticationSSLPEMText() throws Exception { @Test public void testLdapAuthenticationSSLSSLv3() throws Exception { + assumeFalse("SSLv3 is not FIPS-approved", FipsMode.isEnabled()); final Settings settings = createBaseSettings().putList(ConfigConstants.LDAP_HOSTS, "localhost:" + ldapsPort) .put("users.u1.search", "(uid={0})") @@ -252,7 +252,8 @@ public void testLdapAuthenticationSSLSSLv3() throws Exception { Assert.fail("Expected Exception"); } catch (Exception e) { assertThat(e.getCause().getClass(), is(org.ldaptive.provider.ConnectionException.class)); - Assert.assertTrue(ExceptionUtils.getStackTrace(e).contains("No appropriate protocol")); + var message = FipsMode.isEnabled() ? "'protocols' cannot be null, or contain unsupported protocols" : "No appropriate protocol"; + Assert.assertTrue(ExceptionUtils.getStackTrace(e).contains(message)); } } @@ -264,7 +265,6 @@ public void testLdapAuthenticationSSLUnknownCipher() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_ciphers", "AAA") .put("path.home", ".") .build(); @@ -274,10 +274,8 @@ public void testLdapAuthenticationSSLUnknownCipher() throws Exception { Assert.fail("Expected Exception"); } catch (Exception e) { assertThat(e.getCause().getClass(), is(org.ldaptive.provider.ConnectionException.class)); - Assert.assertTrue( - ExceptionUtils.getStackTrace(e), - WildcardMatcher.from("*unsupported*ciphersuite*aaa*").test(ExceptionUtils.getStackTrace(e).toLowerCase()) - ); + var message = FipsMode.isEnabled() ? "No usable cipher suites enabled" : "Unsupported CipherSuite: AAA"; + Assert.assertTrue(ExceptionUtils.getStackTrace(e).contains(message)); } } @@ -289,7 +287,6 @@ public void testLdapAuthenticationSpecialCipherProtocol() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_protocols", "TLSv1.2") .putList("enabled_ssl_ciphers", "TLS_DHE_RSA_WITH_AES_128_CBC_SHA") .put("path.home", ".") @@ -308,7 +305,6 @@ public void testLdapAuthenticationSSLNoKeystore() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -612,7 +608,6 @@ public void testLdapAuthenticationStartTLS() throws Exception { .put("users.u1.search", "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_START_TLS, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); diff --git a/src/test/java/org/opensearch/security/auth/ldap2/LdapBackendTestOldStyleConfig2.java b/src/test/java/org/opensearch/security/auth/ldap2/LdapBackendTestOldStyleConfig2.java index f25c20b19a..cf264ff558 100755 --- a/src/test/java/org/opensearch/security/auth/ldap2/LdapBackendTestOldStyleConfig2.java +++ b/src/test/java/org/opensearch/security/auth/ldap2/LdapBackendTestOldStyleConfig2.java @@ -37,6 +37,7 @@ import org.opensearch.security.auth.ldap.util.ConfigConstants; import org.opensearch.security.auth.ldap.util.LdapHelper; import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.security.user.AuthCredentials; @@ -50,6 +51,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; +import static org.junit.Assume.assumeFalse; @RunWith(Parameterized.class) public class LdapBackendTestOldStyleConfig2 { @@ -219,7 +221,6 @@ public void testLdapAuthenticationSSL() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -236,7 +237,6 @@ public void testLdapAuthenticationSSLPooled() throws Exception { .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(ConfigConstants.LDAP_POOL_ENABLED, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -255,7 +255,6 @@ public void testLdapAuthenticationSSLPEMFile() throws Exception { ConfigConstants.LDAPS_PEMTRUSTEDCAS_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("ldap/root-ca.pem").toFile().getName() ) - .put("verify_hostnames", false) .put("path.home", ".") .put("path.conf", FileHelper.getAbsoluteFilePathFromClassPath("ldap/root-ca.pem").getParent()) .build(); @@ -278,6 +277,7 @@ public void testLdapAuthenticationSSLPEMText() throws Exception { @Test public void testLdapAuthenticationSSLSSLv3() throws Exception { + assumeFalse("SSLv3 is not FIPS-approved", FipsMode.isEnabled()); final Settings settings = createBaseSettings().putList(ConfigConstants.LDAP_HOSTS, "localhost:" + ldapsPort) .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") @@ -293,7 +293,8 @@ public void testLdapAuthenticationSSLSSLv3() throws Exception { Assert.fail("Expected Exception"); } catch (Exception e) { assertThat(e.getCause().getClass(), is(org.ldaptive.provider.ConnectionException.class)); - Assert.assertTrue(ExceptionUtils.getStackTrace(e).contains("No appropriate protocol")); + var message = FipsMode.isEnabled() ? "'protocols' cannot be null, or contain unsupported protocols" : "No appropriate protocol"; + Assert.assertTrue(ExceptionUtils.getStackTrace(e).contains(message)); } } @@ -305,7 +306,6 @@ public void testLdapAuthenticationSSLUnknowCipher() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_ciphers", "AAA") .put("path.home", ".") .build(); @@ -314,8 +314,9 @@ public void testLdapAuthenticationSSLUnknowCipher() throws Exception { new LDAPAuthenticationBackend2(settings, null).authenticate(ctx("jacksonm", "secret")); Assert.fail("Expected Exception"); } catch (Exception e) { - assertThat(e.getCause().getClass().toString(), org.ldaptive.provider.ConnectionException.class, is(e.getCause().getClass())); - Assert.assertTrue(ExceptionUtils.getStackTrace(e), EXCEPTION_MATCHER.test(ExceptionUtils.getStackTrace(e).toLowerCase())); + assertThat(e.getCause().getClass(), is(org.ldaptive.provider.ConnectionException.class)); + var message = FipsMode.isEnabled() ? "No usable cipher suites enabled" : "Unsupported CipherSuite: AAA"; + Assert.assertTrue(ExceptionUtils.getStackTrace(e).contains(message)); } } @@ -327,7 +328,6 @@ public void testLdapAuthenticationSpecialCipherProtocol() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .putList("enabled_ssl_protocols", "TLSv1.2") .putList("enabled_ssl_ciphers", "TLS_DHE_RSA_WITH_AES_128_CBC_SHA") .put("path.home", ".") @@ -346,7 +346,6 @@ public void testLdapAuthenticationSSLNoKeystore() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_SSL, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); @@ -646,7 +645,6 @@ public void testLdapAuthenticationStartTLS() throws Exception { .put(ConfigConstants.LDAP_AUTHC_USERSEARCH, "(uid={0})") .put(ConfigConstants.LDAPS_ENABLE_START_TLS, true) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) - .put("verify_hostnames", false) .put("path.home", ".") .build(); diff --git a/src/test/java/org/opensearch/security/auth/ldap2/LdapMtlsSniAuthenticationTest.java b/src/test/java/org/opensearch/security/auth/ldap2/LdapMtlsSniAuthenticationTest.java new file mode 100644 index 0000000000..5c861cd5e5 --- /dev/null +++ b/src/test/java/org/opensearch/security/auth/ldap2/LdapMtlsSniAuthenticationTest.java @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.opensearch.common.settings.Settings; +import org.opensearch.security.auth.AuthenticationContext; +import org.opensearch.security.auth.ldap.srv.EmbeddedLDAPServer; +import org.opensearch.security.auth.ldap.util.ConfigConstants; +import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.test.helper.file.FileHelper; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.user.User; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +/** + * Proves that SNI hostname is correctly propagated via ThreadLocal when connecting over LDAPS. + * + * The LDAP client connects to "localhost" with explicit hostname verification enabled. + * BouncyCastle requires the hostname from ThreadLocal (set by HostnameAwareConnectionFactory + * before JNDI resolves it to an IP) to verify the server certificate's SAN against "localhost". + * Successful authentication proves SNI was correctly set — without it, BouncyCastle cannot + * determine the expected hostname and hostname verification would fail. + */ +public class LdapMtlsSniAuthenticationTest { + + private static EmbeddedLDAPServer ldapServer; + private static int ldapsPort; + + @BeforeClass + public static void startLdapServer() throws Exception { + ldapServer = new EmbeddedLDAPServer(); + ldapServer.applyLdif("base.ldif"); + ldapsPort = ldapServer.getLdapsPort(); + } + + @AfterClass + public static void stopLdapServer() throws Exception { + if (ldapServer != null) { + ldapServer.stop(); + } + } + + @Test + public void authenticate_succeeds_provingSnIAndHostnameVerification() throws Exception { + Settings settings = Settings.builder() + .putList(ConfigConstants.LDAP_HOSTS, "localhost:" + ldapsPort) + .put("users.u1.search", "(uid={0})") + .put(ConfigConstants.LDAPS_ENABLE_SSL, true) + .put(ConfigConstants.LDAPS_VERIFY_HOSTNAMES, true) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) + .put("path.home", ".") + .build(); + + User user = new LDAPAuthenticationBackend2(settings, null).authenticate( + new AuthenticationContext(new AuthCredentials("jacksonm", "secret".getBytes())) + ); + + assertThat(user, is(notNullValue())); + assertThat(user.getName(), is("cn=Michael Jackson,ou=people,o=TEST")); + } +} diff --git a/src/test/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactoryTest.java b/src/test/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactoryTest.java new file mode 100644 index 0000000000..0eccde4ad7 --- /dev/null +++ b/src/test/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactoryTest.java @@ -0,0 +1,217 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import java.net.InetAddress; +import java.net.Socket; +import java.util.List; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class SNISettingTLSSocketFactoryTest { + + private final SNISettingTLSSocketFactory factory = new SNISettingTLSSocketFactory(null); + + @After + public void clearThreadLocal() { + SNISettingTLSSocketFactory.clearContext(); + } + + // --- configureSocket --- + + @Test + public void configureSocket_setsSniAndEndpointIdentification_whenVerifyHostname() throws Exception { + SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory.configure("example.com", true); + + factory.configureSocket(socket); + + List serverNames = socket.getSSLParameters().getServerNames(); + assertEquals(1, serverNames.size()); + assertEquals("example.com", ((SNIHostName) serverNames.get(0)).getAsciiName()); + assertEquals("LDAPS", socket.getSSLParameters().getEndpointIdentificationAlgorithm()); + } + + @Test + public void configureSocket_setsSniButNotEndpointIdentification_whenVerifyHostnameDisabled() throws Exception { + SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory.configure("example.com", false); + + factory.configureSocket(socket); + + List serverNames = socket.getSSLParameters().getServerNames(); + assertEquals(1, serverNames.size()); + assertEquals("example.com", ((SNIHostName) serverNames.get(0)).getAsciiName()); + assertNull(socket.getSSLParameters().getEndpointIdentificationAlgorithm()); + } + + @Test + public void configureSocket_skipsSnForIpAddress() throws Exception { + SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory.configure("192.168.1.1", true); + + factory.configureSocket(socket); + + assertNull(socket.getSSLParameters().getServerNames()); + assertEquals("LDAPS", socket.getSSLParameters().getEndpointIdentificationAlgorithm()); + } + + @Test + public void configureSocket_skipsWhenNoHostname() throws Exception { + SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + + factory.configureSocket(socket); + + assertNull(socket.getSSLParameters().getServerNames()); + assertNull(socket.getSSLParameters().getEndpointIdentificationAlgorithm()); + } + + @Test(expected = IllegalArgumentException.class) + public void configureSocket_throwsOnInvalidHostname() throws Exception { + SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory.configure("invalid..hostname", true); + + factory.configureSocket(socket); + } + + @Test + public void configureSocket_passesThroughNonSslSocket() { + Socket socket = new Socket(); + SNISettingTLSSocketFactory.configure("example.com", true); + + Socket result = factory.configureSocket(socket); + + assertSame(socket, result); + } + + // --- cipher suite delegation --- + + @Test + public void getDefaultCipherSuites_delegatesToDelegate() throws Exception { + SSLSocketFactory real = SSLContext.getDefault().getSocketFactory(); + SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(real); + + assertEquals(real.getDefaultCipherSuites(), f.getDefaultCipherSuites()); + } + + @Test + public void getSupportedCipherSuites_delegatesToDelegate() throws Exception { + SSLSocketFactory real = SSLContext.getDefault().getSocketFactory(); + SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(real); + + assertEquals(real.getSupportedCipherSuites(), f.getSupportedCipherSuites()); + } + + // --- createSocket --- + + @Test + public void createSocket_wrappingSocket_configuresSni() throws Exception { + SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); + SNISettingTLSSocketFactory.configure("example.com", false); + + Socket result = f.createSocket(new Socket(), "example.com", 636, true); + + assertSame(sslSocket, result); + assertEquals("example.com", ((SNIHostName) ((SSLSocket) result).getSSLParameters().getServerNames().get(0)).getAsciiName()); + } + + @Test + public void createSocket_stringHost_configuresSni() throws Exception { + SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); + SNISettingTLSSocketFactory.configure("example.com", false); + + Socket result = f.createSocket("example.com", 636); + + assertSame(sslSocket, result); + assertEquals("example.com", ((SNIHostName) ((SSLSocket) result).getSSLParameters().getServerNames().get(0)).getAsciiName()); + } + + @Test + public void createSocket_stringHostWithLocalAddress_configuresSni() throws Exception { + SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); + SNISettingTLSSocketFactory.configure("example.com", false); + + Socket result = f.createSocket("example.com", 636, InetAddress.getLoopbackAddress(), 0); + + assertSame(sslSocket, result); + assertEquals("example.com", ((SNIHostName) ((SSLSocket) result).getSSLParameters().getServerNames().get(0)).getAsciiName()); + } + + @Test + public void createSocket_inetAddress_configuresSni() throws Exception { + SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); + SNISettingTLSSocketFactory.configure("example.com", false); + + Socket result = f.createSocket(InetAddress.getLoopbackAddress(), 636); + + assertSame(sslSocket, result); + assertEquals("example.com", ((SNIHostName) ((SSLSocket) result).getSSLParameters().getServerNames().get(0)).getAsciiName()); + } + + @Test + public void createSocket_inetAddressWithLocalAddress_configuresSni() throws Exception { + SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); + SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); + SNISettingTLSSocketFactory.configure("example.com", false); + + Socket result = f.createSocket(InetAddress.getLoopbackAddress(), 636, InetAddress.getLoopbackAddress(), 0); + + assertSame(sslSocket, result); + assertEquals("example.com", ((SNIHostName) ((SSLSocket) result).getSSLParameters().getServerNames().get(0)).getAsciiName()); + } + + private static SSLSocketFactory stubDelegate(Socket socket) { + return new SSLSocketFactory() { + public String[] getDefaultCipherSuites() { + return new String[0]; + } + + public String[] getSupportedCipherSuites() { + return new String[0]; + } + + public Socket createSocket(Socket s, String host, int port, boolean autoClose) { + return socket; + } + + public Socket createSocket(String host, int port) { + return socket; + } + + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) { + return socket; + } + + public Socket createSocket(InetAddress host, int port) { + return socket; + } + + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) { + return socket; + } + }; + } +} diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index 0cd6a364d8..779b1b3a8b 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -11,11 +11,16 @@ package org.opensearch.security.authtoken.jwt; +import java.io.File; +import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; import java.util.Base64; import java.util.Date; import java.util.List; import java.util.function.LongSupplier; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; import org.apache.commons.lang3.RandomStringUtils; import org.apache.logging.log4j.Level; @@ -23,13 +28,16 @@ import org.apache.logging.log4j.core.Appender; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.Logger; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.util.KeyUtils; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.jwk.JWK; @@ -42,6 +50,7 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.core.IsNull.notNullValue; +import static org.opensearch.security.authtoken.jwt.JwtVendor.SIGNING_KEY_PROPERTY_KEY; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -50,6 +59,9 @@ import static org.mockito.Mockito.when; public class JwtVendorTest { + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); + private Appender mockAppender; private ArgumentCaptor logEventCaptor; @@ -59,7 +71,7 @@ public class JwtVendorTest { @Test public void testCreateJwkFromSettings() { - final Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); + final Settings settings = Settings.builder().put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded).build(); final Tuple jwk = JwtVendor.createJwkFromSettings(settings); assertThat(jwk.v1().getAlgorithm().getName(), is("HS512")); @@ -69,7 +81,7 @@ public void testCreateJwkFromSettings() { @Test public void testCreateJwkFromSettingsWithWeakKey() { - Settings settings = Settings.builder().put("signing_key", "abcd1234").build(); + Settings settings = Settings.builder().put(SIGNING_KEY_PROPERTY_KEY, "abcd1234").build(); Throwable exception = assertThrows(OpenSearchException.class, () -> JwtVendor.createJwkFromSettings(settings)); assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); } @@ -94,7 +106,7 @@ public void testCreateJwtWithRolesNoEncryptionKey() throws Exception { int expirySeconds = 300; LongSupplier currentTime = () -> 1696413600000L; // No encryption_key — roles should be written as plain 'dr' claim - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); + Settings settings = Settings.builder().put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded).build(); Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); JwtVendor OBOJwtVendor = new JwtVendor(settings); @@ -127,7 +139,10 @@ public void testCreateJwtWithRoles() throws Exception { // 2023 oct 4, 10:00:00 AM GMT LongSupplier currentTime = () -> 1696413600000L; String claimsEncryptionKey = "1234567890123456"; - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + Settings settings = Settings.builder() + .put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded) + .put("encryption_key", claimsEncryptionKey) + .build(); Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); @@ -176,7 +191,7 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { LongSupplier currentTime = () -> (long) 100; String claimsEncryptionKey = "1234567890123456"; Settings settings = Settings.builder() - .put("signing_key", signingKeyB64Encoded) + .put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded) .put("encryption_key", claimsEncryptionKey) .put(ConfigConstants.EXTENSIONS_BWC_PLUGIN_MODE, true) .build(); @@ -226,7 +241,10 @@ public void testCreateJwtLogsCorrectly() throws Exception { // Mock settings and other required dependencies LongSupplier currentTime = () -> (long) 100; String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + Settings settings = Settings.builder() + .put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded) + .put("encryption_key", claimsEncryptionKey) + .build(); final String issuer = "cluster_0"; final String subject = "admin"; @@ -260,4 +278,52 @@ public void testCreateJwtLogsCorrectly() throws Exception { assertTrue(parts.length >= 3); } + @Test + public void testCreateJwkFromKeystoreSettings() throws Exception { + final byte[] keyBytes = Base64.getDecoder().decode(signingKeyB64Encoded); + final SecretKey hmacKey = new SecretKeySpec(keyBytes, "HmacSHA512"); + + final KeyStore ks = KeyStore.getInstance("BCFKS"); + ks.load(null, null); + ks.setKeyEntry("jwt-signing", hmacKey, "keypass".toCharArray(), null); + + final File tempKs = tempDir.newFile("test-jwt-ks.bcfks"); + try (var out = new FileOutputStream(tempKs)) { + ks.store(out, "kspass".toCharArray()); + } + + final Settings settings = Settings.builder() + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_PATH, tempKs.getAbsolutePath()) + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_TYPE, "BCFKS") + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_PASSWORD, "kspass") + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_ALIAS, "jwt-signing") + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_KEY_PASSWORD, "keypass") + .build(); + + final Tuple jwk = JwtVendor.createJwkFromSettings(settings); + assertThat(jwk.v1().getAlgorithm().getName(), is("HS512")); + assertThat(jwk.v1().getKeyUse().toString(), is("sig")); + assertThat(jwk.v1().toOctetSequenceKey().getKeyValue().decode(), equalTo(keyBytes)); + } + + @Test + public void testCreateJwkFromKeystoreSettingsNonexistentAlias() throws Exception { + final KeyStore ks = KeyStore.getInstance("BCFKS"); + ks.load(null, null); + + final File tempKs = tempDir.newFile("test-jwt-ks-empty.bcfks"); + try (var out = new FileOutputStream(tempKs)) { + ks.store(out, "kspass".toCharArray()); + } + + final Settings settings = Settings.builder() + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_PATH, tempKs.getAbsolutePath()) + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_TYPE, "BCFKS") + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_PASSWORD, "kspass") + .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_ALIAS, "nonexistent") + .build(); + + assertThrows(IllegalArgumentException.class, () -> JwtVendor.createJwkFromSettings(settings)); + } + } diff --git a/src/test/java/org/opensearch/security/hasher/AbstractPasswordHasherTests.java b/src/test/java/org/opensearch/security/hasher/AbstractPasswordHasherTests.java index 5e420c9c78..d05d449b23 100644 --- a/src/test/java/org/opensearch/security/hasher/AbstractPasswordHasherTests.java +++ b/src/test/java/org/opensearch/security/hasher/AbstractPasswordHasherTests.java @@ -17,6 +17,7 @@ import org.junit.Test; import org.opensearch.OpenSearchSecurityException; +import org.opensearch.security.support.FipsMode; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; @@ -27,7 +28,7 @@ public abstract class AbstractPasswordHasherTests extends LuceneTestCase { PasswordHasher passwordHasher; - final String password = "testPassword"; + final String password = FipsMode.isEnabled() ? "notarealpassword" : "testPassword"; final String wrongPassword = "wrongTestPassword"; @Test @@ -76,18 +77,18 @@ public void shouldHandleNullHashWhenChecking() { @Test public void shouldCleanupPasswordCharArray() { - char[] password = new char[] { 'p', 'a', 's', 's', 'w', 'o', 'r', 'd' }; - passwordHasher.hash(password); - assertThat("\0\0\0\0\0\0\0\0", is(new String(password))); + char[] passwordAsChar = password.toCharArray(); + passwordHasher.hash(passwordAsChar); + assertThat("\0".repeat(password.length()), is(new String(passwordAsChar))); } @Test public void shouldCleanupPasswordCharBuffer() { - char[] password = new char[] { 'p', 'a', 's', 's', 'w', 'o', 'r', 'd' }; - CharBuffer passwordBuffer = CharBuffer.wrap(password); - passwordHasher.hash(password); - assertThat("\0\0\0\0\0\0\0\0", is(new String(password))); - assertThat("\0\0\0\0\0\0\0\0", is(passwordBuffer.toString())); + char[] passwordAsChar = password.toCharArray(); + CharBuffer passwordBuffer = CharBuffer.wrap(passwordAsChar); + passwordHasher.hash(passwordAsChar); + assertThat("\0".repeat(password.length()), is(new String(passwordAsChar))); + assertThat("\0".repeat(password.length()), is(passwordBuffer.toString())); } } diff --git a/src/test/java/org/opensearch/security/hasher/BCryptPasswordHasherTests.java b/src/test/java/org/opensearch/security/hasher/BCryptPasswordHasherTests.java index ee950f1058..41c709aad4 100644 --- a/src/test/java/org/opensearch/security/hasher/BCryptPasswordHasherTests.java +++ b/src/test/java/org/opensearch/security/hasher/BCryptPasswordHasherTests.java @@ -36,8 +36,8 @@ public void setup() { @Test public void shouldBeBackwardsCompatible() { String legacyHash = "$2y$12$gdh2ecVBQmwpmcAeyReicuNtXyR6GMWSfXHxtcBBqFeFz2VQ8kDZe"; - assertThat(passwordHasher.check(password.toCharArray(), legacyHash), is(true)); - assertThat(passwordHasher.check(wrongPassword.toCharArray(), legacyHash), is(false)); + assertThat(passwordHasher.check("testPassword".toCharArray(), legacyHash), is(true)); + assertThat(passwordHasher.check("wrongTestPassword".toCharArray(), legacyHash), is(false)); } @Test diff --git a/src/test/java/org/opensearch/security/hasher/PBKDF2PasswordHasherTests.java b/src/test/java/org/opensearch/security/hasher/PBKDF2PasswordHasherTests.java index 6d54173a10..9f3bab1e8e 100644 --- a/src/test/java/org/opensearch/security/hasher/PBKDF2PasswordHasherTests.java +++ b/src/test/java/org/opensearch/security/hasher/PBKDF2PasswordHasherTests.java @@ -13,11 +13,15 @@ import org.junit.Before; import org.junit.Test; +import org.bouncycastle.crypto.fips.FipsUnapprovedOperationError; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeTrue; public class PBKDF2PasswordHasherTests extends AbstractPasswordHasherTests { @@ -57,4 +61,11 @@ public void shouldGenerateValidHashesFromParameters() { assertThat(hasher.check(password.toCharArray(), hash), is(true)); assertThat(hasher.check(wrongPassword.toCharArray(), hash), is(false)); } + + @Test + public void shouldThrowExceptionForWeekPassword() { + assumeTrue("BCFIPS provider is required", FipsMode.isEnabled()); + var hasher = new PBKDF2PasswordHasher("SHA512", 10000, 512); + assertThrows(FipsUnapprovedOperationError.class, () -> hasher.hash("test".toCharArray())); + } } diff --git a/src/test/java/org/opensearch/security/ssl/CertificateValidatorTest.java b/src/test/java/org/opensearch/security/ssl/CertificateValidatorTest.java index 3740eb91d1..d52863fe11 100644 --- a/src/test/java/org/opensearch/security/ssl/CertificateValidatorTest.java +++ b/src/test/java/org/opensearch/security/ssl/CertificateValidatorTest.java @@ -35,11 +35,11 @@ import org.apache.logging.log4j.Logger; import org.junit.Assert; import org.junit.Test; -import org.bouncycastle.crypto.CryptoServicesRegistrar; import org.opensearch.security.ssl.util.CertificateValidator; import org.opensearch.security.ssl.util.ExceptionUtils; import org.opensearch.security.ssl.util.SSLRequestHelper; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.helper.file.FileHelper; import static org.hamcrest.MatcherAssert.assertThat; @@ -87,9 +87,7 @@ public void testStaticCRL() throws Exception { validator.validate(certsToValidate.toArray(new X509Certificate[0])); Assert.fail(); } catch (GeneralSecurityException e) { - String expectedMessage = CryptoServicesRegistrar.isInApprovedOnlyMode() - ? "Certificate revocation after 2018-05-05" - : "Certificate has been revoked"; + String expectedMessage = FipsMode.isEnabled() ? "Certificate revocation after 2018-05-05" : "Certificate has been revoked"; Assert.assertNotNull(ExceptionUtils.findMsg(e, expectedMessage)); } } @@ -129,9 +127,7 @@ public void testStaticCRLOk() throws Exception { try { validator.validate(certsToValidate.toArray(new X509Certificate[0])); } catch (GeneralSecurityException e) { - String expectedMessage = CryptoServicesRegistrar.isInApprovedOnlyMode() - ? "No CRLs found for issuer" - : "Certificate has been revoked"; + String expectedMessage = FipsMode.isEnabled() ? "No CRLs found for issuer" : "Certificate has been revoked"; Assert.assertNotNull(ExceptionUtils.findMsg(e, expectedMessage)); } } @@ -165,9 +161,7 @@ public void testNoValidationPossible() throws Exception { Assert.fail(); } catch (GeneralSecurityException e) { assertThat(e, instanceOf(CertPathValidatorException.class)); - String expectedMessage = CryptoServicesRegistrar.isInApprovedOnlyMode() - ? "No CRLs found for issuer" - : "Certificate has been revoked"; + String expectedMessage = FipsMode.isEnabled() ? "Certificate revocation after" : "Certificate has been revoked"; Assert.assertNotNull(ExceptionUtils.findMsg(e, expectedMessage)); } } @@ -203,9 +197,7 @@ public void testCRLDP() throws Exception { validator.validate(certsToValidate.toArray(new X509Certificate[0])); Assert.fail(); } catch (GeneralSecurityException e) { - String expectedMessage = CryptoServicesRegistrar.isInApprovedOnlyMode() - ? "No CRLs found for issuer" - : "Certificate has been revoked"; + String expectedMessage = FipsMode.isEnabled() ? "Certificate revocation after" : "Certificate has been revoked"; Assert.assertNotNull(ExceptionUtils.findMsg(e, expectedMessage)); } } diff --git a/src/test/java/org/opensearch/security/ssl/CertificatesRule.java b/src/test/java/org/opensearch/security/ssl/CertificatesRule.java index 65786ba0be..97855c1543 100644 --- a/src/test/java/org/opensearch/security/ssl/CertificatesRule.java +++ b/src/test/java/org/opensearch/security/ssl/CertificatesRule.java @@ -13,9 +13,11 @@ import java.io.IOException; import java.math.BigInteger; +import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; @@ -45,15 +47,20 @@ import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; -import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.opensearch.common.collect.Tuple; +import org.opensearch.security.test.helper.file.FileHelper; + +import static org.opensearch.security.ssl.CertificatesUtils.privateKeyToPemObject; +import static org.opensearch.security.ssl.CertificatesUtils.writePemContent; +import static org.opensearch.security.ssl.util.SSLConfigConstants.DEFAULT_STORE_TYPE; public class CertificatesRule extends ExternalResource { - private final static BouncyCastleFipsProvider BOUNCY_CASTLE_FIPS_PROVIDER = new BouncyCastleFipsProvider(); + /** BC FIPS requires at least 14 characters for PKCS8 key-encryption passwords in approved-only mode. */ + public static final int FIPS_MIN_PASSWORD_LENGTH = 14; private final TemporaryFolder temporaryFolder = new TemporaryFolder(); @@ -61,7 +68,7 @@ public class CertificatesRule extends ExternalResource { private Path configRootFolder; - private final String privateKeyPassword = RandomStringUtils.randomAlphabetic(10); + private final String privateKeyPassword = RandomStringUtils.secure().nextAlphanumeric(FIPS_MIN_PASSWORD_LENGTH); private X509CertificateHolder caCertificateHolder; @@ -128,7 +135,7 @@ public PrivateKey accessCertificatePrivateKey() { } public KeyPair generateKeyPair() throws NoSuchAlgorithmException { - KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", BOUNCY_CASTLE_FIPS_PROVIDER); + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(4096); return generator.generateKeyPair(); } @@ -179,7 +186,7 @@ public X509CertificateHolder generateCaCertificate( endDate ).addExtension(Extension.basicConstraints, true, new BasicConstraints(true)) .addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign | KeyUsage.cRLSign)) - .build(new JcaContentSignerBuilder("SHA256withRSA").setProvider(BOUNCY_CASTLE_FIPS_PROVIDER).build(parentKeyPair.getPrivate())); + .build(new JcaContentSignerBuilder("SHA256withRSA").build(parentKeyPair.getPrivate())); } public Tuple generateAccessCertificate(final KeyPair parentKeyPair) throws NoSuchAlgorithmException, @@ -282,8 +289,39 @@ public Tuple generateAccessCertificate( final Instant startDate, final Instant endDate, final List sans + ) throws NoSuchAlgorithmException, IOException, OperatorCreationException { + return generateCertificate(subject, issuer, parentKeyPair, serialNumber, startDate, endDate, sans, false); + } + + public Tuple generateNodeCertificate(final KeyPair parentKeyPair, final String subject) + throws NoSuchAlgorithmException, IOException, OperatorCreationException { + final var startAndEndDate = generateStartAndEndDate(); + return generateCertificate( + subject, + DEFAULT_SUBJECT_NAME, + parentKeyPair, + generateSerialNumber(), + startAndEndDate.v1(), + startAndEndDate.v2(), + defaultSubjectAlternativeNames(), + true + ); + } + + private Tuple generateCertificate( + final String subject, + final String issuer, + final KeyPair parentKeyPair, + final BigInteger serialNumber, + final Instant startDate, + final Instant endDate, + final List sans, + final boolean includeServerAuth ) throws NoSuchAlgorithmException, IOException, OperatorCreationException { final var keyPair = generateKeyPair(); + final KeyPurposeId[] ekuOids = includeServerAuth + ? new KeyPurposeId[] { KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth } + : new KeyPurposeId[] { KeyPurposeId.id_kp_clientAuth }; final var certificate = createCertificateBuilder( subject, issuer, @@ -298,9 +336,9 @@ public Tuple generateAccessCertificate( true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation | KeyUsage.keyEncipherment) ) - .addExtension(Extension.extendedKeyUsage, true, new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth)) + .addExtension(Extension.extendedKeyUsage, true, new ExtendedKeyUsage(ekuOids)) .addExtension(Extension.subjectAlternativeName, false, new DERSequence(sans.toArray(sans.toArray(new ASN1Encodable[0])))) - .build(new JcaContentSignerBuilder("SHA256withRSA").setProvider(BOUNCY_CASTLE_FIPS_PROVIDER).build(parentKeyPair.getPrivate())); + .build(new JcaContentSignerBuilder("SHA256withRSA").build(parentKeyPair.getPrivate())); return Tuple.tuple(keyPair.getPrivate(), certificate); } @@ -349,4 +387,28 @@ public BigInteger generateSerialNumber() { return BigInteger.valueOf(Instant.now().plusMillis(100).getEpochSecond()); } + public record PemFiles(Path cert, Path key, Path trustedCasPem, FileHelper.TypedStore trustStore, String trustStorePassword) { + } + + public PemFiles generatePemFiles(final String subject, final String password) throws Exception { + final var keyPair = generateKeyPair(); + final X509CertificateHolder caCert = generateCaCertificate(keyPair); + final var nodeKeyAndCert = generateNodeCertificate(keyPair, subject); + final var cert = configRootFolder.resolve("test-node.crt.pem"); + final var key = configRootFolder.resolve("test-node.key.pem"); + final var trustedCasPem = configRootFolder.resolve("test-ca.pem"); + writePemContent(cert, nodeKeyAndCert.v2(), caCert); + writePemContent(key, privateKeyToPemObject(nodeKeyAndCert.v1(), password)); + writePemContent(trustedCasPem, caCert); + final String tsExt = FileHelper.TYPE_TO_EXTENSION_MAP.get(DEFAULT_STORE_TYPE).getFirst(); + final var tsFile = new FileHelper.TypedStore(configRootFolder.resolve("test-ca-truststore" + tsExt), DEFAULT_STORE_TYPE); + final var ts = KeyStore.getInstance(tsFile.type()); + ts.load(null, null); + ts.setCertificateEntry("ca", toX509Certificate(caCert)); + try (var out = Files.newOutputStream(tsFile.path())) { + ts.store(out, password.toCharArray()); + } + return new PemFiles(cert, key, trustedCasPem, tsFile, password); + } + } diff --git a/src/test/java/org/opensearch/security/ssl/CertificatesUtils.java b/src/test/java/org/opensearch/security/ssl/CertificatesUtils.java index e6ad9991ee..0de829819d 100644 --- a/src/test/java/org/opensearch/security/ssl/CertificatesUtils.java +++ b/src/test/java/org/opensearch/security/ssl/CertificatesUtils.java @@ -26,8 +26,8 @@ public class CertificatesUtils { public static void writePemContent(final Path path, final Object... content) throws IOException { - for (final Object c : content) { - try (JcaPEMWriter writer = new JcaPEMWriter(Files.newBufferedWriter(path))) { + try (JcaPEMWriter writer = new JcaPEMWriter(Files.newBufferedWriter(path))) { + for (final Object c : content) { writer.writeObject(c); } } @@ -36,7 +36,7 @@ public static void writePemContent(final Path path, final Object... content) thr public static PemObject privateKeyToPemObject(final PrivateKey privateKey, final String password) throws Exception { return new PKCS8Generator( PrivateKeyInfo.getInstance(privateKey.getEncoded()), - new JceOpenSSLPKCS8EncryptorBuilder(PKCS8Generator.PBE_SHA1_3DES).setRandom(new SecureRandom()) + new JceOpenSSLPKCS8EncryptorBuilder(PKCS8Generator.AES_256_CBC).setRandom(new SecureRandom()) .setPassword(password.toCharArray()) .build() ).generate(); diff --git a/src/test/java/org/opensearch/security/ssl/SSLTest.java b/src/test/java/org/opensearch/security/ssl/SSLTest.java index bab075e196..30b436322f 100644 --- a/src/test/java/org/opensearch/security/ssl/SSLTest.java +++ b/src/test/java/org/opensearch/security/ssl/SSLTest.java @@ -32,6 +32,7 @@ import com.google.common.collect.Lists; import org.apache.hc.core5.http.NoHttpResponseException; import org.junit.Assert; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -54,6 +55,7 @@ import org.opensearch.security.ssl.util.ExceptionUtils; import org.opensearch.security.ssl.util.SSLConfigConstants; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.AbstractSecurityUnitTest; import org.opensearch.security.test.SingleClusterTest; import org.opensearch.security.test.helper.file.FileHelper; @@ -74,6 +76,9 @@ @SuppressWarnings({ "resource", "unchecked" }) public class SSLTest extends SingleClusterTest { + @ClassRule + public static final CertificatesRule certificatesRule = new CertificatesRule(false); + @Rule public final ExpectedException thrown = ExpectedException.none(); @@ -185,14 +190,19 @@ public void testCipherAndProtocols() throws Exception { Security.setProperty("jdk.tls.disabledAlgorithms", ""); + var typedKeyStore = FileHelper.resolveStore("ssl/node-0-keystore"); + var typedTrustStore = FileHelper.resolveStore("ssl/truststore"); + Settings settings = Settings.builder() .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_ENABLED, false) .put(ConfigConstants.SECURITY_SSL_ONLY, true) .put(SSLConfigConstants.SECURITY_SSL_HTTP_KEYSTORE_ALIAS, "node-0") .put(SSLConfigConstants.SECURITY_SSL_HTTP_ENABLED, true) .put(SSLConfigConstants.SECURITY_SSL_HTTP_CLIENTAUTH_MODE, "REQUIRE") - .put(SSLConfigConstants.SECURITY_SSL_HTTP_KEYSTORE_FILEPATH, FileHelper.resolveStore("ssl/node-0-keystore").path()) - .put(SSLConfigConstants.SECURITY_SSL_HTTP_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ssl/truststore").path()) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_KEYSTORE_FILEPATH, typedKeyStore.path()) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_KEYSTORE_TYPE, typedKeyStore.type()) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_TRUSTSTORE_FILEPATH, typedTrustStore.path()) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_TRUSTSTORE_TYPE, typedTrustStore.type()) // WEAK and insecure cipher, do NOT use this, its here for unittesting only!!! .put(SSLConfigConstants.SECURITY_SSL_HTTP_ENABLED_CIPHERS, "SSL_RSA_EXPORT_WITH_RC4_40_MD5") // WEAK and insecure protocol, do NOT use this, its here for unittesting only!!! @@ -213,8 +223,8 @@ public void testCipherAndProtocols() throws Exception { settings = Settings.builder() .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_ENABLED, true) .put(ConfigConstants.SECURITY_SSL_ONLY, true) - .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_KEYSTORE_FILEPATH, FileHelper.resolveStore("ssl/node-0-keystore").path()) - .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ssl/truststore").path()) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_KEYSTORE_FILEPATH, typedKeyStore.path()) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, typedTrustStore.path()) // WEAK and insecure cipher, do NOT use this, its here for unittesting only!!! .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_ENABLED_CIPHERS, "SSL_RSA_EXPORT_WITH_RC4_40_MD5") // WEAK and insecure protocol, do NOT use this, its here for unittesting only!!! @@ -413,35 +423,27 @@ public void testHttpsAndNodeSSLPKCS1Pem() throws Exception { @Test public void testHttpsAndNodeSSLPemEnc() throws Exception { + final String keyPassword = randomAsciiAlphanumOfLength(CertificatesRule.FIPS_MIN_PASSWORD_LENGTH); + final CertificatesRule.PemFiles pem = certificatesRule.generatePemFiles( + "CN=node-0.example.com,OU=SSL,O=Test,L=Test,C=DE", + keyPassword + ); final MockSecureSettings mockSecureSettings = new MockSecureSettings(); - mockSecureSettings.setString(SECURITY_SSL_HTTP_PEMKEY_PASSWORD.propertyName, "changeit"); - mockSecureSettings.setString(SECURITY_SSL_TRANSPORT_PEMKEY_PASSWORD.propertyName, "changeit"); + mockSecureSettings.setString(SECURITY_SSL_HTTP_PEMKEY_PASSWORD.propertyName, keyPassword); + mockSecureSettings.setString(SECURITY_SSL_TRANSPORT_PEMKEY_PASSWORD.propertyName, keyPassword); final Settings settings = Settings.builder() .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_ENABLED, true) .put(ConfigConstants.SECURITY_SSL_ONLY, true) - .put( - SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMCERT_FILEPATH, - FileHelper.getAbsoluteFilePathFromClassPath("ssl/pem/node-4.crt.pem") - ) - .put( - SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMKEY_FILEPATH, - FileHelper.getAbsoluteFilePathFromClassPath("ssl/pem/node-4.key") - ) - .put( - SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMTRUSTEDCAS_FILEPATH, - FileHelper.getAbsoluteFilePathFromClassPath("ssl/root-ca.pem") - ) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMCERT_FILEPATH, pem.cert()) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMKEY_FILEPATH, pem.key()) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMTRUSTEDCAS_FILEPATH, pem.trustedCasPem()) .put(TRANSPORT_SSL_ENFORCE_HOSTNAME_VERIFICATION_KEY, false) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_ENFORCE_HOSTNAME_VERIFICATION_RESOLVE_HOST_NAME, false) - .put(SSLConfigConstants.SECURITY_SSL_HTTP_ENABLED, true) .put(SSLConfigConstants.SECURITY_SSL_HTTP_CLIENTAUTH_MODE, "REQUIRE") - .put( - SSLConfigConstants.SECURITY_SSL_HTTP_PEMCERT_FILEPATH, - FileHelper.getAbsoluteFilePathFromClassPath("ssl/pem/node-4.crt.pem") - ) - .put(SSLConfigConstants.SECURITY_SSL_HTTP_PEMKEY_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("ssl/pem/node-4.key")) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_PEMCERT_FILEPATH, pem.cert()) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_PEMKEY_FILEPATH, pem.key()) .put( SSLConfigConstants.SECURITY_SSL_HTTP_PEMTRUSTEDCAS_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("ssl/root-ca.pem") @@ -454,45 +456,39 @@ public void testHttpsAndNodeSSLPemEnc() throws Exception { RestHelper rh = restHelper(); rh.enableHTTPClientSSL = true; rh.trustHTTPServerCertificate = true; + rh.customTrustStoreFile = pem.trustStore(); + rh.customTrustStorePassword = pem.trustStorePassword(); rh.sendAdminCertificate = true; String res = rh.executeSimpleRequest("_opendistro/_security/sslinfo?pretty"); Assert.assertTrue(res.contains("TLS")); Assert.assertTrue(rh.executeSimpleRequest("_nodes/settings?pretty").contains(clusterInfo.clustername)); - // Assert.assertTrue(!executeSimpleRequest("_opendistro/_security/sslinfo?pretty").contains("null")); Assert.assertTrue(res.contains("CN=node-0.example.com,OU=SSL,O=Test,L=Test,C=DE")); } @Test public void testSSLPemEncWithInsecureSettings() throws Exception { + final String keyPassword = randomAsciiAlphanumOfLength(CertificatesRule.FIPS_MIN_PASSWORD_LENGTH); + final CertificatesRule.PemFiles pem = certificatesRule.generatePemFiles( + "CN=node-0.example.com,OU=SSL,O=Test,L=Test,C=DE", + keyPassword + ); + final Settings settings = Settings.builder() .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_ENABLED, true) .put(ConfigConstants.SECURITY_SSL_ONLY, true) - .put( - SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMCERT_FILEPATH, - FileHelper.getAbsoluteFilePathFromClassPath("ssl/pem/node-4.crt.pem") - ) - .put( - SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMKEY_FILEPATH, - FileHelper.getAbsoluteFilePathFromClassPath("ssl/pem/node-4.key") - ) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMCERT_FILEPATH, pem.cert()) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMKEY_FILEPATH, pem.key()) // legacy insecure passwords - .put(SECURITY_SSL_TRANSPORT_PEMKEY_PASSWORD.insecurePropertyName, "changeit") - .put(SECURITY_SSL_HTTP_PEMKEY_PASSWORD.insecurePropertyName, "changeit") - .put( - SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMTRUSTEDCAS_FILEPATH, - FileHelper.getAbsoluteFilePathFromClassPath("ssl/root-ca.pem") - ) + .put(SECURITY_SSL_TRANSPORT_PEMKEY_PASSWORD.insecurePropertyName, keyPassword) + .put(SECURITY_SSL_HTTP_PEMKEY_PASSWORD.insecurePropertyName, keyPassword) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_PEMTRUSTEDCAS_FILEPATH, pem.trustedCasPem()) .put(TRANSPORT_SSL_ENFORCE_HOSTNAME_VERIFICATION_KEY, false) .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_ENFORCE_HOSTNAME_VERIFICATION_RESOLVE_HOST_NAME, false) - .put(SSLConfigConstants.SECURITY_SSL_HTTP_ENABLED, true) .put(SSLConfigConstants.SECURITY_SSL_HTTP_CLIENTAUTH_MODE, "REQUIRE") - .put( - SSLConfigConstants.SECURITY_SSL_HTTP_PEMCERT_FILEPATH, - FileHelper.getAbsoluteFilePathFromClassPath("ssl/pem/node-4.crt.pem") - ) - .put(SSLConfigConstants.SECURITY_SSL_HTTP_PEMKEY_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("ssl/pem/node-4.key")) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_PEMCERT_FILEPATH, pem.cert()) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_PEMKEY_FILEPATH, pem.key()) .put( SSLConfigConstants.SECURITY_SSL_HTTP_PEMTRUSTEDCAS_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("ssl/root-ca.pem") @@ -504,6 +500,8 @@ public void testSSLPemEncWithInsecureSettings() throws Exception { RestHelper rh = restHelper(); rh.enableHTTPClientSSL = true; rh.trustHTTPServerCertificate = true; + rh.customTrustStoreFile = pem.trustStore(); + rh.customTrustStorePassword = pem.trustStorePassword(); rh.sendAdminCertificate = true; Assert.assertTrue( @@ -627,6 +625,8 @@ public void testHttpsEnforceFail() throws Exception { @Test public void testHttpsV3Fail() throws Exception { + assumeFalse("SSLv3 is not FIPS-approved", FipsMode.isEnabled()); + thrown.expect(SSLHandshakeException.class); final Settings settings = Settings.builder() @@ -824,6 +824,7 @@ public void testCRLPem() throws Exception { FileHelper.getAbsoluteFilePathFromClassPath("ssl/chain-ca.pem") ) .put(SSLConfigConstants.SECURITY_SSL_HTTP_CRL_VALIDATE, true) + .put(SSLConfigConstants.SECURITY_SSL_HTTP_CRL_FILE, FileHelper.getAbsoluteFilePathFromClassPath("ssl/crl/revoked.crl")) .put(SSLConfigConstants.SECURITY_SSL_HTTP_CRL_VALIDATION_DATE, CertificateValidatorTest.CRL_DATE.getTime()) .build(); diff --git a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerReloadListenerTest.java b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerReloadListenerTest.java index 8f02f16613..8826ea9094 100644 --- a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerReloadListenerTest.java +++ b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerReloadListenerTest.java @@ -23,6 +23,7 @@ import java.util.concurrent.TimeUnit; import com.carrotsearch.randomizedtesting.RandomizedTest; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import org.awaitility.Awaitility; import org.junit.After; import org.junit.Before; @@ -36,6 +37,8 @@ import org.opensearch.env.Environment; import org.opensearch.env.TestEnvironment; import org.opensearch.security.ssl.config.CertType; +import org.opensearch.security.support.PemKeyReaderLoadSecretKeyTest; +import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.threadpool.TestThreadPool; import org.opensearch.threadpool.ThreadPool; import org.opensearch.watcher.ResourceWatcherService; @@ -54,6 +57,7 @@ import static org.opensearch.security.ssl.util.SSLConfigConstants.TRUSTSTORE_TYPE; import static org.opensearch.transport.AuxTransport.AUX_TRANSPORT_TYPES_SETTING; +@ThreadLeakFilters(filters = { PemKeyReaderLoadSecretKeyTest.BCFipsEntropyDaemonFilter.class }) public class SslSettingsManagerReloadListenerTest extends RandomizedTest { @ClassRule @@ -84,8 +88,8 @@ public void setUp() throws Exception { ); } - static Path path(final String fileName) { - return certificatesRule.configRootFolder().resolve(fileName); + static Path path(final String filename) { + return certificatesRule.configRootFolder().resolve(filename); } @After @@ -144,6 +148,16 @@ private void reloadSslContextOnJdkStoreFilesChangedForTransportType(CertType cer final String keyStorePathSetting = settingPrefix + KEYSTORE_FILEPATH; final String keyStoreTypeSetting = settingPrefix + KEYSTORE_TYPE; final String certTypeFilePrefix = certType.id().toLowerCase(Locale.ROOT); + FileHelper.TypedStore typedTrustStore = FileHelper.resolveStore( + certificatesRule.configRootFolder(), + certTypeFilePrefix + "_truststore", + ".jks" + ); + FileHelper.TypedStore typedKeyStore = FileHelper.resolveStore( + certificatesRule.configRootFolder(), + certTypeFilePrefix + "_keystore", + ".p12" + ); final var keyStorePassword = randomAsciiAlphanumOfLength(10); final var secureSettings = new MockSecureSettings(); secureSettings.setString(settingPrefix + "truststore_password_secure", keyStorePassword); @@ -156,18 +170,18 @@ private void reloadSslContextOnJdkStoreFilesChangedForTransportType(CertType cer // If certType is TRANSPORT the following line will re-enable it. .put(SECURITY_SSL_TRANSPORT_ENABLED, false) .put(enabledSetting, true) - .put(trustStorePathSetting, path(certTypeFilePrefix + "_truststore.jks")) - .put(trustStoreTypeSetting, "jks") - .put(keyStorePathSetting, path(certTypeFilePrefix + "_keystore.p12")) - .put(keyStoreTypeSetting, "pkcs12") + .put(trustStorePathSetting, typedTrustStore.path()) + .put(trustStoreTypeSetting, typedTrustStore.type()) + .put(keyStorePathSetting, typedKeyStore.path()) + .put(keyStoreTypeSetting, typedKeyStore.type()) .setSecureSettings(secureSettings) .build(), (filePrefix, caCertificate, accessKeyAndCertificate) -> { - final var trustStore = KeyStore.getInstance("jks"); + final var trustStore = KeyStore.getInstance(typedTrustStore.type()); trustStore.load(null, null); trustStore.setCertificateEntry("ca", certificatesRule.toX509Certificate(caCertificate)); - writeStore(trustStore, path(String.format("%s_truststore.jks", filePrefix)), keyStorePassword); - final var keyStore = KeyStore.getInstance("pkcs12"); + writeStore(trustStore, typedTrustStore.path(), keyStorePassword); + final var keyStore = KeyStore.getInstance(typedKeyStore.type()); keyStore.load(null, null); keyStore.setKeyEntry( "pk", @@ -175,7 +189,7 @@ private void reloadSslContextOnJdkStoreFilesChangedForTransportType(CertType cer certificatesRule.privateKeyPassword().toCharArray(), new X509Certificate[] { certificatesRule.toX509Certificate(accessKeyAndCertificate.v2()) } ); - writeStore(keyStore, path(String.format("%s_keystore.p12", filePrefix)), keyStorePassword); + writeStore(keyStore, typedKeyStore.path(), keyStorePassword); } ); } diff --git a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java index 8e4131e173..4008e89b37 100644 --- a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java +++ b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java @@ -16,6 +16,7 @@ import java.util.Locale; import com.carrotsearch.randomizedtesting.RandomizedTest; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import org.junit.BeforeClass; import org.junit.ClassRule; import org.junit.Test; @@ -26,6 +27,7 @@ import org.opensearch.env.Environment; import org.opensearch.env.TestEnvironment; import org.opensearch.security.ssl.config.CertType; +import org.opensearch.security.support.PemKeyReaderLoadSecretKeyTest; import io.netty.handler.ssl.ClientAuth; import io.netty.handler.ssl.SslContext; @@ -66,6 +68,7 @@ import static org.opensearch.transport.AuxTransport.AUX_TRANSPORT_TYPES_SETTING; import static org.junit.Assert.assertThrows; +@ThreadLeakFilters(filters = { PemKeyReaderLoadSecretKeyTest.BCFipsEntropyDaemonFilter.class }) public class SslSettingsManagerTest extends RandomizedTest { @ClassRule diff --git a/src/test/java/org/opensearch/security/ssl/config/JdkSslCertificatesLoaderTest.java b/src/test/java/org/opensearch/security/ssl/config/JdkSslCertificatesLoaderTest.java index 9a00d1ca59..74efa001d7 100644 --- a/src/test/java/org/opensearch/security/ssl/config/JdkSslCertificatesLoaderTest.java +++ b/src/test/java/org/opensearch/security/ssl/config/JdkSslCertificatesLoaderTest.java @@ -25,6 +25,7 @@ import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.MockSecureSettings; import org.opensearch.env.TestEnvironment; +import org.opensearch.security.support.FipsMode; import static java.util.Objects.isNull; import static org.hamcrest.CoreMatchers.is; @@ -270,7 +271,9 @@ private void testJdkBasedSslConfiguration(final String sslConfigPrefix, final bo } String randomKeyStoreType() { - return randomFrom(new String[] { "jks", "pkcs12", null }); + return FipsMode.isEnabled() + ? randomFrom(new String[] { "bcfks", null }) + : randomFrom(new String[] { "bcfks", "jks", "pkcs12", null }); } String randomKeyStorePassword(final boolean useSecurePassword) { @@ -282,7 +285,7 @@ Path createTrustStore(final String type, final String password, Map SanParser.parse(x509)); assertThat(ex.getCause(), instanceOf(UnknownHostException.class)); @@ -71,7 +71,7 @@ public void badIpSan_fipsMode_throwsRuntimeException() throws Exception { @Test public void badIpSan_nonFipsMode_returnsEmpty() throws Exception { - Assume.assumeFalse(CryptoServicesRegistrar.isInApprovedOnlyMode()); + Assume.assumeFalse(FipsMode.isEnabled()); assertThat(SanParser.parse(buildCertWithBadIpSan()), is("")); } @@ -80,7 +80,7 @@ public void badIpSan_nonFipsMode_returnsEmpty() throws Exception { /** Builds a self-signed cert with a 3-byte iPAddress SAN (invalid: must be 4 or 16 bytes). */ private static X509Certificate buildCertWithBadIpSan() throws Exception { KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); - kpg.initialize(1024); + kpg.initialize(2048); KeyPair kp = kpg.generateKeyPair(); X500Name dn = new X500Name("CN=test"); Date now = new Date(); diff --git a/src/test/java/org/opensearch/security/support/FipsModeTest.java b/src/test/java/org/opensearch/security/support/FipsModeTest.java new file mode 100644 index 0000000000..06f5ebe6ad --- /dev/null +++ b/src/test/java/org/opensearch/security/support/FipsModeTest.java @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.security.support; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertThrows; + +public class FipsModeTest { + + private java.util.function.Supplier originalSupplier; + + @Before + public void saveSupplier() { + originalSupplier = FipsMode.envSupplier; + } + + @After + public void restoreSupplier() { + FipsMode.envSupplier = originalSupplier; + } + + @Test + public void isEnabled_isTrueOnlyForCaseInsensitiveTrueValue() { + for (String enabled : new String[] { "true", "TRUE", "True" }) { + FipsMode.envSupplier = () -> enabled; + assertThat("expected enabled for: " + enabled, FipsMode.isEnabled(), equalTo(true)); + } + for (String disabled : new String[] { "false", null, "", "yes" }) { + FipsMode.envSupplier = () -> disabled; + assertThat("expected disabled for: " + disabled, FipsMode.isEnabled(), equalTo(false)); + } + } + + @Test + public void constructor_isNotInstantiable() throws Exception { + Constructor constructor = FipsMode.class.getDeclaredConstructor(); + constructor.setAccessible(true); + Exception ex = assertThrows(InvocationTargetException.class, constructor::newInstance); + assertThat(ex.getCause(), instanceOf(UnsupportedOperationException.class)); + } +} diff --git a/src/test/java/org/opensearch/security/support/PemKeyReaderDetectStoreTypeTest.java b/src/test/java/org/opensearch/security/support/PemKeyReaderDetectStoreTypeTest.java index 133d8b400c..9196ba5ca3 100644 --- a/src/test/java/org/opensearch/security/support/PemKeyReaderDetectStoreTypeTest.java +++ b/src/test/java/org/opensearch/security/support/PemKeyReaderDetectStoreTypeTest.java @@ -15,14 +15,11 @@ import java.io.FileOutputStream; import java.io.UncheckedIOException; import java.security.KeyStore; -import java.security.Security; import org.apache.lucene.tests.util.LuceneTestCase; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; -import org.bouncycastle.crypto.CryptoServicesRegistrar; -import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -33,25 +30,19 @@ public class PemKeyReaderDetectStoreTypeTest extends LuceneTestCase { - static { - if (Security.getProvider("BCFIPS") == null) { - Security.addProvider(new BouncyCastleFipsProvider()); - } - } - @Rule public TemporaryFolder tempDir = new TemporaryFolder(); @Test public void detectsJks() throws Exception { - assumeFalse("JKS truststores are not supported in FIPS mode", CryptoServicesRegistrar.isInApprovedOnlyMode()); + assumeFalse("JKS truststores are not supported in FIPS mode", FipsMode.isEnabled()); File file = storeFile("JKS"); assertThat(PemKeyReader.extractStoreType(file.getAbsolutePath(), null), equalTo(PemKeyReader.JKS)); } @Test public void detectsPkcs12() throws Exception { - assumeFalse("PKCS12 truststores are not supported in FIPS mode", CryptoServicesRegistrar.isInApprovedOnlyMode()); + assumeFalse("PKCS12 truststores are not supported in FIPS mode", FipsMode.isEnabled()); File file = storeFile("PKCS12"); assertThat(PemKeyReader.extractStoreType(file.getAbsolutePath(), null), equalTo(PemKeyReader.PKCS12)); } @@ -65,7 +56,7 @@ public void detectsBcfks() throws Exception { @Test public void explicitTypeSkipsDetection() throws Exception { // file content is irrelevant when type is explicitly provided - assumeFalse("PKCS12 truststores are not supported in FIPS mode", CryptoServicesRegistrar.isInApprovedOnlyMode()); + assumeFalse("PKCS12 truststores are not supported in FIPS mode", FipsMode.isEnabled()); File file = tempDir.newFile("irrelevant.bin"); try (FileOutputStream fos = new FileOutputStream(file)) { fos.write(new byte[] { 0x00, 0x01, 0x02, 0x03 }); diff --git a/src/test/java/org/opensearch/security/support/PemKeyReaderLoadKeyStoreTest.java b/src/test/java/org/opensearch/security/support/PemKeyReaderLoadKeyStoreTest.java new file mode 100644 index 0000000000..ac44f4f0e3 --- /dev/null +++ b/src/test/java/org/opensearch/security/support/PemKeyReaderLoadKeyStoreTest.java @@ -0,0 +1,68 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.support; + +import java.io.File; +import java.io.FileOutputStream; +import java.security.KeyStore; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import org.opensearch.OpenSearchException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +public class PemKeyReaderLoadKeyStoreTest { + + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); + + @Test + public void returnsNullWhenPathIsNullAndTypeIsNotPkcs11() throws Exception { + assertNull(PemKeyReader.loadKeyStore(null, null, PemKeyReader.BCFKS)); + } + + @Test + public void loadsBcfksStore() throws Exception { + File file = storeFile(); + KeyStore ks = PemKeyReader.loadKeyStore(file.getAbsolutePath(), null, PemKeyReader.BCFKS); + assertThat(ks, notNullValue()); + assertThat(ks.getType(), equalTo(PemKeyReader.BCFKS)); + } + + @Test + public void pkcs11ThrowsDescriptiveExceptionWhenProviderUnconfigured() { + // In a standard test environment there is no PKCS#11 token configured, + // so getInstance or load will fail — verify our message wraps it. + OpenSearchException ex = assertThrows(OpenSearchException.class, () -> PemKeyReader.loadKeyStore(null, null, PemKeyReader.PKCS11)); + assertThat(ex.getMessage(), containsString("Failed to initialize PKCS#11 keystore")); + assertThat(ex.getCause(), notNullValue()); + assertThat(ex.getCause().getMessage(), containsString("PKCS11 not found")); + } + + private File storeFile() throws Exception { + File file = tempDir.newFile("store.bcfks"); + KeyStore ks = KeyStore.getInstance("BCFKS"); + ks.load(null, null); + try (FileOutputStream fos = new FileOutputStream(file)) { + ks.store(fos, new char[0]); + } + return file; + } +} diff --git a/src/test/java/org/opensearch/security/support/PemKeyReaderLoadSecretKeyTest.java b/src/test/java/org/opensearch/security/support/PemKeyReaderLoadSecretKeyTest.java new file mode 100644 index 0000000000..9123ed4f8f --- /dev/null +++ b/src/test/java/org/opensearch/security/support/PemKeyReaderLoadSecretKeyTest.java @@ -0,0 +1,167 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.support; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import com.carrotsearch.randomizedtesting.RandomizedRunner; +import com.carrotsearch.randomizedtesting.ThreadFilter; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; + +import org.opensearch.test.BouncyCastleThreadFilter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static com.carrotsearch.randomizedtesting.RandomizedTest.randomFrom; +import static org.junit.Assert.assertThrows; + +@RunWith(RandomizedRunner.class) +@ThreadLeakFilters(filters = { BouncyCastleThreadFilter.class, PemKeyReaderLoadSecretKeyTest.BCFipsEntropyDaemonFilter.class }) +public class PemKeyReaderLoadSecretKeyTest { + + // "BC FIPS Entropy Daemon" is not yet covered by the framework's BouncyCastleThreadFilter. + public static class BCFipsEntropyDaemonFilter implements ThreadFilter { + @Override + public boolean reject(Thread t) { + return "BC FIPS Entropy Daemon".equals(t.getName()); + } + } + + private static final SecretKey SECRET_KEY = new SecretKeySpec( + "unit-test-hmac-key-256-bits!!!!!".getBytes(StandardCharsets.US_ASCII), + "HmacSHA256" + ); + + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); + + private String storeType; + + @Before + public void setup() { + storeType = randomKeyStoreType(); + } + + @Test + public void loadsSecretKeyByAlias() throws Exception { + File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); + SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", storeType, "test-key", "keypass"); + assertThat(loaded, notNullValue()); + assertThat(loaded.getEncoded(), equalTo(SECRET_KEY.getEncoded())); + } + + @Test + public void keyPasswordFallsBackToKeystorePassword() throws Exception { + File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "kspass", storeType); + SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", storeType, "test-key", null); + assertThat(loaded.getEncoded(), equalTo(SECRET_KEY.getEncoded())); + } + + @Test + public void throwsWhenPathIsNull() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> PemKeyReader.loadSecretKeyFromKeystore(null, "kspass", storeType, "test-key", "keypass") + ); + assertThat(ex.getMessage(), containsString("Failed to load secret key from keystore-type ")); + assertThat(ex.getMessage(), containsString(storeType)); + assertThat(ex.getMessage(), containsString("at path 'null'")); + } + + @Test + public void throwsForNonexistentAlias() throws Exception { + File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", storeType, "no-such-alias", "keypass") + ); + assertThat(ex.getMessage(), containsString("No key found at alias")); + assertThat(ex.getMessage(), containsString("no-such-alias")); + } + + @Test + public void throwsForWrongKeystorePassword() throws Exception { + File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "wrongkspass", storeType, "test-key", "keypass") + ); + assertThat(ex.getMessage(), containsString("Failed to load secret key from keystore-type ")); + assertThat(ex.getMessage(), containsString(storeType)); + assertThat(ex.getMessage(), containsString(ks.getAbsolutePath())); + } + + @Test + public void throwsForWrongKeyPassword() throws Exception { + File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", storeType, "test-key", "wrongkeypass") + ); + assertThat(ex.getMessage(), containsString("Failed to load secret key from keystore-type ")); + assertThat(ex.getMessage(), containsString(storeType)); + assertThat(ex.getMessage(), containsString(ks.getAbsolutePath())); + } + + @Test + public void autoDetectsStoreTypeFromFileContentWhenTypeIsNull() throws Exception { + File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); + SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", null, "test-key", "keypass"); + assertThat(loaded, notNullValue()); + assertThat(loaded.getEncoded(), equalTo(SECRET_KEY.getEncoded())); + } + + @Test + public void throwsWhenAliasHoldsNonSecretKey() { + String ksPath = getClass().getClassLoader().getResource("kirk-keystore.bcfks").getPath(); + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> PemKeyReader.loadSecretKeyFromKeystore(ksPath, "changeit", "BCFKS", "kirk", "changeit") + ); + assertThat(ex.getMessage(), containsString("kirk")); + assertThat(ex.getMessage(), containsString("is not a SecretKey")); + } + + private File storeKeystoreWithKey(String alias, SecretKey key, String ksPassword, String keyPassword, String storeType) + throws Exception { + File file = tempDir.newFile("test." + storeType.toLowerCase()); + KeyStore ks = KeyStore.getInstance(storeType); + ks.load(null, null); + ks.setKeyEntry(alias, key, keyPassword.toCharArray(), null); + try (FileOutputStream fos = new FileOutputStream(file)) { + ks.store(fos, ksPassword.toCharArray()); + } + return file; + } + + private String randomKeyStoreType() { + // JKS is excluded: its engineSetKeyEntry enforces instanceof PrivateKey (the asymmetric-key + // interface), so SecretKey entries are rejected with "Cannot store non-PrivateKeys". + // JCEKS was introduced specifically to extend JKS with SecretKey support. + // BCFKS (BC FIPS) also supports SecretKey and is the only FIPS-approved option. + return FipsMode.isEnabled() // + ? randomFrom(new String[] { "bcfks" }) // + : randomFrom(new String[] { "bcfks", "jceks", "pkcs12" }); + } +} diff --git a/src/test/java/org/opensearch/security/test/AbstractSecurityUnitTest.java b/src/test/java/org/opensearch/security/test/AbstractSecurityUnitTest.java index 86dbf15493..06f95aacf1 100644 --- a/src/test/java/org/opensearch/security/test/AbstractSecurityUnitTest.java +++ b/src/test/java/org/opensearch/security/test/AbstractSecurityUnitTest.java @@ -83,6 +83,7 @@ import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.ssl.util.SSLConfigConstants; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.test.helper.cluster.ClusterHelper; import org.opensearch.security.test.helper.cluster.ClusterInfo; @@ -167,7 +168,7 @@ protected RestHighLevelClient getRestClient( var typedKeyStore = FileHelper.resolveStore(prefix + keyStoreName); File keyStoreFile = typedKeyStore.path().toFile(); KeyStore keyStore = KeyStore.getInstance(typedKeyStore.type()); - keyStore.load(new FileInputStream(keyStoreFile), null); + keyStore.load(new FileInputStream(keyStoreFile), "changeit".toCharArray()); sslContextBuilder.loadKeyMaterial(keyStore, "changeit".toCharArray()); var typedTrustStore = FileHelper.resolveStore(prefix + trustStoreName); @@ -179,11 +180,13 @@ protected RestHighLevelClient getRestClient( SSLContext sslContext = sslContextBuilder.build(); HttpHost httpHost = new HttpHost("https", info.httpHost, info.httpPort); - + String[] tlsVersions = FipsMode.isEnabled() + ? new String[] { "TLSv1.2", "TLSv1.3" } + : new String[] { "TLSv1", "TLSv1.1", "TLSv1.2", "SSLv3" }; RestClientBuilder restClientBuilder = RestClient.builder(httpHost).setHttpClientConfigCallback(builder -> { TlsStrategy tlsStrategy = ClientTlsStrategyBuilder.create() .setSslContext(sslContext) - .setTlsVersions(new String[] { "TLSv1", "TLSv1.1", "TLSv1.2", "SSLv3" }) + .setTlsVersions(tlsVersions) .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) // See please https://issues.apache.org/jira/browse/HTTPCLIENT-2219 .setTlsDetailsFactory(new Factory() { diff --git a/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java b/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java index 6ada07addd..a93343e261 100644 --- a/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java +++ b/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java @@ -63,6 +63,10 @@ import org.opensearch.http.HttpInfo; import org.opensearch.node.Node; import org.opensearch.node.PluginAwareNode; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.AbstractSecurityUnitTest; import org.opensearch.security.test.NodeSettingsSupplier; import org.opensearch.security.test.SingleClusterTest; @@ -466,7 +470,7 @@ private Settings.Builder getMinimumNonSecurityNodeSettingsBuilder( int httpPort ) { - return AbstractSecurityUnitTest.nodeRolesSettings(Settings.builder(), isClusterManagerNode, isDataNode) + Settings.Builder builder = AbstractSecurityUnitTest.nodeRolesSettings(Settings.builder(), isClusterManagerNode, isDataNode) .put("node.name", "node_" + clustername + "_num" + nodenum) .put("cluster.name", clustername) .put("path.data", "./target/data/" + clustername + "/data") @@ -483,6 +487,12 @@ private Settings.Builder getMinimumNonSecurityNodeSettingsBuilder( .put("http.cors.enabled", true) .put("path.home", "./target") .put("bootstrap.serial_filter", true); + + if (FipsMode.isEnabled()) { + builder.put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.PBKDF2); + } + + return builder; } private enum ClusterState { diff --git a/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java b/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java index b19042dee4..dd8e9d21af 100644 --- a/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java +++ b/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java @@ -47,7 +47,6 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.bouncycastle.crypto.CryptoServicesRegistrar; import org.opensearch.common.io.Streams; import org.opensearch.common.xcontent.XContentFactory; @@ -56,6 +55,7 @@ import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.support.FipsMode; import static org.opensearch.core.xcontent.DeprecationHandler.THROW_UNSUPPORTED_OPERATION; @@ -107,7 +107,7 @@ public static KeyStore getKeystoreFromClassPath(final String baseName, String pa *

* The format is chosen based on the runtime environment: *

    - *
  • FIPS approved-only mode ({@link CryptoServicesRegistrar#isInApprovedOnlyMode()}) → + *
  • FIPS mode ({@link FipsMode#isEnabled()}) → * {@code .bcfks} / {@code "BCFKS"}
  • *
  • Non-FIPS → {@code .jks} / {@code "JKS"} if a JKS variant exists on the classpath, * otherwise {@code .p12} / {@code "PKCS12"}
  • @@ -118,7 +118,7 @@ public static KeyStore getKeystoreFromClassPath(final String baseName, String pa * @throws IllegalStateException if no matching file is found on the classpath */ public static TypedStore resolveStore(final String baseName) { - if (CryptoServicesRegistrar.isInApprovedOnlyMode()) { + if (FipsMode.isEnabled()) { return new TypedStore(getAbsoluteFilePathFromClassPath(baseName + ".bcfks"), "BCFKS"); } if (classpathResourceExists(baseName + ".jks")) { @@ -127,6 +127,14 @@ public static TypedStore resolveStore(final String baseName) { return new TypedStore(getAbsoluteFilePathFromClassPath(baseName + ".p12"), "PKCS12"); } + public static TypedStore resolveStore(final Path dir, final String baseName, final String nonFipsExtension) { + if (FipsMode.isEnabled()) { + return new TypedStore(dir.resolve(baseName + ".bcfks"), "BCFKS"); + } + Path path = dir.resolve(baseName + nonFipsExtension); + return new TypedStore(path, inferStoreType(path)); + } + public static boolean classpathResourceExists(final String name) { return FileHelper.class.getClassLoader().getResource(name) != null; } diff --git a/src/test/java/org/opensearch/security/test/helper/rest/RestHelper.java b/src/test/java/org/opensearch/security/test/helper/rest/RestHelper.java index 9127c5406d..b49b3cf412 100644 --- a/src/test/java/org/opensearch/security/test/helper/rest/RestHelper.java +++ b/src/test/java/org/opensearch/security/test/helper/rest/RestHelper.java @@ -100,6 +100,8 @@ public class RestHelper { public boolean enableHTTPClientSSLv3Only = false; public boolean sendAdminCertificate = false; public boolean trustHTTPServerCertificate = true; + public FileHelper.TypedStore customTrustStoreFile = null; + public String customTrustStorePassword = null; public boolean sendHTTPClientCredentials = false; public String keystore = "node-0-keystore"; public final String prefix; @@ -339,8 +341,15 @@ protected final CloseableHttpAsyncClient getHTTPClient() throws Exception { String keystoreDir = ksParent != null ? ksParent + "/" : ""; var resolvedTrustStore = FileHelper.resolveStore(keystoreDir + "truststore"); - final KeyStore myTrustStore = KeyStore.getInstance(resolvedTrustStore.type()); - myTrustStore.load(new FileInputStream(resolvedTrustStore.path().toFile()), "changeit".toCharArray()); + final KeyStore myTrustStore; + if (customTrustStoreFile != null) { + myTrustStore = KeyStore.getInstance(customTrustStoreFile.type()); + char[] tsPass = customTrustStorePassword != null ? customTrustStorePassword.toCharArray() : null; + myTrustStore.load(new FileInputStream(customTrustStoreFile.path().toFile()), tsPass); + } else { + myTrustStore = KeyStore.getInstance(resolvedTrustStore.type()); + myTrustStore.load(new FileInputStream(resolvedTrustStore.path().toFile()), "changeit".toCharArray()); + } final KeyStore keyStore = KeyStore.getInstance(resolvedKeyStore.type()); keyStore.load(new FileInputStream(resolvedKeyStore.path().toFile()), "changeit".toCharArray()); diff --git a/src/test/java/org/opensearch/security/tools/HasherTests.java b/src/test/java/org/opensearch/security/tools/HasherTests.java index be9227eb8b..ed2d7ed557 100644 --- a/src/test/java/org/opensearch/security/tools/HasherTests.java +++ b/src/test/java/org/opensearch/security/tools/HasherTests.java @@ -32,6 +32,7 @@ public class HasherTests { private final ByteArrayOutputStream out = new ByteArrayOutputStream(); private final PrintStream originalOut = System.out; private final InputStream originalIn = System.in; + private final String password = "notarealpassword"; @Before public void setOutputStreams() { @@ -46,58 +47,58 @@ public void restoreStreams() { @Test public void testWithDefaultArguments() { - Hasher.main(new String[] { "-p", "password" }); + Hasher.main(new String[] { "-p", password }); assertTrue("should return a valid BCrypt hash with the default BCrypt configuration", out.toString().startsWith("$2y$12")); } // BCRYPT @Test public void testWithBCryptRoundsArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "BCrypt", "-r", "5" }); + Hasher.main(new String[] { "-p", password, "-a", "BCrypt", "-r", "5" }); assertTrue("should return a valid BCrypt hash with the correct value for \"rounds\"", out.toString().startsWith("$2y$05")); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "BCrypt", "-r", "5" }); + Hasher.main(new String[] { "-p", password, "-a", "BCrypt", "-r", "5" }); assertTrue("should return a valid BCrypt hash with the correct value for \"rounds\"", out.toString().startsWith("$2y$05")); } @Test public void testWithBCryptMinorArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "BCrypt", "-min", "A" }); + Hasher.main(new String[] { "-p", password, "-a", "BCrypt", "-min", "A" }); assertTrue("should return a valid BCrypt hash with the correct value for \"minor\"", out.toString().startsWith("$2a$12")); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "BCrypt", "-min", "Y" }); + Hasher.main(new String[] { "-p", password, "-a", "BCrypt", "-min", "Y" }); assertTrue("should return a valid BCrypt hash with the correct value for \"minor\"", out.toString().startsWith("$2y$12")); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "BCrypt", "-min", "B" }); + Hasher.main(new String[] { "-p", password, "-a", "BCrypt", "-min", "B" }); assertTrue("should return a valid BCrypt hash with the correct value for \"minor\"", out.toString().startsWith("$2b$12")); out.reset(); } @Test public void testWithBCryptAllArguments() { - Hasher.main(new String[] { "-p", "password", "-a", "BCrypt", "-min", "A", "-r", "5" }); + Hasher.main(new String[] { "-p", password, "-a", "BCrypt", "-min", "A", "-r", "5" }); assertTrue("should return a valid BCrypt hash with the correct configuration", out.toString().startsWith("$2a$05")); } @Test public void testWithPBKDF2Prefix() { - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2" }); assertTrue("should return a valid PBKDF2 hash with the default configuration", out.toString().startsWith("$3$25")); } @Test public void testWithArgon2Prefix() { - Hasher.main(new String[] { "-p", "password", "-a", "Argon2" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2" }); assertTrue("should return a valid Argon2 hash with the default configuration", out.toString().startsWith("$argon2")); } // PBKDF2 @Test public void testWithPBKDF2DefaultArguments() { - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2" }); CompressedPBKDF2Function pbkdf2Function = CompressedPBKDF2Function.getInstanceFromHash(out.toString()); assertEquals("should return a valid PBKDF2 hash with the correct value for \"function\"", pbkdf2Function.getAlgorithm(), "SHA256"); assertEquals("should return a valid PBKDF2 hash with the default value for \"iterations\"", pbkdf2Function.getIterations(), 600000); @@ -106,14 +107,14 @@ public void testWithPBKDF2DefaultArguments() { @Test public void testWithPBKDF2FunctionArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2", "-f", "SHA512" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2", "-f", "SHA512" }); CompressedPBKDF2Function pbkdf2Function = CompressedPBKDF2Function.getInstanceFromHash(out.toString()); assertEquals("should return a valid PBKDF2 hash with the correct value for \"function\"", pbkdf2Function.getAlgorithm(), "SHA512"); assertEquals("should return a valid PBKDF2 hash with the default value for \"iterations\"", pbkdf2Function.getIterations(), 600000); assertEquals("should return a valid PBKDF2 hash with the default value for \"length\"", pbkdf2Function.getLength(), 256); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2", "-f", "SHA384" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2", "-f", "SHA384" }); pbkdf2Function = CompressedPBKDF2Function.getInstanceFromHash(out.toString()); assertEquals("should return a valid PBKDF2 hash with the correct value for \"function\"", pbkdf2Function.getAlgorithm(), "SHA384"); assertEquals("should return a valid PBKDF2 hash with the default value for \"iterations\"", pbkdf2Function.getIterations(), 600000); @@ -122,14 +123,14 @@ public void testWithPBKDF2FunctionArgument() { @Test public void testWithPBKDF2IterationsArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2", "-i", "100000" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2", "-i", "100000" }); CompressedPBKDF2Function pbkdf2Function = CompressedPBKDF2Function.getInstanceFromHash(out.toString()); assertEquals("should return a valid PBKDF2 hash with the correct value for \"function\"", pbkdf2Function.getAlgorithm(), "SHA256"); assertEquals("should return a valid PBKDF2 hash with the default value for \"iterations\"", pbkdf2Function.getIterations(), 100000); assertEquals("should return a valid PBKDF2 hash with the default value for \"length\"", pbkdf2Function.getLength(), 256); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2", "-i", "200000" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2", "-i", "200000" }); pbkdf2Function = CompressedPBKDF2Function.getInstanceFromHash(out.toString()); assertEquals("should return a valid PBKDF2 hash with the correct value for \"function\"", pbkdf2Function.getAlgorithm(), "SHA256"); assertEquals("should return a valid PBKDF2 hash with the default value for \"iterations\"", pbkdf2Function.getIterations(), 200000); @@ -138,14 +139,14 @@ public void testWithPBKDF2IterationsArgument() { @Test public void testWithPBKDF2LengthArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2", "-l", "400" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2", "-l", "400" }); CompressedPBKDF2Function pbkdf2Function = CompressedPBKDF2Function.getInstanceFromHash(out.toString()); assertEquals("should return a valid PBKDF2 hash with the correct value for \"function\"", pbkdf2Function.getAlgorithm(), "SHA256"); assertEquals("should return a valid PBKDF2 hash with the default value for \"iterations\"", pbkdf2Function.getIterations(), 600000); assertEquals("should return a valid PBKDF2 hash with the default value for \"length\"", pbkdf2Function.getLength(), 400); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2", "-l", "300" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2", "-l", "300" }); pbkdf2Function = CompressedPBKDF2Function.getInstanceFromHash(out.toString()); assertEquals("should return a valid PBKDF2 hash with the correct value for \"function\"", pbkdf2Function.getAlgorithm(), "SHA256"); assertEquals("should return a valid PBKDF2 hash with the default value for \"iterations\"", pbkdf2Function.getIterations(), 600000); @@ -154,7 +155,7 @@ public void testWithPBKDF2LengthArgument() { @Test public void testWithPBKDF2AllArguments() { - Hasher.main(new String[] { "-p", "password", "-a", "PBKDF2", "-l", "250", "-i", "150000", "-f", "SHA384" }); + Hasher.main(new String[] { "-p", password, "-a", "PBKDF2", "-l", "250", "-i", "150000", "-f", "SHA384" }); CompressedPBKDF2Function pbkdf2Function = CompressedPBKDF2Function.getInstanceFromHash(out.toString()); assertEquals("should return a valid PBKDF2 hash with the correct value for \"function\"", pbkdf2Function.getAlgorithm(), "SHA384"); assertEquals("should return a valid PBKDF2 hash with the default value for \"iterations\"", pbkdf2Function.getIterations(), 150000); @@ -164,7 +165,7 @@ public void testWithPBKDF2AllArguments() { // ARGON2 @Test public void testWithArgon2DefaultArguments() { - Hasher.main(new String[] { "-p", "password", "-a", "Argon2" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2" }); Argon2Function argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals( "should return a valid Argon2 hash with the default value for \"memory\"", @@ -200,7 +201,7 @@ public void testWithArgon2DefaultArguments() { @Test public void testWithArgon2MemoryArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-m", "47104" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-m", "47104" }); Argon2Function argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the correct value for \"memory\"", argon2Function.getMemory(), 47104); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -210,7 +211,7 @@ public void testWithArgon2MemoryArgument() { assertEquals("should return a valid Argon2 hash with the default value for \"version\"", argon2Function.getVersion(), 19); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-m", "19456" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-m", "19456" }); argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the correct value for \"memory\"", argon2Function.getMemory(), 19456); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -222,7 +223,7 @@ public void testWithArgon2MemoryArgument() { @Test public void testWithArgon2IterationsArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-i", "1" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-i", "1" }); Argon2Function argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the correct value for \"iterations\"", argon2Function.getIterations(), 1); @@ -232,7 +233,7 @@ public void testWithArgon2IterationsArgument() { assertEquals("should return a valid Argon2 hash with the default value for \"version\"", argon2Function.getVersion(), 19); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-i", "5" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-i", "5" }); argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the correct value for \"iterations\"", argon2Function.getIterations(), 5); @@ -244,7 +245,7 @@ public void testWithArgon2IterationsArgument() { @Test public void testWithArgon2ParallelismArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-par", "2" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-par", "2" }); Argon2Function argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -254,7 +255,7 @@ public void testWithArgon2ParallelismArgument() { assertEquals("should return a valid Argon2 hash with the default value for \"version\"", argon2Function.getVersion(), 19); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-par", "1" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-par", "1" }); argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -266,7 +267,7 @@ public void testWithArgon2ParallelismArgument() { @Test public void testWithArgon2LengthArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-l", "64" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-l", "64" }); Argon2Function argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -276,7 +277,7 @@ public void testWithArgon2LengthArgument() { assertEquals("should return a valid Argon2 hash with the default value for \"version\"", argon2Function.getVersion(), 19); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-l", "12" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-l", "12" }); argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -288,7 +289,7 @@ public void testWithArgon2LengthArgument() { @Test public void testWithArgon2TypeArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-t", "argon2i" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-t", "argon2i" }); Argon2Function argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -298,7 +299,7 @@ public void testWithArgon2TypeArgument() { assertEquals("should return a valid Argon2 hash with the default value for \"version\"", argon2Function.getVersion(), 19); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-t", "argon2d" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-t", "argon2d" }); argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -310,7 +311,7 @@ public void testWithArgon2TypeArgument() { @Test public void testWithArgon2VersionArgument() { - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-v", "16" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-v", "16" }); Argon2Function argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -320,7 +321,7 @@ public void testWithArgon2VersionArgument() { assertEquals("should return a valid Argon2 hash with the correct value for \"version\"", argon2Function.getVersion(), 16); out.reset(); - Hasher.main(new String[] { "-p", "password", "-a", "Argon2", "-v", "19" }); + Hasher.main(new String[] { "-p", password, "-a", "Argon2", "-v", "19" }); argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the default value for \"memory\"", argon2Function.getMemory(), 65536); assertEquals("should return a valid Argon2 hash with the default value for \"iterations\"", argon2Function.getIterations(), 3); @@ -333,23 +334,7 @@ public void testWithArgon2VersionArgument() { @Test public void testWithArgon2AllArguments() { Hasher.main( - new String[] { - "-p", - "password", - "-a", - "Argon2", - "-m", - "47104", - "-i", - "1", - "-par", - "2", - "-l", - "64", - "-t", - "argon2d", - "-v", - "19" } + new String[] { "-p", password, "-a", "Argon2", "-m", "47104", "-i", "1", "-par", "2", "-l", "64", "-t", "argon2d", "-v", "19" } ); Argon2Function argon2Function = Argon2Function.getInstanceFromHash(out.toString().trim()); assertEquals("should return a valid Argon2 hash with the correct value for \"memory\"", argon2Function.getMemory(), 47104); diff --git a/src/test/java/org/opensearch/security/tools/democonfig/CertificateGeneratorTests.java b/src/test/java/org/opensearch/security/tools/democonfig/CertificateGeneratorTests.java index 18b857cf84..a5ce377039 100644 --- a/src/test/java/org/opensearch/security/tools/democonfig/CertificateGeneratorTests.java +++ b/src/test/java/org/opensearch/security/tools/democonfig/CertificateGeneratorTests.java @@ -34,6 +34,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.is; import static org.opensearch.security.tools.democonfig.util.DemoConfigHelperUtil.createDirectory; @@ -137,7 +138,7 @@ private static void checkCertificateValidity(String certPath) throws Exception { x509Certificate.checkValidity(); verifyExpiryAtLeastAYearFromNow(expiry); - assertThat(x509Certificate.getSigAlgName(), is(equalTo("SHA256withRSA"))); + assertThat(x509Certificate.getSigAlgName(), is(equalToIgnoringCase("SHA256withRSA"))); } } } diff --git a/src/test/java/org/opensearch/security/tools/democonfig/SecuritySettingsConfigurerTests.java b/src/test/java/org/opensearch/security/tools/democonfig/SecuritySettingsConfigurerTests.java index 203056cb87..07c9338683 100644 --- a/src/test/java/org/opensearch/security/tools/democonfig/SecuritySettingsConfigurerTests.java +++ b/src/test/java/org/opensearch/security/tools/democonfig/SecuritySettingsConfigurerTests.java @@ -29,13 +29,16 @@ import java.util.Map; import com.carrotsearch.randomizedtesting.RandomizedRunner; -import org.apache.commons.lang3.RandomStringUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.opensearch.common.settings.Settings; +import org.opensearch.security.hasher.PasswordHasher; +import org.opensearch.security.hasher.PasswordHasherFactory; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.tools.Hasher; import static org.hamcrest.MatcherAssert.assertThat; @@ -44,15 +47,16 @@ import static org.hamcrest.Matchers.is; import static org.opensearch.security.dlic.rest.validation.RequestContentValidator.ValidationError.INVALID_PASSWORD_INVALID_REGEX; import static org.opensearch.security.dlic.rest.validation.RequestContentValidator.ValidationError.INVALID_PASSWORD_TOO_SHORT; -import static org.opensearch.security.tools.democonfig.SecuritySettingsConfigurer.DEFAULT_ADMIN_PASSWORD; import static org.opensearch.security.tools.democonfig.SecuritySettingsConfigurer.DEFAULT_PASSWORD_MIN_LENGTH; import static org.opensearch.security.tools.democonfig.SecuritySettingsConfigurer.REST_ENABLED_ROLES; import static org.opensearch.security.tools.democonfig.SecuritySettingsConfigurer.isKeyPresentInYMLFile; import static org.opensearch.security.tools.democonfig.util.DemoConfigHelperUtil.createDirectory; import static org.opensearch.security.tools.democonfig.util.DemoConfigHelperUtil.createFile; import static org.opensearch.security.tools.democonfig.util.DemoConfigHelperUtil.deleteDirectoryRecursive; +import static com.carrotsearch.randomizedtesting.RandomizedTest.randomAsciiAlphanumOfLength; import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; +import static org.junit.Assume.assumeFalse; @RunWith(RandomizedRunner.class) public class SecuritySettingsConfigurerTests { @@ -118,7 +122,8 @@ public void testUpdateAdminPasswordWithCustomPassword() throws NoSuchFieldExcept } @Test - public void testUpdateAdminPassword_noPasswordSupplied() throws IOException { + public void testUpdateAdminPassword_noPasswordSupplied() throws NoSuchFieldException, IllegalAccessException { + unsetEnvVariables(); // Ensure ADMIN_PASSWORD is empty so that no custom password is supplied SecuritySettingsConfigurer.ADMIN_PASSWORD = ""; installer.setExitHandler(status -> { throw new TestExitException(status); }); @@ -164,6 +169,7 @@ public void testUpdateAdminPasswordWithShortPassword() throws NoSuchFieldExcepti @Test public void testUpdateAdminPasswordWithWeakPassword_skipPasswordValidation() throws NoSuchFieldException, IllegalAccessException, IOException { + assumeFalse("Weak passwords cannot be hashed with FIPS-approved KDFs (112-bit minimum)", FipsMode.isEnabled()); setEnv(adminPasswordKey, "weakpassword"); installer.environment = ExecutionEnvironment.TEST; // In test environment, password validation is skipped. @@ -183,7 +189,7 @@ public void testUpdateAdminPasswordWithCustomInternalUsersYML() throws IOExcepti " type: \"internalusers\"", " config_version: 2", "admin:", - " hash: " + Hasher.hash(RandomStringUtils.randomAlphanumeric(16).toCharArray()), + " hash: " + Hasher.hash(randomAsciiAlphanumOfLength(16).toCharArray(), fixtureHasherSettings()), " backend_roles:", " - \"admin\"" ); @@ -406,15 +412,21 @@ private void verifyStdOutContainsString(String s) { assertThat(outContent.toString(), containsString(s)); } + private static Settings fixtureHasherSettings() { + String algorithm = FipsMode.isEnabled() ? ConfigConstants.PBKDF2 : ConfigConstants.BCRYPT; + return Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, algorithm).build(); + } + private void setUpInternalUsersYML() throws IOException { String internalUsersFile = installer.OPENSEARCH_CONF_DIR + "opensearch-security" + File.separator + "internal_users.yml"; Path internalUsersFilePath = Paths.get(internalUsersFile); + PasswordHasher fixtureHasher = PasswordHasherFactory.createPasswordHasher(fixtureHasherSettings()); List defaultContent = Arrays.asList( "_meta:", " type: \"internalusers\"", " config_version: 2", "admin:", - " hash: " + Hasher.hash(DEFAULT_ADMIN_PASSWORD.toCharArray()), + " hash: " + fixtureHasher.hash(fixtureHasher.defaultAdminPassword().toCharArray()), " reserved: true", " backend_roles:", " - \"admin\"", diff --git a/src/test/java/org/opensearch/security/util/KeyUtilsLoadKeyFromKeystoreTest.java b/src/test/java/org/opensearch/security/util/KeyUtilsLoadKeyFromKeystoreTest.java new file mode 100644 index 0000000000..b808a7f0ee --- /dev/null +++ b/src/test/java/org/opensearch/security/util/KeyUtilsLoadKeyFromKeystoreTest.java @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.util; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import org.opensearch.common.settings.Settings; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.opensearch.security.util.KeyUtils.KEYSTORE_ALIAS; +import static org.opensearch.security.util.KeyUtils.KEYSTORE_KEY_PASSWORD; +import static org.opensearch.security.util.KeyUtils.KEYSTORE_PASSWORD; +import static org.opensearch.security.util.KeyUtils.KEYSTORE_PATH; +import static org.opensearch.security.util.KeyUtils.KEYSTORE_TYPE; + +public class KeyUtilsLoadKeyFromKeystoreTest { + + private static final String PREFIX = "signing_key"; + private static final SecretKey SECRET_KEY = new SecretKeySpec( + "unit-test-hmac-key-256-bits!!!!!".getBytes(StandardCharsets.US_ASCII), + "HmacSHA256" + ); + + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); + + @Test + public void returnsNullWhenAliasSettingAbsent() { + Settings settings = Settings.builder().build(); + assertThat(KeyUtils.loadKeyFromKeystore(settings, PREFIX), nullValue()); + } + + @Test + public void loadsKeyWhenAllSettingsPresent() throws Exception { + File ks = keystoreWithKey("bcfks"); + Settings settings = Settings.builder() + .put(PREFIX + KEYSTORE_PATH, ks.getAbsolutePath()) + .put(PREFIX + KEYSTORE_TYPE, "bcfks") + .put(PREFIX + KEYSTORE_PASSWORD, "kspass") + .put(PREFIX + KEYSTORE_ALIAS, "test-key") + .put(PREFIX + KEYSTORE_KEY_PASSWORD, "keypass") + .build(); + SecretKey loaded = KeyUtils.loadKeyFromKeystore(settings, PREFIX); + assertThat(loaded, notNullValue()); + assertThat(loaded.getEncoded(), equalTo(SECRET_KEY.getEncoded())); + } + + private File keystoreWithKey(String storeType) throws Exception { + File file = tempDir.newFile("test." + storeType.toLowerCase()); + KeyStore ks = KeyStore.getInstance(storeType); + ks.load(null, null); + ks.setKeyEntry("test-key", SECRET_KEY, "keypass".toCharArray(), null); + try (FileOutputStream fos = new FileOutputStream(file)) { + ks.store(fos, "kspass".toCharArray()); + } + return file; + } +} diff --git a/src/test/java/org/opensearch/security/util/SettingsBasedSSLConfiguratorV4Test.java b/src/test/java/org/opensearch/security/util/SettingsBasedSSLConfiguratorV4Test.java index 3183828e77..2a3518c46b 100644 --- a/src/test/java/org/opensearch/security/util/SettingsBasedSSLConfiguratorV4Test.java +++ b/src/test/java/org/opensearch/security/util/SettingsBasedSSLConfiguratorV4Test.java @@ -61,10 +61,12 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.bouncycastle.tls.TlsFatalAlert; import org.opensearch.common.settings.MockSecureSettings; import org.opensearch.common.settings.Settings; import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.security.test.helper.network.SocketUtils; import org.opensearch.security.util.SettingsBasedSSLConfiguratorV4.SSLConfig; @@ -134,7 +136,8 @@ public void testPemWrongTrust() throws Exception { CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(sslConfig.toSSLConnectionSocketFactory()).build() ) { - thrown.expect(SSLHandshakeException.class); + Class exceptionClass = FipsMode.isEnabled() ? TlsFatalAlert.class : SSLHandshakeException.class; + thrown.expect(exceptionClass); try (CloseableHttpResponse response = httpClient.execute(new HttpGet(testServer.getUri()))) { Assert.fail("Connection should have failed due to wrong trust"); @@ -159,7 +162,7 @@ public void testPemClientAuth() throws Exception { .put("prefix.enable_ssl_client_auth", "true") .put("prefix.pemcert_filepath", "kirk.pem") .put("prefix.pemkey_filepath", "kirk.key") - .put("prefix.pemkey_password", "secret") + .put("prefix.pemkey_password", "notarealpassword") .build(); Path configPath = rootCaPemPath.getParent(); @@ -194,7 +197,7 @@ public void testPemClientAuthFailure() throws Exception { .put("prefix.enable_ssl_client_auth", "true") .put("prefix.pemcert_filepath", "wrong-kirk.pem") .put("prefix.pemkey_filepath", "wrong-kirk.key") - .put("prefix.pemkey_password", "G0CVtComen4a") + .put("prefix.pemkey_password", "notarealpassword") .build(); Path configPath = rootCaPemPath.getParent(); @@ -357,7 +360,8 @@ public void testJksWrongTrust() throws Exception { CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(sslConfig.toSSLConnectionSocketFactory()).build() ) { - thrown.expect(SSLHandshakeException.class); + Class exceptionClass = FipsMode.isEnabled() ? TlsFatalAlert.class : SSLHandshakeException.class; + thrown.expect(exceptionClass); try (CloseableHttpResponse response = httpClient.execute(new HttpGet(testServer.getUri()))) { Assert.fail("Connection should have failed due to wrong trust"); diff --git a/src/test/resources/auditlog/endpoints/configuration_wrong_endpoint_names.yml b/src/test/resources/auditlog/endpoints/configuration_wrong_endpoint_names.yml index dee8c95641..9187fb7008 100644 --- a/src/test/resources/auditlog/endpoints/configuration_wrong_endpoint_names.yml +++ b/src/test/resources/auditlog/endpoints/configuration_wrong_endpoint_names.yml @@ -11,7 +11,6 @@ plugins.security: username: auditloguser password: auditlogpassword enable_ssl: false - verify_hostnames: false enable_ssl_client_auth: false endpoint3: type: debug diff --git a/src/test/resources/auditlog/endpoints/routing/configuration_valid.yml b/src/test/resources/auditlog/endpoints/routing/configuration_valid.yml index 027ee6869e..0bd01d9122 100644 --- a/src/test/resources/auditlog/endpoints/routing/configuration_valid.yml +++ b/src/test/resources/auditlog/endpoints/routing/configuration_valid.yml @@ -7,7 +7,6 @@ plugins.security: username: auditloguser password: auditlogpassword enable_ssl: false - verify_hostnames: false enable_ssl_client_auth: false endpoints: endpoint1: @@ -36,7 +35,6 @@ plugins.security: username: auditloguser password: auditlogpassword enable_ssl: false - verify_hostnames: false enable_ssl_client_auth: false endpoint3: type: debug diff --git a/src/test/resources/auditlog/endpoints/routing/configuration_wrong_endpoint_names.yml b/src/test/resources/auditlog/endpoints/routing/configuration_wrong_endpoint_names.yml index 2b96265492..b7e36a082c 100644 --- a/src/test/resources/auditlog/endpoints/routing/configuration_wrong_endpoint_names.yml +++ b/src/test/resources/auditlog/endpoints/routing/configuration_wrong_endpoint_names.yml @@ -12,7 +12,6 @@ plugins.security: username: auditloguser password: auditlogpassword enable_ssl: false - verify_hostnames: false enable_ssl_client_auth: false endpoint3: type: debug diff --git a/src/test/resources/auditlog/endpoints/routing/configuration_wrong_endpoint_types.yml b/src/test/resources/auditlog/endpoints/routing/configuration_wrong_endpoint_types.yml index c59adc4ee1..1d3f6447db 100644 --- a/src/test/resources/auditlog/endpoints/routing/configuration_wrong_endpoint_types.yml +++ b/src/test/resources/auditlog/endpoints/routing/configuration_wrong_endpoint_types.yml @@ -12,7 +12,6 @@ plugins.security: username: auditloguser password: auditlogpassword enable_ssl: false - verify_hostnames: false enable_ssl_client_auth: false endpoint3: type: debug diff --git a/src/test/resources/ldap/config.yml b/src/test/resources/ldap/config.yml index b7a099a36c..e327d85536 100644 --- a/src/test/resources/ldap/config.yml +++ b/src/test/resources/ldap/config.yml @@ -40,7 +40,6 @@ config: hosts: "localhost:${ldapsPort}" usersearch: "(uid={0})" enable_ssl: true - verify_hostnames: false description: "Migrated from v6" authz: {} do_not_fail_on_forbidden: false diff --git a/src/test/resources/ldap/config_ldap2.yml b/src/test/resources/ldap/config_ldap2.yml index cea994dd02..5ef6da0a4a 100644 --- a/src/test/resources/ldap/config_ldap2.yml +++ b/src/test/resources/ldap/config_ldap2.yml @@ -40,7 +40,6 @@ config: hosts: "localhost:${ldapsPort}" usersearch: "(uid={0})" enable_ssl: true - verify_hostnames: false description: "Migrated from v6" authz: {} do_not_fail_on_forbidden: false diff --git a/src/test/resources/ldap/test1.yml b/src/test/resources/ldap/test1.yml index e0ad96ceea..c6d926b68f 100644 --- a/src/test/resources/ldap/test1.yml +++ b/src/test/resources/ldap/test1.yml @@ -110,4 +110,3 @@ pemtrustedcas_content: | pTIIfrQcZ1vrDg0lYzVgQ1iT -----END CERTIFICATE----- usersearch: "(uid={0})" -verify_hostnames: false diff --git a/src/test/resources/ssl/pem/node-4.crt.pem b/src/test/resources/ssl/pem/node-4.crt.pem deleted file mode 100644 index 3bf727f94f..0000000000 --- a/src/test/resources/ssl/pem/node-4.crt.pem +++ /dev/null @@ -1,50 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEcTCCA1mgAwIBAgIBBjANBgkqhkiG9w0BAQsFADCBlTETMBEGCgmSJomT8ixk -ARkWA2NvbTEXMBUGCgmSJomT8ixkARkWB2V4YW1wbGUxGTAXBgNVBAoMEEV4YW1w -bGUgQ29tIEluYy4xJDAiBgNVBAsMG0V4YW1wbGUgQ29tIEluYy4gU2lnbmluZyBD -QTEkMCIGA1UEAwwbRXhhbXBsZSBDb20gSW5jLiBTaWduaW5nIENBMB4XDTE4MDUw -NTE0MzcxNFoXDTI4MDUwMjE0MzcxNFowVjELMAkGA1UEBhMCREUxDTALBgNVBAcM -BFRlc3QxDTALBgNVBAoMBFRlc3QxDDAKBgNVBAsMA1NTTDEbMBkGA1UEAwwSbm9k -ZS00LmV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA -1EjVHY9yVLJjWN7J5OU2pkZyrttRO674kUtIn1Z8lCNTD31DIfOnJ6iLlUDZ0HhC -9VumGOu4dc9HCSoxr4ck1GiDG0YrVdbZ6Hx4E3P+BY/RCxuQutGQeKi4BwgBm/bF -5yg9ro0+ia2808RB9pWuvLClypPjhSJSMfG+UUJ8+23jRYdxYRboWPScacbuV634 -1AdOdlr7k7Rauw7nagehRi1k/DUEEOrSHbWqxyziiisJvU97Ey9FZhVya8st8srM -wgNst+Lr16458V1pLRm097PoBYQMU9/MgMVXIz0Rmm1oqRMCPW5CPg6x5SXiKoCf -9i1rwmt5abB58tYwxwJB8wIDAQABo4IBCDCCAQQwDgYDVR0PAQH/BAQDAgWgMAkG -A1UdEwQCMAAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQW -BBSuaYlnZZNkm6DUGh5K4zLBBYvQMTAfBgNVHSMEGDAWgBSUd+KTMQs/tufL5Y5q -RKTE4wTB4DBiBgNVHR8EWzBZMFegVaBThlFodHRwczovL3Jhdy5naXRodWJ1c2Vy -Y29udGVudC5jb20vZmxvcmFndW5uY29tL3VuaXR0ZXN0LWFzc2V0cy9tYXN0ZXIv -cmV2b2tlZC5jcmwwJAYDVR0RBB0wG4ISbm9kZS00LmV4YW1wbGUuY29tiAUqAwQF -BTANBgkqhkiG9w0BAQsFAAOCAQEAlCP1gK4DkG5VwPAY68BeifMSmSFai/JOOVSA -URk7UiQ+nNkwo7Wuvf60aXqN+K440D3fvW3Bzk7+EnnKQFNXm4jief3ndM4Hvra0 -WKh979iWfrBY2LUqWhpW9zsIEx6PQGJIBlAfNiaQSW+gU05DQ8sxcfOgPPou94XT -xrioFBg42JDMDLhiNhyLcMgMEwmdyfNw+N28HLrsoEJ223H9DWwfse1j/IgOKA8h -5/PWNWUE1OFUflWov9wmlMVH4RUDovJGjvzFqZ7e+mH5gALp3CNtIcwl8u5jT3Y7 -206hdGScJoTWorKv5ujyhrGY/2Vp7AzY3pHHuP74HZL0HHvp4w== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIEBzCCAu+gAwIBAgIBAjANBgkqhkiG9w0BAQsFADCBjzETMBEGCgmSJomT8ixk -ARkWA2NvbTEXMBUGCgmSJomT8ixkARkWB2V4YW1wbGUxGTAXBgNVBAoMEEV4YW1w -bGUgQ29tIEluYy4xITAfBgNVBAsMGEV4YW1wbGUgQ29tIEluYy4gUm9vdCBDQTEh -MB8GA1UEAwwYRXhhbXBsZSBDb20gSW5jLiBSb290IENBMB4XDTE4MDUwNTE0Mzcw -OFoXDTI4MDUwNDE0MzcwOFowgZUxEzARBgoJkiaJk/IsZAEZFgNjb20xFzAVBgoJ -kiaJk/IsZAEZFgdleGFtcGxlMRkwFwYDVQQKDBBFeGFtcGxlIENvbSBJbmMuMSQw -IgYDVQQLDBtFeGFtcGxlIENvbSBJbmMuIFNpZ25pbmcgQ0ExJDAiBgNVBAMMG0V4 -YW1wbGUgQ29tIEluYy4gU2lnbmluZyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBANWsMh2EWiqH2eZmaiHreWG4NlhLZGcUbwzRIZT0HmeeBolQygGq -cJE1MpzCMYdezjTRaws/FVA2dkrtcox2xGT6YG7sKqr+4VlIt3Pd0Sah/5dEdRJv -RsN2mj8V8xNUZdduD6NnrIGW/wAoF4isDNJ3QlGFhPM0f0Of5TVFIyholgrevNLT -7D5rdUupIW192zQbOOuOxOmeXkunl8u35wq/VI/ZyJ4/mutCLR5sqd6/kOSDKQTU -gQ+xIrs7LiuF1xZbCtRT3/PWnnD/GJulUsuJ0xOeEHkQaJuwRwYzqFkyVrEea2Wf -U6XmSRZK9L0q5jy8TpCgzULlxb92POZd9ssCAwEAAaNmMGQwDgYDVR0PAQH/BAQD -AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFJR34pMxCz+258vljmpE -pMTjBMHgMB8GA1UdIwQYMBaAFFvHubBG7v/2QuZe5M8S+FdMajAVMA0GCSqGSIb3 -DQEBCwUAA4IBAQAY22ahhmYBdYUpPwQEyEUexyTWal29sbV+R44qVKM6FDEEd/8Q -cFe5cnguDqmLBwHDLey4eSsAHI5tBUtslPJMqobWbwzswxdZ9WCOaLBWlvZdK4XU -hkrq919wENMT6DVagNdpNRmDA47G4eRha4oD1ZO2YCFM0H8rEWDRSlaAHsGHLR59 -cJ5AgPqAVrEMfP6WzxXW2ThY6HD1LsE69T20/CfR8/k826BkcYVHKR/MQ/YZOWXb -ccfb7D5o/oMop0E4+huCdF7ZDOt7/f5+BAfJZ08GCMLy5GSxU9gf8WiT/yNBYETS -DAj+BAKhzlzvsaC3E2lAeyUepIMN0B8YHjqV ------END CERTIFICATE----- diff --git a/src/test/resources/ssl/pem/node-4.key b/src/test/resources/ssl/pem/node-4.key deleted file mode 100644 index 965c02e4c4..0000000000 --- a/src/test/resources/ssl/pem/node-4.key +++ /dev/null @@ -1,29 +0,0 @@ ------BEGIN ENCRYPTED PRIVATE KEY----- -MIIE6TAbBgkqhkiG9w0BBQMwDgQIAUztzPE48tECAggABIIEyMq0nF+vkpgA9vs1 -LrkGVinMLcl9kcnZ6sclulgUpar+agZ7yce3rtdFYrTFTRe9cLfOI/+nm3scsHaH -kf0j6ZVAAyyXupivEV9jnFw6QjMql14EEEpzSjdUJ0e+yO8IIXO5q8dx7tFpiUUg -WlSTaVqLS82OhuwD1VrDykR5XlmHp6COOhEJT7mVdvmNZFCwQ8VUg9k2hjAWB7vw -PhJPNBnhI4Tr5Gb0/qFvpPcPkV5muxSkSJ2VA+YgHHgvZKpC/8E46uq7t2m45owM -iCPOAVSy5nLigKcttP9Q8o7LY+l8iJdwkzTotu1gFjYfVp6+n1vHIKDf3kQz457v -wCpSTa9p7PEreAo+SNUsigG23SsNIWP9/uUpPiy3G4X2kuNlxWdmqY8KSmblttGq -6vmdIqpNwGOGDy6stfU9K7tgVdoEvRXfaNS1PwyF4V9ZVF9y6HEqmRcdpdjKIufW -H49nF7AcNPGsEGCeX36ILnAGborvWstCOMl0WFkBg45sbLiT2Q9gAfSo37+Rfh11 -O+k6V8p5X30o6ftav3OR7j/dViKp/5j5dyAMjB/4tcDMkywHwm8Evyik8aWomQer -1u6KPpE9hYn2YOY2cZouiC3d8KNLZJMdkcOlqYRs23N9QNNeFW3CvGGb0203+uWb -185gjGHVX/lT8INlquPJyAt/3VT4EWTaSvIUS4pI75kDq/gzr56ak4/GgDEB4pK+ -yH7npm3U1p/jyoriJ3/1jMXkRp7HN42XSL2/m/Kzk5fX9JaNNO4ANHpuxMqV6MU/ -krWMa2bbC2shaVxZFOlx7LOUL7eHh7Vkk3T6/G28s57d9pW8qHBlwgNrYdegB8aC -wF8SNmo75c4xHkpShx+FmGG/Q7h0VYzzr2j9XQTs7GspbEJo9ugTSVoAEVLI2avL -sE6GS8AzKGTDKPNlzAru2+nzqQ4aG0RxYMpg9a+cPMQeoos7knjkkT1Twc1gcWQv -r9O3vsY87pLKMSsstDtkZRONqxwPqbfDIMyV2JOF/x1EZ3+V1NMQErwGgl/tqlW7 -+PnW9JrIVFp+AEAzY666cta/RSJ2MNa7OrodIrgH5TtEwEy3qDH1YtVXUXG9A05W -AGjLMFCQKEtrTrN4EotqXEXLmY54JF9WvT7hvtjYYhxkS+QtquujJb6En+Pte+Lm -HN89dZToAY62LxhR4P1morg0jUwrAMssFa0YnMO3wN6ADQ8RSVT0cyOtGDM77DrI -ps/K1tY+lQi9OV1pflP+Q6SukTWs+6dm8MpRcySPiKI+OZLVwy0MYub8UlCaO7PK -QM+VcXVX3OyIKUe6BUiEE7KVnFh3V/I0KfNbYKInEM3gWPV06BWNSUo8TEXrxXWz -xNRGG9r8RKf99cMzotox7G2YUSBJdI3SAqUse5ubdOW7TRsSMtdzMpSr62hambZ8 -BYLjtjYZm050vu3PHeYO6htCMj52m+w6IFrPGlmmKqwMsgnSC31Ns5zmMQP5Oeu9 -spLrr+oGsURF3DRVdAXGBY1OlvWCDLsCSp0kOF2Vpj4y7v9zPEEku8w6eGMbRKcc -+Cg86Acrc1hOkDtzWtfpbDkkhXPkcYU8ic8c7gN3japCoH+RFLqoNhJow1vx6TW1 -9vCq0aTqdksLCNZ1Bw== ------END ENCRYPTED PRIVATE KEY----- diff --git a/src/test/resources/ssl/pem/node-4.p12 b/src/test/resources/ssl/pem/node-4.p12 deleted file mode 100644 index 892b6297bf09cfcc4d2fd414aee8717caf4a97f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3821 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 nul \ No newline at end of file +@echo off + +set OPENSEARCH_MAIN_CLASS=org.opensearch.security.tools.SecurityAdmin +set OPENSEARCH_ADDITIONAL_CLASSPATH_DIRECTORIES=plugins/opensearch-security + +rem Forward JAVA_OPTS into OPENSEARCH_JAVA_OPTS for backward compatibility +if defined JAVA_OPTS ( + set OPENSEARCH_JAVA_OPTS=%JAVA_OPTS% %OPENSEARCH_JAVA_OPTS% +) + +"%~dp0..\..\..\bin\opensearch-cli.bat" %* diff --git a/tools/securityadmin.sh b/tools/securityadmin.sh index 91fb8271c5..f170a928f0 100755 --- a/tools/securityadmin.sh +++ b/tools/securityadmin.sh @@ -1,29 +1,14 @@ -#!/bin/bash +#!/usr/bin/env bash -SCRIPT_PATH="${BASH_SOURCE[0]}" -if ! [ -x "$(command -v realpath)" ]; then - if [ -L "$SCRIPT_PATH" ]; then +set -e -o pipefail - [ -x "$(command -v readlink)" ] || { echo "Not able to resolve symlink. Install realpath or readlink.";exit 1; } +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" - # try readlink (-f not needed because we know its a symlink) - DIR="$( cd "$( dirname $(readlink "$SCRIPT_PATH") )" && pwd -P)" - else - DIR="$( cd "$( dirname "$SCRIPT_PATH" )" && pwd -P)" - fi -else - DIR="$( cd "$( dirname "$(realpath "$SCRIPT_PATH")" )" && pwd -P)" -fi +# Forward JAVA_OPTS into OPENSEARCH_JAVA_OPTS for backward compatibility +OPENSEARCH_JAVA_OPTS="${JAVA_OPTS:+${JAVA_OPTS} }${OPENSEARCH_JAVA_OPTS}" -BIN_PATH="java" - -# now set the path to java: first OPENSEARCH_JAVA_HOME, then JAVA_HOME -if [ ! -z "$OPENSEARCH_JAVA_HOME" ]; then - BIN_PATH="$OPENSEARCH_JAVA_HOME/bin/java" -elif [ ! -z "$JAVA_HOME" ]; then - BIN_PATH="$JAVA_HOME/bin/java" -else - echo "WARNING: nor OPENSEARCH_JAVA_HOME nor JAVA_HOME is set, will use $(which $BIN_PATH)" -fi - -"$BIN_PATH" $JAVA_OPTS -Dorg.apache.logging.log4j.simplelog.StatusLogger.level=OFF -cp "$DIR/../*:$DIR/../../../lib/*:$DIR/../deps/*" org.opensearch.security.tools.SecurityAdmin "$@" 2>/dev/null +OPENSEARCH_MAIN_CLASS=org.opensearch.security.tools.SecurityAdmin \ + OPENSEARCH_ADDITIONAL_CLASSPATH_DIRECTORIES=plugins/opensearch-security \ + OPENSEARCH_JAVA_OPTS="$OPENSEARCH_JAVA_OPTS" \ + "${SCRIPT_DIR}/../../../bin/opensearch-cli" \ + "$@" From 120e5f95a08aff1466d81302796f804a707136d7 Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Thu, 25 Jun 2026 17:40:57 +0200 Subject: [PATCH 3/9] OBO-Tokens hardening Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad --- .../jwt/EncryptionDecryptionUtil.java | 56 ++- .../jwt/claims/OBOJwtClaimsBuilder.java | 4 +- .../http/OnBehalfOfAuthenticator.java | 83 +++- .../identity/SecurityTokenManager.java | 5 +- .../securityconf/DynamicConfigModelV7.java | 5 +- .../securityconf/impl/v7/ConfigV7.java | 19 +- .../opensearch/security/support/FipsMode.java | 2 +- .../jwt/EncryptionDecryptionUtilsTest.java | 57 ++- .../security/authtoken/jwt/JwtVendorTest.java | 25 +- .../http/OnBehalfOfAuthenticatorTest.java | 462 +++++++++++++----- .../securityconf/impl/v7/ConfigV7Test.java | 36 ++ .../SslSettingsManagerReloadListenerTest.java | 5 +- .../security/ssl/SslSettingsManagerTest.java | 5 +- .../PemKeyReaderLoadSecretKeyTest.java | 110 ++--- .../security/test/helper/file/FileHelper.java | 36 ++ .../util/BCFipsEntropyDaemonFilter.java | 22 + 16 files changed, 696 insertions(+), 236 deletions(-) create mode 100644 src/test/java/org/opensearch/security/util/BCFipsEntropyDaemonFilter.java diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java index 13c68bb759..65294b0c84 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java @@ -23,6 +23,10 @@ import org.bouncycastle.crypto.KDFCalculator; import org.bouncycastle.crypto.fips.FipsKDF; +import org.opensearch.common.settings.Settings; +import org.opensearch.security.support.FipsMode; +import org.opensearch.security.util.KeyUtils; + public class EncryptionDecryptionUtil { private static final byte[] HKDF_INFO = "opensearch-obo-jwt-encryption".getBytes(StandardCharsets.UTF_8); @@ -30,11 +34,46 @@ public class EncryptionDecryptionUtil { private static final int GCM_TAG_LENGTH = 128; // bits private static final String AES_GCM_NO_PADDING = "AES/GCM/NoPadding"; + // HKDF derives an AES-256 key (32 bytes). + private static final int AES_KEY_LENGTH_BYTES = 32; + + // A KDF cannot create entropy, so the derived key is only as strong as its input keying material. + // We therefore require at least as much IKM as the derived key it backs (AES-256). + private static final int MINIMUM_IKM_BYTES = AES_KEY_LENGTH_BYTES; + private final SecretKey aesKey; private final SecureRandom secureRandom = new SecureRandom(); - public EncryptionDecryptionUtil(final String secret) { - this.aesKey = deriveKey(secret); + /** + * Resolves the encryption key from {@code } in the given settings, supporting either a keystore + * (e.g. BCFKS, keeping the key out of cluster state) or a Base64-encoded plaintext value. Both issuance + * and verification call this so they derive the same AES key from the same key material. + * + * @return a util instance, or {@code null} if no key is configured + */ + public static EncryptionDecryptionUtil fromSettings(final Settings settings, final String prefix) { + final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, prefix); + if (keystoreKey != null) { + return new EncryptionDecryptionUtil(keystoreKey.getEncoded()); + } + final String configured = settings.get(prefix); + return configured != null ? new EncryptionDecryptionUtil(configured) : null; + } + + public EncryptionDecryptionUtil(final String encodedSecret) { + this(decodeBase64(encodedSecret)); + } + + public EncryptionDecryptionUtil(final byte[] secretBytes) { + this.aesKey = deriveKey(secretBytes); + } + + private static byte[] decodeBase64(final String secret) { + try { + return Base64.getDecoder().decode(secret); + } catch (final IllegalArgumentException e) { + throw new RuntimeException("encryption_key is not a valid Base64-encoded value", e); + } } public String encrypt(final String data) { @@ -67,18 +106,25 @@ public String decrypt(final String encryptedString) { } } - private static SecretKey deriveKey(final String secret) { + private static SecretKey deriveKey(final byte[] secretBytes) { + if (FipsMode.isEnabled() && secretBytes.length < MINIMUM_IKM_BYTES) { + throw new IllegalArgumentException( + "Configured encryption_key is not strong enough for FIPS mode. Please configure an encryption_key with more key material." + ); + } + try { - final byte[] secretBytes = Base64.getDecoder().decode(secret); FipsKDF.AgreementKDFParameters hkdfParams = FipsKDF.HKDF.withPRF(FipsKDF.AgreementKDFPRF.SHA256_HMAC) .using(secretBytes) .withIV(HKDF_INFO); // BC FIPS maps withIV → HKDF info (expand-phase context binding per RFC 5869) KDFCalculator kdf = new FipsKDF.AgreementOperatorFactory().createKDFCalculator(hkdfParams); - byte[] derivedKey = new byte[32]; // 256 bits for AES-256 + byte[] derivedKey = new byte[AES_KEY_LENGTH_BYTES]; // AES-256 kdf.generateBytes(derivedKey); return new SecretKeySpec(derivedKey, "AES"); } catch (final Exception e) { throw new RuntimeException("Error deriving key from secret", e); + } finally { + Arrays.fill(secretBytes, (byte) 0); } } } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java index 2b9b5c7abe..0bc74805d2 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java @@ -18,9 +18,9 @@ public class OBOJwtClaimsBuilder extends JwtClaimsBuilder { private final EncryptionDecryptionUtil encryptionDecryptionUtil; - public OBOJwtClaimsBuilder(String encryptionKey) { + public OBOJwtClaimsBuilder(EncryptionDecryptionUtil encryptionDecryptionUtil) { super(); - this.encryptionDecryptionUtil = encryptionKey != null ? new EncryptionDecryptionUtil(encryptionKey) : null; + this.encryptionDecryptionUtil = encryptionDecryptionUtil; } public OBOJwtClaimsBuilder addRoles(List roles) { diff --git a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java index b8a39f7d6b..efa2b85f74 100644 --- a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java @@ -12,6 +12,7 @@ package org.opensearch.security.http; import java.util.Arrays; +import java.util.Base64; import java.util.Collection; import java.util.List; import java.util.Map.Entry; @@ -19,6 +20,7 @@ import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; +import javax.crypto.SecretKey; import org.apache.hc.core5.http.HttpHeaders; import org.apache.logging.log4j.LogManager; @@ -38,42 +40,90 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.WeakKeyException; public class OnBehalfOfAuthenticator implements HTTPAuthenticator { private static final int MINIMUM_SIGNING_KEY_BIT_LENGTH = 512; + private static final String SIGNING_KEY = "signing_key"; + private static final String ENCRYPTION_KEY = "encryption_key"; protected final Logger log = LogManager.getLogger(this.getClass()); private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE); private static final String BEARER_PREFIX = "bearer "; - private final JwtParser jwtParser; - private final String encryptionKey; + private final Settings settings; private final Boolean enabled; private final String clusterName; - - private final EncryptionDecryptionUtil encryptionUtil; + private volatile boolean initialized = false; + private volatile JwtParser jwtParser; + private volatile EncryptionDecryptionUtil encryptionUtil; public OnBehalfOfAuthenticator(Settings settings, String clusterName) { - enabled = settings.getAsBoolean("enabled", Boolean.TRUE); - encryptionKey = settings.get("encryption_key"); - jwtParser = AccessController.doPrivileged(() -> { - JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); - return builder.build(); - }); + this.enabled = settings.getAsBoolean("enabled", Boolean.TRUE); + this.settings = settings; this.clusterName = clusterName; - this.encryptionUtil = encryptionKey != null ? new EncryptionDecryptionUtil(encryptionKey) : null; } - private JwtParserBuilder initParserBuilder(final String signingKey) { + /** + * Builds the JWT parser and encryption helper on first use. Initialization is attempted exactly once. + * + * @return {@code true} if OBO authentication is usable, {@code false} if it is misconfigured + */ + private synchronized boolean ensureInitialized() { + if (!initialized) { + initialized = true; + try { + jwtParser = AccessController.doPrivileged(this::buildJwtParser); + encryptionUtil = EncryptionDecryptionUtil.fromSettings(settings, ENCRYPTION_KEY); + } catch (final RuntimeException e) { + log.error("On-behalf-of authentication is misconfigured; OBO tokens will be rejected: {}", e.toString(), e); + } + } + return jwtParser != null; + } + + /** + * Builds the HMAC verification parser. The signing key may be supplied either via a keystore (e.g. BCFKS, + * keeping the key out of cluster state) or as a Base64-encoded {@code signing_key} setting. The keystore + * path mirrors how {@link org.opensearch.security.authtoken.jwt.JwtVendor} signs OBO tokens, so issuance + * and verification share the same key material. + */ + private JwtParser buildJwtParser() { + final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, SIGNING_KEY); + if (keystoreKey != null) { + final byte[] keyBytes = keystoreKey.getEncoded(); + validateSigningKeyBitLength(keyBytes.length * Byte.SIZE); + return Jwts.parser().verifyWith(Keys.hmacShaKeyFor(keyBytes)).build(); + } + final String signingKey = settings.get(SIGNING_KEY); + validateSigningKey(signingKey); + return KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log).build(); + } + + /** + * Validates a Base64-encoded {@code signing_key}, throwing {@link OpenSearchSecurityException} with a + * descriptive message when it is missing, not valid Base64, or below the + * {@value #MINIMUM_SIGNING_KEY_BIT_LENGTH}-bit minimum. Package-private static so it can be unit-tested + * directly without standing up an authenticator. + */ + static void validateSigningKey(final String signingKey) { if (signingKey == null) { throw new OpenSearchSecurityException("Unable to find on behalf of authenticator signing_key"); } + final int signingKeyLengthBits; + try { + signingKeyLengthBits = Base64.getDecoder().decode(signingKey).length * Byte.SIZE; + } catch (final IllegalArgumentException e) { + throw new OpenSearchSecurityException("Signing key is not a valid Base64-encoded value: " + e.getMessage()); + } + validateSigningKeyBitLength(signingKeyLengthBits); + } - final int signingKeyLengthBits = signingKey.length() * 8; + static void validateSigningKeyBitLength(final int signingKeyLengthBits) { if (signingKeyLengthBits < MINIMUM_SIGNING_KEY_BIT_LENGTH) { throw new OpenSearchSecurityException( "Signing key size was " @@ -83,9 +133,6 @@ private JwtParserBuilder initParserBuilder(final String signingKey) { + " bits." ); } - JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); - - return jwtParserBuilder; } private List extractSecurityRolesFromClaims(Claims claims) { @@ -148,7 +195,7 @@ private AuthCredentials extractCredentials0(final SecurityRequest request) { } String jwtToken = extractJwtFromHeader(request); - if (jwtToken == null) { + if (jwtToken == null || !ensureInitialized()) { return null; } diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index 88b2c7d890..c22a07f7b6 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -29,6 +29,7 @@ import org.opensearch.identity.tokens.AuthToken; import org.opensearch.identity.tokens.OnBehalfOfClaims; import org.opensearch.identity.tokens.TokenManager; +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; @@ -129,7 +130,9 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final throw new IllegalArgumentException("Roles cannot be null"); } - final OBOJwtClaimsBuilder claimsBuilder = new OBOJwtClaimsBuilder(oboSettings.get("encryption_key")); + final OBOJwtClaimsBuilder claimsBuilder = new OBOJwtClaimsBuilder( + EncryptionDecryptionUtil.fromSettings(oboSettings, "encryption_key") + ); // Add obo claims claimsBuilder.issuer(cs.getClusterName().value()); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 686ffecead..c4c137e0c4 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -67,6 +67,7 @@ import org.opensearch.security.securityconf.impl.v7.ConfigV7.Authz; import org.opensearch.security.securityconf.impl.v7.ConfigV7.AuthzDomain; import org.opensearch.security.support.ReflectionHelper; +import org.opensearch.security.util.KeyUtils; public class DynamicConfigModelV7 extends DynamicConfigModel { @@ -393,7 +394,9 @@ private void buildAAA() { * order: -1 - prioritize the OBO authentication when it gets enabled */ Settings oboSettings = getDynamicOnBehalfOfSettings(); - if (oboSettings.get("signing_key") != null) { + final boolean signingKeyConfigured = oboSettings.get("signing_key") != null + || oboSettings.get("signing_key" + KeyUtils.KEYSTORE_ALIAS) != null; + if (signingKeyConfigured) { final AuthDomain _ad = new AuthDomain( new NoOpAuthenticationBackend(Settings.EMPTY, null), new OnBehalfOfAuthenticator(getDynamicOnBehalfOfSettings(), this.cih.getClusterName()), diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 1d6edec335..a35a31081c 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -460,6 +460,18 @@ public static class OnBehalfOfSettings { @JsonProperty("encryption_key") private String encryptionKey; + private final Map additionalSettings = new HashMap<>(); + + @JsonAnySetter + public void setAdditionalSetting(final String name, final Object value) { + additionalSettings.put(name, value); + } + + @JsonAnyGetter + public Map getAdditionalSettings() { + return additionalSettings; + } + @JsonIgnore public String configAsJson() { return DefaultObjectMapper.writeValueAsString(this, false); @@ -491,7 +503,12 @@ public void setEncryptionKey(String encryptionKey) { @Override public String toString() { - return "OnBehalfOfSettings [ enabled=" + enabled + ", signing_key=" + signingKey + ", encryption_key=" + encryptionKey + "]"; + return String.format( + "OnBehalfOfSettings [ enabled=%s, signing_key=%s, encryption_key=%s]", + enabled, + signingKey != null ? "****" : "", + encryptionKey != null ? "****" : "" + ); } } diff --git a/src/main/java/org/opensearch/security/support/FipsMode.java b/src/main/java/org/opensearch/security/support/FipsMode.java index b612cfc163..169a5d4208 100644 --- a/src/main/java/org/opensearch/security/support/FipsMode.java +++ b/src/main/java/org/opensearch/security/support/FipsMode.java @@ -14,7 +14,7 @@ */ public final class FipsMode { - static java.util.function.Supplier envSupplier = () -> System.getenv("OPENSEARCH_FIPS_MODE"); + public static java.util.function.Supplier envSupplier = () -> System.getenv("OPENSEARCH_FIPS_MODE"); public static boolean isEnabled() { return "true".equalsIgnoreCase(envSupplier.get()); diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java index d9f663d9fe..50f722649c 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java @@ -11,19 +11,31 @@ package org.opensearch.security.authtoken.jwt; +import java.nio.charset.StandardCharsets; import java.util.Base64; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; +import org.opensearch.security.support.FipsMode; + import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; public class EncryptionDecryptionUtilsTest { + // encryption_key is consumed Base64-decoded; a 32-char alphanumeric string decodes to only 24 bytes, + // under the 32-byte AES-256 floor FIPS requires. Base64-encode the 32 bytes so it decodes back to 32. + final static String key = RandomStringUtils.secure().nextAlphanumeric(32); + final static String encodedKey = Base64.getEncoder() + .encodeToString(key.getBytes(StandardCharsets.UTF_8)); + @Test public void testEncryptDecrypt() { - String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String secret = encodedKey; String data = "Hello, OpenSearch!"; EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); @@ -36,8 +48,8 @@ public void testEncryptDecrypt() { @Test public void testDecryptingWithWrongKey() { - String secret1 = Base64.getEncoder().encodeToString("correctKey12345".getBytes()); - String secret2 = Base64.getEncoder().encodeToString("wrongKey1234567".getBytes()); + String secret1 = Base64.getEncoder().encodeToString("correctKey123456correctKey123456".getBytes()); + String secret2 = Base64.getEncoder().encodeToString("wrongKey12345678wrongKey12345678".getBytes()); String data = "Hello, OpenSearch!"; EncryptionDecryptionUtil util1 = new EncryptionDecryptionUtil(secret1); @@ -51,7 +63,7 @@ public void testDecryptingWithWrongKey() { @Test public void testDecryptingCorruptedData() { - String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String secret = encodedKey; String corruptedEncryptedString = "corruptedData"; EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); @@ -62,7 +74,7 @@ public void testDecryptingCorruptedData() { @Test public void testEncryptDecryptEmptyString() { - String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String secret = encodedKey; String data = ""; EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); @@ -74,19 +86,48 @@ public void testEncryptDecryptEmptyString() { @Test(expected = NullPointerException.class) public void testEncryptNullValue() { - String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String secret = encodedKey; String data = null; EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); util.encrypt(data); } + @Test + public void testFipsModeRejectsWeakEncryptionKey() { + Assume.assumeTrue(FipsMode.isEnabled()); + // 16-byte (128-bit) key material — below the 256-bit minimum required to back the derived AES-256 key + String weakSecret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + + IllegalArgumentException ex = Assert.assertThrows(IllegalArgumentException.class, () -> new EncryptionDecryptionUtil(weakSecret)); + assertThat(ex.getMessage(), containsString("not strong enough for FIPS mode")); + } + + @Test + public void testFipsModeAcceptsStrongEncryptionKey() { + FipsMode.envSupplier = () -> "true"; + // 32-byte (256-bit) key material satisfies the FIPS minimum + String strongSecret = Base64.getEncoder().encodeToString("mySecretKey12345mySecretKey12345".getBytes()); + String data = "Hello, OpenSearch!"; + + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(strongSecret); + + assertThat(util.decrypt(util.encrypt(data)), is(data)); + } + + @Test + public void testByteArrayConstructorWipesInput() { + final byte[] ikm = new byte[32]; + java.util.Arrays.fill(ikm, (byte) 9); + new EncryptionDecryptionUtil(ikm); + assertThat("IKM should be zeroed after key derivation", ikm, is(new byte[32])); + } + @Test(expected = NullPointerException.class) public void testDecryptNullValue() { - String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); String data = null; - EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(encodedKey); util.decrypt(data); } } diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index 779b1b3a8b..b7cfd0f85f 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -69,6 +69,12 @@ public class JwtVendorTest { "This is my super safe signing key that no one will ever be able to guess. It's would take billions of years and the world's most powerful quantum computer to crack"; final static String signingKeyB64Encoded = Base64.getEncoder().encodeToString(signingKey.getBytes(StandardCharsets.UTF_8)); + // encryption_key is consumed Base64-decoded; a 32-char alphanumeric string decodes to only 24 bytes, + // under the 32-byte AES-256 floor FIPS requires. Base64-encode the 32 bytes so it decodes back to 32. + final static String claimsEncryptionKey = RandomStringUtils.secure().nextAlphanumeric(32); + final static String claimsEncryptionKeyB64Encoded = Base64.getEncoder() + .encodeToString(claimsEncryptionKey.getBytes(StandardCharsets.UTF_8)); + @Test public void testCreateJwkFromSettings() { final Settings settings = Settings.builder().put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded).build(); @@ -138,17 +144,16 @@ public void testCreateJwtWithRoles() throws Exception { int expirySeconds = 300; // 2023 oct 4, 10:00:00 AM GMT LongSupplier currentTime = () -> 1696413600000L; - String claimsEncryptionKey = "1234567890123456"; Settings settings = Settings.builder() .put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded) - .put("encryption_key", claimsEncryptionKey) + .put("encryption_key", claimsEncryptionKeyB64Encoded) .build(); Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); JwtVendor OBOJwtVendor = new JwtVendor(settings); final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( - new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + new OBOJwtClaimsBuilder(new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded)).addRoles(roles) .addBackendRoles(false, backendRoles) .issuer(issuer) .subject(subject) @@ -169,7 +174,7 @@ public void testCreateJwtWithRoles() throws Exception { assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("iat")).getTime(), is(1696413600000L)); // 2023 oct 4, 10:05:00 AM GMT assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime(), is(1696413900000L)); - EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded); assertThat( encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("encrypted_roles").toString()), equalTo(expectedRoles) @@ -189,17 +194,16 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { int expirySeconds = 300; LongSupplier currentTime = () -> (long) 100; - String claimsEncryptionKey = "1234567890123456"; Settings settings = Settings.builder() .put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded) - .put("encryption_key", claimsEncryptionKey) + .put("encryption_key", claimsEncryptionKeyB64Encoded) .put(ConfigConstants.EXTENSIONS_BWC_PLUGIN_MODE, true) .build(); final JwtVendor OBOJwtVendor = new JwtVendor(settings); Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( - new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + new OBOJwtClaimsBuilder(new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded)).addRoles(roles) .addBackendRoles(true, backendRoles) .issuer(issuer) .subject(subject) @@ -221,7 +225,7 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { assertThat(signedJWT.getJWTClaimsSet().getClaims().get("backend_roles"), is(notNullValue())); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("backend_roles").toString(), equalTo(expectedBackendRoles)); - EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded); assertThat( encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("encrypted_roles").toString()), equalTo(expectedRoles) @@ -240,10 +244,9 @@ public void testCreateJwtLogsCorrectly() throws Exception { // Mock settings and other required dependencies LongSupplier currentTime = () -> (long) 100; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); Settings settings = Settings.builder() .put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded) - .put("encryption_key", claimsEncryptionKey) + .put("encryption_key", claimsEncryptionKeyB64Encoded) .build(); final String issuer = "cluster_0"; @@ -256,7 +259,7 @@ public void testCreateJwtLogsCorrectly() throws Exception { final JwtVendor OBOJwtVendor = new JwtVendor(settings); Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); OBOJwtVendor.createJwt( - new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + new OBOJwtClaimsBuilder(new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded)).addRoles(roles) .addBackendRoles(true, backendRoles) .issuer(issuer) .subject(subject) diff --git a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java index ce49940923..86f7b4aae3 100644 --- a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java @@ -23,7 +23,10 @@ import java.util.Optional; import java.util.Set; import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import com.carrotsearch.randomizedtesting.RandomizedRunner; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import org.apache.commons.lang3.RandomStringUtils; import org.apache.hc.core5.http.HttpHeaders; import org.apache.logging.log4j.Level; @@ -31,18 +34,24 @@ import org.apache.logging.log4j.core.Appender; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.Logger; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; import org.opensearch.OpenSearchSecurityException; import org.opensearch.common.settings.Settings; import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.security.filter.SecurityResponse; +import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.BCFipsEntropyDaemonFilter; import org.opensearch.security.util.FakeRestRequest; +import org.opensearch.security.util.KeyUtils; +import org.opensearch.test.BouncyCastleThreadFilter; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.mockito.ArgumentCaptor; @@ -54,100 +63,121 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@RunWith(RandomizedRunner.class) +@ThreadLeakFilters(filters = { BouncyCastleThreadFilter.class, BCFipsEntropyDaemonFilter.class }) public class OnBehalfOfAuthenticatorTest { final static String clusterName = "cluster_0"; final static String enableOBO = "true"; final static String disableOBO = "false"; - final static String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + // encryption_key is consumed Base64-decoded; a 32-char alphanumeric string decodes to only 24 bytes, + // under the 32-byte AES-256 floor FIPS requires. Base64-encode the 32 bytes so it decodes back to 32. + final static String claimsEncryptionKey = RandomStringUtils.secure().nextAlphanumeric(32); + final static String claimsEncryptionKeyB64Encoded = Base64.getEncoder() + .encodeToString(claimsEncryptionKey.getBytes(StandardCharsets.UTF_8)); final static String signingKey = "This is my super safe signing key that no one will ever be able to guess. It's would take billions of years and the world's most powerful quantum computer to crack"; final static String signingKeyB64Encoded = Base64.getEncoder().encodeToString(signingKey.getBytes(StandardCharsets.UTF_8)); final static SecretKey secretKey = Keys.hmacShaKeyFor(signingKeyB64Encoded.getBytes(StandardCharsets.UTF_8)); - private static final String SECURITY_PREFIX = "/_plugins/_security/"; - private static final String ON_BEHALF_OF_SUFFIX = "api/obo/token"; - private static final String ACCOUNT_SUFFIX = "api/account"; + private static final String KEYSTORE_STORE_PASSWORD = "kspass"; + private static final String KEYSTORE_KEY_PASSWORD = "keypass"; + + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); @Test - public void testReRequestAuthenticationReturnsEmptyOptional() { + public void testReRequestAuthenticationReturnsEmptyOptional() throws Exception { OnBehalfOfAuthenticator authenticator = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); Optional result = authenticator.reRequestAuthentication(null, null); assertFalse(result.isPresent()); } @Test - public void testGetTypeReturnsExpectedType() { + public void testGetTypeReturnsExpectedType() throws Exception { OnBehalfOfAuthenticator authenticator = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); String type = authenticator.getType(); assertThat(type, is("onbehalfof_jwt")); } @Test - public void testNoKey() { - Exception exception = assertThrows( - RuntimeException.class, - () -> extractCredentialsFromJwtHeader( - null, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), - false - ) + public void testNoSigningKey() throws Exception { + OpenSearchSecurityException ex = assertThrows( + OpenSearchSecurityException.class, + () -> OnBehalfOfAuthenticator.validateSigningKey(null) ); - assertThat(exception.getMessage(), equalTo("Unable to find on behalf of authenticator signing_key")); + assertThat(ex.getMessage(), equalTo("Unable to find on behalf of authenticator signing_key")); } @Test - public void testEmptyKey() { - Exception exception = assertThrows( - RuntimeException.class, - () -> extractCredentialsFromJwtHeader( - "", - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), - false - ) + public void testEmptySigningKey() throws Exception { + OpenSearchSecurityException ex = assertThrows( + OpenSearchSecurityException.class, + () -> OnBehalfOfAuthenticator.validateSigningKey("") ); assertThat( - exception.getMessage(), + ex.getMessage(), equalTo("Signing key size was 0 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") ); } @Test - public void testBadKey() { - Exception exception = assertThrows( - RuntimeException.class, - () -> extractCredentialsFromJwtHeader( - Base64.getEncoder().encodeToString(new byte[] { 1, 3, 3, 4, 3, 6, 7, 8, 3, 10 }), - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), - false - ) + public void testSigningKeyOf80BitsRejected() throws Exception { + // 10 raw bytes -> Base64-decoded length is 80 bits, below the 512-bit minimum + String key = Base64.getEncoder().encodeToString(new byte[] { 1, 3, 3, 4, 3, 6, 7, 8, 3, 10 }); + OpenSearchSecurityException ex = assertThrows( + OpenSearchSecurityException.class, + () -> OnBehalfOfAuthenticator.validateSigningKey(key) ); assertThat( - exception.getMessage(), - equalTo("Signing key size was 128 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ex.getMessage(), + equalTo("Signing key size was 80 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") ); } @Test - public void testWeakKeyExceptionHandling() throws Exception { - Settings settings = Settings.builder().put("signing_key", "testKey").put("encryption_key", claimsEncryptionKey).build(); - try { - OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, "testCluster"); - fail("Expected WeakKeyException"); - } catch (OpenSearchSecurityException e) { - assertThat( - e.getMessage(), - equalTo("Signing key size was 56 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") - ); - } + public void testSigningKeyOf384BitsRejected() throws Exception { + // 64 Base64 characters decode to only 48 bytes = 384 bits, below the 512-bit minimum + final String key = Base64.getEncoder().encodeToString(new byte[48]); + assertThat(key.length(), equalTo(64)); + OpenSearchSecurityException ex = assertThrows( + OpenSearchSecurityException.class, + () -> OnBehalfOfAuthenticator.validateSigningKey(key) + ); + assertThat( + ex.getMessage(), + equalTo("Signing key size was 384 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ); + } + + @Test + public void testSigningKeyOf40BitsRejected() throws Exception { + // "testKey" Base64-decodes to 5 bytes = 40 bits + OpenSearchSecurityException ex = assertThrows( + OpenSearchSecurityException.class, + () -> OnBehalfOfAuthenticator.validateSigningKey("testKey") + ); + assertThat( + ex.getMessage(), + equalTo("Signing key size was 40 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ); + } + + @Test + public void testMisconfiguredKeyDeclinesWithoutThrowing() throws Exception { + Settings settings = Settings.builder() + .put("enabled", enableOBO) + .put("signing_key", "testKey") // misconfigured signing key + .put("encryption_key", claimsEncryptionKeyB64Encoded) + .build(); + OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName); + Map headers = Map.of("Authorization", "Bearer a.b.c"); + AuthCredentials credentials = auth.extractCredentials(new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), null); + assertNull(credentials); } @Test @@ -183,10 +213,12 @@ public void testInvalid() throws Exception { @Test public void testDisabled() throws Exception { String jwsToken = Jwts.builder() - .setIssuer(clusterName) - .setSubject("Leonard McCoy") - .setAudience("ext_0") - .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("ext_0") + .and() + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(disableOBOSettings(), clusterName); @@ -201,7 +233,7 @@ public void testDisabled() throws Exception { } @Test - public void testInvalidTokenException() { + public void testInvalidTokenException() throws Exception { Appender mockAppender = mock(Appender.class); ArgumentCaptor logEventCaptor = ArgumentCaptor.forClass(LogEvent.class); when(mockAppender.getName()).thenReturn("MockAppender"); @@ -236,10 +268,12 @@ public void testInvalidTokenException() { @Test public void testNonSpecifyOBOSetting() throws Exception { String jwsToken = Jwts.builder() - .setIssuer(clusterName) - .setSubject("Leonard McCoy") - .setAudience("ext_0") - .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("ext_0") + .and() + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(nonSpecifyOBOSetting(), clusterName); @@ -261,10 +295,12 @@ public void testBearer() throws Exception { expectedAttributes.put("attr.jwt.aud", "[\"ext_0\"]"); String jwsToken = Jwts.builder() - .setIssuer(clusterName) - .setSubject("Leonard McCoy") - .setAudience("ext_0") - .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("ext_0") + .and() + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); @@ -287,10 +323,12 @@ public void testBearer() throws Exception { public void testBearerWrongPosition() throws Exception { String jwsToken = Jwts.builder() - .setIssuer(clusterName) - .setSubject("Leonard McCoy") - .setAudience("ext_0") - .signWith(secretKey, SignatureAlgorithm.HS512) + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("ext_0") + .and() + .signWith(secretKey, Jwts.SIG.HS512) .compact(); OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); @@ -308,10 +346,12 @@ public void testBearerWrongPosition() throws Exception { @Test public void testBasicAuthHeader() throws Exception { String jwsToken = Jwts.builder() - .setIssuer(clusterName) - .setSubject("Leonard McCoy") - .setAudience("ext_0") - .signWith(secretKey, SignatureAlgorithm.HS512) + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("ext_0") + .and() + .signWith(secretKey, Jwts.SIG.HS512) .compact(); OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); @@ -356,12 +396,14 @@ public void testMissingBearerScheme() throws Exception { } @Test - public void testMissingBearerPrefixInAuthHeader() { + public void testMissingBearerPrefixInAuthHeader() throws Exception { String jwsToken = Jwts.builder() - .setIssuer(clusterName) - .setSubject("Leonard McCoy") - .setAudience("ext_0") - .signWith(secretKey, SignatureAlgorithm.HS512) + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("ext_0") + .and() + .signWith(secretKey, Jwts.SIG.HS512) .compact(); OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); @@ -377,12 +419,12 @@ public void testMissingBearerPrefixInAuthHeader() { } @Test - public void testPlainTextedRolesFromDrClaim() { + public void testPlainTextedRolesFromDrClaim() throws Exception { final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").claim("roles", "role1,role2").setAudience("svc1"), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).subject("Leonard McCoy").claim("roles", "role1,role2").audience().add("svc1").and(), true ); @@ -393,13 +435,157 @@ public void testPlainTextedRolesFromDrClaim() { } @Test - public void testBackendRolesExtraction() { + public void testSigningKeyFromKeystoreVerifiesToken() throws Exception { + final byte[] keyBytes = Base64.getDecoder().decode(signingKeyB64Encoded); + final FileHelper.TypedStore typedStore = FileHelper.storeSecretKey( + tempDir, + "obo-signing", + new SecretKeySpec(keyBytes, "HmacSHA512"), + KEYSTORE_STORE_PASSWORD, + KEYSTORE_KEY_PASSWORD + ); + + final Settings settings = putKeystoreSettings( + Settings.builder().put("enabled", enableOBO).put("encryption_key", claimsEncryptionKeyB64Encoded), + typedStore, + "signing_key", + "obo-signing" + ).build(); + + final String jwsToken = Jwts.builder() + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("svc1") + .and() + .claim("roles", "role1,role2") + .signWith(Keys.hmacShaKeyFor(keyBytes), Jwts.SIG.HS512) + .compact(); + + final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName); + final Map headers = Map.of("Authorization", "Bearer " + jwsToken); + final AuthCredentials credentials = auth.extractCredentials( + new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), + null + ); + + assertNotNull(credentials); + assertThat(credentials.getUsername(), is("Leonard McCoy")); + assertThat(credentials.getSecurityRoles().size(), is(2)); + } + + @Test + public void testEncryptionKeyFromKeystoreDecryptsRoles() throws Exception { + final byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 7); + final FileHelper.TypedStore typedStore = FileHelper.storeSecretKey( + tempDir, + "obo-enc", + new SecretKeySpec(aesKeyBytes, "AES"), + KEYSTORE_STORE_PASSWORD, + KEYSTORE_KEY_PASSWORD + ); + + final Settings settings = putKeystoreSettings( + Settings.builder().put("enabled", enableOBO).put("signing_key", signingKeyB64Encoded), + typedStore, + "encryption_key", + "obo-enc" + ).build(); + + // Simulate issuance: encrypt the roles with the same keystore-derived key + final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings(settings, "encryption_key"); + final String encryptedRoles = issuerUtil.encrypt("role1,role2"); + + final String jwsToken = Jwts.builder() + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("svc1") + .and() + .claim("encrypted_roles", encryptedRoles) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) + .compact(); + + final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName); + final Map headers = Map.of("Authorization", "Bearer " + jwsToken); + final AuthCredentials credentials = auth.extractCredentials( + new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), + null + ); + + assertNotNull(credentials); + assertThat(credentials.getSecurityRoles().size(), is(2)); + assertTrue(credentials.getSecurityRoles().contains("role1")); + assertTrue(credentials.getSecurityRoles().contains("role2")); + } + + @Test + public void testSigningAndEncryptionKeysBothFromKeystore() throws Exception { + final byte[] signingKeyBytes = Base64.getDecoder().decode(signingKeyB64Encoded); + final FileHelper.TypedStore signingStore = FileHelper.storeSecretKey( + tempDir, + "obo-signing", + new SecretKeySpec(signingKeyBytes, "HmacSHA512"), + KEYSTORE_STORE_PASSWORD, + KEYSTORE_KEY_PASSWORD + ); + + final byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 7); + final FileHelper.TypedStore encryptionStore = FileHelper.storeSecretKey( + tempDir, + "obo-enc", + new SecretKeySpec(aesKeyBytes, "AES"), + KEYSTORE_STORE_PASSWORD, + KEYSTORE_KEY_PASSWORD + ); + + Settings.Builder builder = putKeystoreSettings( + Settings.builder().put("enabled", enableOBO), + signingStore, + "signing_key", + "obo-signing" + ); + builder = putKeystoreSettings(builder, encryptionStore, "encryption_key", "obo-enc"); + final Settings settings = builder.build(); + + // Simulate issuance: encrypt the roles with the same keystore-derived AES key the verifier will resolve. + final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings(settings, "encryption_key"); + final String encryptedRoles = issuerUtil.encrypt("role1,role2"); + + final String jwsToken = Jwts.builder() + .issuer(clusterName) + .subject("Leonard McCoy") + .audience() + .add("svc1") + .and() + .claim("encrypted_roles", encryptedRoles) + .signWith(Keys.hmacShaKeyFor(signingKeyBytes), Jwts.SIG.HS512) + .compact(); + + final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName); + final Map headers = Map.of("Authorization", "Bearer " + jwsToken); + final AuthCredentials credentials = auth.extractCredentials( + new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), + null + ); + + assertNotNull(credentials); + assertThat(credentials.getUsername(), is("Leonard McCoy")); + assertThat(credentials.getSecurityRoles().size(), is(2)); + assertTrue(credentials.getSecurityRoles().contains("role1")); + assertTrue(credentials.getSecurityRoles().contains("role2")); + } + + @Test + public void testBackendRolesExtraction() throws Exception { String rolesString = "role1, role2 ,role3,role4 , role5"; final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Test User").setAudience("audience_0").claim("backend_roles", rolesString), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).subject("Test User").audience().add("audience_0").and().claim("backend_roles", rolesString), true ); @@ -412,8 +598,8 @@ public void testBackendRolesExtraction() { } @Test - public void testRolesDecryptionFromErClaimWithNoEncryptionKeyReturnsEmpty() { - EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(claimsEncryptionKey); + public void testRolesDecryptionFromErClaimWithNoEncryptionKeyReturnsEmpty() throws Exception { + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded); String encryptedRole = util.encrypt("admin,developer"); // No encryption_key in settings @@ -422,11 +608,13 @@ public void testRolesDecryptionFromErClaimWithNoEncryptionKeyReturnsEmpty() { clusterName ); final String jwsToken = Jwts.builder() - .setIssuer(clusterName) - .setSubject("Test User") - .setAudience("audience_0") + .issuer(clusterName) + .subject("Test User") + .audience() + .add("audience_0") + .and() .claim("encrypted_roles", encryptedRole) - .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); final AuthCredentials credentials = jwtAuth.extractCredentials( new FakeRestRequest(Map.of("Authorization", "Bearer " + jwsToken), new HashMap<>()).asSecurityRequest(), @@ -438,18 +626,20 @@ public void testRolesDecryptionFromErClaimWithNoEncryptionKeyReturnsEmpty() { } @Test - public void testPlainTextRolesFromDrClaimWithNoEncryptionKey() { + public void testPlainTextRolesFromDrClaimWithNoEncryptionKey() throws Exception { // No encryption_key in settings — dr claim should still be readable final OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator( Settings.builder().put("enabled", enableOBO).put("signing_key", signingKeyB64Encoded).build(), clusterName ); final String jwsToken = Jwts.builder() - .setIssuer(clusterName) - .setSubject("Test User") - .setAudience("audience_0") + .issuer(clusterName) + .subject("Test User") + .audience() + .add("audience_0") + .and() .claim("roles", "role1,role2") - .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); final AuthCredentials credentials = jwtAuth.extractCredentials( new FakeRestRequest(Map.of("Authorization", "Bearer " + jwsToken), new HashMap<>()).asSecurityRequest(), @@ -462,14 +652,20 @@ public void testPlainTextRolesFromDrClaimWithNoEncryptionKey() { } @Test - public void testRolesDecryptionFromErClaim() { - EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(claimsEncryptionKey); + public void testRolesDecryptionFromErClaim() throws Exception { + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded); String encryptedRole = util.encrypt("admin,developer"); final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Test User").setAudience("audience_0").claim("encrypted_roles", encryptedRole), + claimsEncryptionKeyB64Encoded, + Jwts.builder() + .issuer(clusterName) + .subject("Test User") + .audience() + .add("audience_0") + .and() + .claim("encrypted_roles", encryptedRole), true ); @@ -483,8 +679,8 @@ public void testNullClaim() throws Exception { final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").claim("roles", null).setAudience("svc1"), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).subject("Leonard McCoy").claim("roles", null).audience().add("svc1").and(), false ); @@ -498,8 +694,8 @@ public void testNonStringClaim() throws Exception { final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").claim("roles", 123L).setAudience("svc1"), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).subject("Leonard McCoy").claim("roles", 123L).audience().add("svc1").and(), true ); @@ -514,8 +710,8 @@ public void testRolesMissing() throws Exception { final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").setAudience("svc1"), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).subject("Leonard McCoy").audience().add("svc1").and(), false ); @@ -530,8 +726,8 @@ public void testWrongSubjectKey() throws Exception { final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).claim("roles", "role1,role2").claim("asub", "Dr. Who").setAudience("svc1"), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).claim("roles", "role1,role2").claim("asub", "Dr. Who").audience().add("svc1").and(), false ); @@ -543,8 +739,8 @@ public void testMissingAudienceClaim() throws Exception { final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Test User").claim("roles", "role1,role2"), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).subject("Test User").claim("roles", "role1,role2"), false ); @@ -556,8 +752,8 @@ public void testExp() throws Exception { final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Expired").setExpiration(new Date(100)), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).subject("Expired").expiration(new Date(100)), false ); @@ -569,8 +765,8 @@ public void testNbf() throws Exception { final AuthCredentials credentials = extractCredentialsFromJwtHeader( signingKeyB64Encoded, - claimsEncryptionKey, - Jwts.builder().setIssuer(clusterName).setSubject("Expired").setNotBefore(new Date(System.currentTimeMillis() + (1000 * 36000))), + claimsEncryptionKeyB64Encoded, + Jwts.builder().issuer(clusterName).subject("Expired").notBefore(new Date(System.currentTimeMillis() + (1000 * 36000))), false ); @@ -591,7 +787,12 @@ public void testRolesArray() throws Exception { + "}" ); - final AuthCredentials credentials = extractCredentialsFromJwtHeader(signingKeyB64Encoded, claimsEncryptionKey, builder, true); + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKeyB64Encoded, + builder, + true + ); assertNotNull(credentials); assertThat(credentials.getUsername(), is("Cluster_0")); @@ -605,10 +806,12 @@ public void testRolesArray() throws Exception { public void testDifferentIssuer() throws Exception { String jwsToken = Jwts.builder() - .setIssuer("Wrong Cluster Identifier") - .setSubject("Leonard McCoy") - .setAudience("ext_0") - .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .issuer("Wrong Cluster Identifier") + .subject("Leonard McCoy") + .audience() + .add("ext_0") + .and() + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); @@ -623,6 +826,19 @@ public void testDifferentIssuer() throws Exception { assertNull(credentials); } + private static Settings.Builder putKeystoreSettings( + Settings.Builder builder, + FileHelper.TypedStore typedStore, + String prefix, + String alias + ) { + return builder.put(prefix + KeyUtils.KEYSTORE_PATH, typedStore.path()) + .put(prefix + KeyUtils.KEYSTORE_TYPE, typedStore.type()) + .put(prefix + KeyUtils.KEYSTORE_PASSWORD, KEYSTORE_STORE_PASSWORD) + .put(prefix + KeyUtils.KEYSTORE_ALIAS, alias) + .put(prefix + KeyUtils.KEYSTORE_KEY_PASSWORD, KEYSTORE_KEY_PASSWORD); + } + /** extracts a default user credential from a request header */ private AuthCredentials extractCredentialsFromJwtHeader( final String signingKeyB64Encoded, @@ -639,10 +855,8 @@ private AuthCredentials extractCredentialsFromJwtHeader( clusterName ); - final String jwsToken = jwtBuilder.signWith( - Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), - SignatureAlgorithm.HS512 - ).compact(); + final String jwsToken = + jwtBuilder.signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512).compact(); final Map headers = Map.of("Authorization", "Bearer " + jwsToken); return jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), null); } @@ -651,7 +865,7 @@ private Settings defaultSettings() { return Settings.builder() .put("enabled", enableOBO) .put("signing_key", signingKeyB64Encoded) - .put("encryption_key", claimsEncryptionKey) + .put("encryption_key", claimsEncryptionKeyB64Encoded) .build(); } @@ -659,15 +873,15 @@ private Settings disableOBOSettings() { return Settings.builder() .put("enabled", disableOBO) .put("signing_key", signingKeyB64Encoded) - .put("encryption_key", claimsEncryptionKey) + .put("encryption_key", claimsEncryptionKeyB64Encoded) .build(); } private Settings noSigningKeyOBOSettings() { - return Settings.builder().put("enabled", disableOBO).put("encryption_key", claimsEncryptionKey).build(); + return Settings.builder().put("enabled", disableOBO).put("encryption_key", claimsEncryptionKeyB64Encoded).build(); } private Settings nonSpecifyOBOSetting() { - return Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + return Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKeyB64Encoded).build(); } } diff --git a/src/test/java/org/opensearch/security/securityconf/impl/v7/ConfigV7Test.java b/src/test/java/org/opensearch/security/securityconf/impl/v7/ConfigV7Test.java index f0c498e15b..cf044d5bc8 100644 --- a/src/test/java/org/opensearch/security/securityconf/impl/v7/ConfigV7Test.java +++ b/src/test/java/org/opensearch/security/securityconf/impl/v7/ConfigV7Test.java @@ -26,6 +26,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; @RunWith(Parameterized.class) public class ConfigV7Test { @@ -133,4 +134,39 @@ public void testOnBehalfOfSettings() { Assert.assertNull(oboSettings.getSigningKey()); Assert.assertNull(oboSettings.getEncryptionKey()); } + + @Test + public void testOnBehalfOfSettingsCarriesKeystoreSubSettings() throws Exception { + final String json = "{\"enabled\":true," + + "\"signing_key_keystore_path\":\"/etc/obo/ks.bcfks\"," + + "\"signing_key_keystore_alias\":\"obo-signing\"," + + "\"signing_key_keystore_type\":\"BCFKS\"," + + "\"encryption_key_keystore_alias\":\"obo-enc\"}"; + + final ConfigV7.OnBehalfOfSettings obo = DefaultObjectMapper.readValue(json, ConfigV7.OnBehalfOfSettings.class); + final String rendered = obo.configAsJson(); + + assertThat(rendered, containsString("signing_key_keystore_path")); + assertThat(rendered, containsString("/etc/obo/ks.bcfks")); + assertThat(rendered, containsString("signing_key_keystore_alias")); + assertThat(rendered, containsString("obo-signing")); + assertThat(rendered, containsString("encryption_key_keystore_alias")); + } + + @Test + public void testOnBehalfOfSettingsToStringRedactsKeys() { + ConfigV7.OnBehalfOfSettings oboSettings = new ConfigV7.OnBehalfOfSettings(); + oboSettings.setSigningKey("c3VwZXItc2VjcmV0LXNpZ25pbmcta2V5"); + oboSettings.setEncryptionKey("c3VwZXItc2VjcmV0LWVuY3J5cHRpb24ta2V5"); + + final String rendered = oboSettings.toString(); + assertThat(rendered, not(containsString("c3VwZXItc2VjcmV0LXNpZ25pbmcta2V5"))); + assertThat(rendered, not(containsString("c3VwZXItc2VjcmV0LWVuY3J5cHRpb24ta2V5"))); + assertThat(rendered, containsString("signing_key=****")); + assertThat(rendered, containsString("encryption_key=****")); + + final String emptyRendered = new ConfigV7.OnBehalfOfSettings().toString(); + assertThat(emptyRendered, containsString("signing_key=")); + assertThat(emptyRendered, containsString("encryption_key=")); + } } diff --git a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerReloadListenerTest.java b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerReloadListenerTest.java index 8826ea9094..54745545b5 100644 --- a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerReloadListenerTest.java +++ b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerReloadListenerTest.java @@ -37,8 +37,9 @@ import org.opensearch.env.Environment; import org.opensearch.env.TestEnvironment; import org.opensearch.security.ssl.config.CertType; -import org.opensearch.security.support.PemKeyReaderLoadSecretKeyTest; import org.opensearch.security.test.helper.file.FileHelper; +import org.opensearch.security.util.BCFipsEntropyDaemonFilter; +import org.opensearch.test.BouncyCastleThreadFilter; import org.opensearch.threadpool.TestThreadPool; import org.opensearch.threadpool.ThreadPool; import org.opensearch.watcher.ResourceWatcherService; @@ -57,7 +58,7 @@ import static org.opensearch.security.ssl.util.SSLConfigConstants.TRUSTSTORE_TYPE; import static org.opensearch.transport.AuxTransport.AUX_TRANSPORT_TYPES_SETTING; -@ThreadLeakFilters(filters = { PemKeyReaderLoadSecretKeyTest.BCFipsEntropyDaemonFilter.class }) +@ThreadLeakFilters(filters = { BouncyCastleThreadFilter.class, BCFipsEntropyDaemonFilter.class }) public class SslSettingsManagerReloadListenerTest extends RandomizedTest { @ClassRule diff --git a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java index 4008e89b37..7eb9916c0f 100644 --- a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java +++ b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java @@ -27,10 +27,11 @@ import org.opensearch.env.Environment; import org.opensearch.env.TestEnvironment; import org.opensearch.security.ssl.config.CertType; -import org.opensearch.security.support.PemKeyReaderLoadSecretKeyTest; +import org.opensearch.security.util.BCFipsEntropyDaemonFilter; import io.netty.handler.ssl.ClientAuth; import io.netty.handler.ssl.SslContext; +import org.opensearch.test.BouncyCastleThreadFilter; import static org.hamcrest.MatcherAssert.assertThat; import static org.opensearch.security.ssl.CertificatesUtils.privateKeyToPemObject; @@ -68,7 +69,7 @@ import static org.opensearch.transport.AuxTransport.AUX_TRANSPORT_TYPES_SETTING; import static org.junit.Assert.assertThrows; -@ThreadLeakFilters(filters = { PemKeyReaderLoadSecretKeyTest.BCFipsEntropyDaemonFilter.class }) +@ThreadLeakFilters(filters = { BouncyCastleThreadFilter.class, BCFipsEntropyDaemonFilter.class }) public class SslSettingsManagerTest extends RandomizedTest { @ClassRule diff --git a/src/test/java/org/opensearch/security/support/PemKeyReaderLoadSecretKeyTest.java b/src/test/java/org/opensearch/security/support/PemKeyReaderLoadSecretKeyTest.java index 9123ed4f8f..707f9209cf 100644 --- a/src/test/java/org/opensearch/security/support/PemKeyReaderLoadSecretKeyTest.java +++ b/src/test/java/org/opensearch/security/support/PemKeyReaderLoadSecretKeyTest.java @@ -11,43 +11,31 @@ package org.opensearch.security.support; -import java.io.File; -import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; -import java.security.KeyStore; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import com.carrotsearch.randomizedtesting.RandomizedRunner; -import com.carrotsearch.randomizedtesting.ThreadFilter; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; -import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; +import org.opensearch.security.test.helper.file.FileHelper; +import org.opensearch.security.util.BCFipsEntropyDaemonFilter; import org.opensearch.test.BouncyCastleThreadFilter; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.notNullValue; -import static com.carrotsearch.randomizedtesting.RandomizedTest.randomFrom; import static org.junit.Assert.assertThrows; @RunWith(RandomizedRunner.class) -@ThreadLeakFilters(filters = { BouncyCastleThreadFilter.class, PemKeyReaderLoadSecretKeyTest.BCFipsEntropyDaemonFilter.class }) +@ThreadLeakFilters(filters = { BouncyCastleThreadFilter.class, BCFipsEntropyDaemonFilter.class }) public class PemKeyReaderLoadSecretKeyTest { - // "BC FIPS Entropy Daemon" is not yet covered by the framework's BouncyCastleThreadFilter. - public static class BCFipsEntropyDaemonFilter implements ThreadFilter { - @Override - public boolean reject(Thread t) { - return "BC FIPS Entropy Daemon".equals(t.getName()); - } - } - private static final SecretKey SECRET_KEY = new SecretKeySpec( "unit-test-hmac-key-256-bits!!!!!".getBytes(StandardCharsets.US_ASCII), "HmacSHA256" @@ -56,30 +44,36 @@ public boolean reject(Thread t) { @Rule public TemporaryFolder tempDir = new TemporaryFolder(); - private String storeType; - - @Before - public void setup() { - storeType = randomKeyStoreType(); - } - @Test public void loadsSecretKeyByAlias() throws Exception { - File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); - SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", storeType, "test-key", "keypass"); + FileHelper.TypedStore typedStore = FileHelper.storeSecretKey(tempDir, "test-key", SECRET_KEY, "kspass", "keypass"); + SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore( + typedStore.path().toString(), + "kspass", + typedStore.type(), + "test-key", + "keypass" + ); assertThat(loaded, notNullValue()); assertThat(loaded.getEncoded(), equalTo(SECRET_KEY.getEncoded())); } @Test public void keyPasswordFallsBackToKeystorePassword() throws Exception { - File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "kspass", storeType); - SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", storeType, "test-key", null); + FileHelper.TypedStore typedStore = FileHelper.storeSecretKey(tempDir, "test-key", SECRET_KEY, "kspass", "kspass"); + SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore( + typedStore.path().toString(), + "kspass", + typedStore.type(), + "test-key", + null + ); assertThat(loaded.getEncoded(), equalTo(SECRET_KEY.getEncoded())); } @Test public void throwsWhenPathIsNull() { + var storeType = FileHelper.randomKeyStoreType(); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> PemKeyReader.loadSecretKeyFromKeystore(null, "kspass", storeType, "test-key", "keypass") @@ -91,10 +85,16 @@ public void throwsWhenPathIsNull() { @Test public void throwsForNonexistentAlias() throws Exception { - File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); + FileHelper.TypedStore typedStore = FileHelper.storeSecretKey(tempDir, "test-key", SECRET_KEY, "kspass", "keypass"); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", storeType, "no-such-alias", "keypass") + () -> PemKeyReader.loadSecretKeyFromKeystore( + typedStore.path().toString(), + "kspass", + typedStore.type(), + "no-such-alias", + "keypass" + ) ); assertThat(ex.getMessage(), containsString("No key found at alias")); assertThat(ex.getMessage(), containsString("no-such-alias")); @@ -102,32 +102,44 @@ public void throwsForNonexistentAlias() throws Exception { @Test public void throwsForWrongKeystorePassword() throws Exception { - File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); + FileHelper.TypedStore typedStore = FileHelper.storeSecretKey(tempDir, "test-key", SECRET_KEY, "kspass", "keypass"); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "wrongkspass", storeType, "test-key", "keypass") + () -> PemKeyReader.loadSecretKeyFromKeystore( + typedStore.path().toString(), + "wrongkspass", + typedStore.type(), + "test-key", + "keypass" + ) ); assertThat(ex.getMessage(), containsString("Failed to load secret key from keystore-type ")); - assertThat(ex.getMessage(), containsString(storeType)); - assertThat(ex.getMessage(), containsString(ks.getAbsolutePath())); + assertThat(ex.getMessage(), containsString(typedStore.type())); + assertThat(ex.getMessage(), containsString(typedStore.path().toString())); } @Test public void throwsForWrongKeyPassword() throws Exception { - File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); + FileHelper.TypedStore typedStore = FileHelper.storeSecretKey(tempDir, "test-key", SECRET_KEY, "kspass", "keypass"); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", storeType, "test-key", "wrongkeypass") + () -> PemKeyReader.loadSecretKeyFromKeystore( + typedStore.path().toString(), + "kspass", + typedStore.type(), + "test-key", + "wrongkeypass" + ) ); assertThat(ex.getMessage(), containsString("Failed to load secret key from keystore-type ")); - assertThat(ex.getMessage(), containsString(storeType)); - assertThat(ex.getMessage(), containsString(ks.getAbsolutePath())); + assertThat(ex.getMessage(), containsString(typedStore.type())); + assertThat(ex.getMessage(), containsString(typedStore.path().toString())); } @Test public void autoDetectsStoreTypeFromFileContentWhenTypeIsNull() throws Exception { - File ks = storeKeystoreWithKey("test-key", SECRET_KEY, "kspass", "keypass", storeType); - SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore(ks.getAbsolutePath(), "kspass", null, "test-key", "keypass"); + FileHelper.TypedStore typedStore = FileHelper.storeSecretKey(tempDir, "test-key", SECRET_KEY, "kspass", "keypass"); + SecretKey loaded = PemKeyReader.loadSecretKeyFromKeystore(typedStore.path().toString(), "kspass", null, "test-key", "keypass"); assertThat(loaded, notNullValue()); assertThat(loaded.getEncoded(), equalTo(SECRET_KEY.getEncoded())); } @@ -142,26 +154,4 @@ public void throwsWhenAliasHoldsNonSecretKey() { assertThat(ex.getMessage(), containsString("kirk")); assertThat(ex.getMessage(), containsString("is not a SecretKey")); } - - private File storeKeystoreWithKey(String alias, SecretKey key, String ksPassword, String keyPassword, String storeType) - throws Exception { - File file = tempDir.newFile("test." + storeType.toLowerCase()); - KeyStore ks = KeyStore.getInstance(storeType); - ks.load(null, null); - ks.setKeyEntry(alias, key, keyPassword.toCharArray(), null); - try (FileOutputStream fos = new FileOutputStream(file)) { - ks.store(fos, ksPassword.toCharArray()); - } - return file; - } - - private String randomKeyStoreType() { - // JKS is excluded: its engineSetKeyEntry enforces instanceof PrivateKey (the asymmetric-key - // interface), so SecretKey entries are rejected with "Cannot store non-PrivateKeys". - // JCEKS was introduced specifically to extend JKS with SecretKey support. - // BCFKS (BC FIPS) also supports SecretKey and is the only FIPS-approved option. - return FipsMode.isEnabled() // - ? randomFrom(new String[] { "bcfks" }) // - : randomFrom(new String[] { "bcfks", "jceks", "pkcs12" }); - } } diff --git a/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java b/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java index dd8e9d21af..13209af82d 100644 --- a/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java +++ b/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java @@ -29,6 +29,7 @@ import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; @@ -43,10 +44,13 @@ import java.nio.file.Paths; import java.security.KeyStore; import java.util.List; +import java.util.Locale; import java.util.Map; +import javax.crypto.SecretKey; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.junit.rules.TemporaryFolder; import org.opensearch.common.io.Streams; import org.opensearch.common.xcontent.XContentFactory; @@ -58,6 +62,7 @@ import org.opensearch.security.support.FipsMode; import static org.opensearch.core.xcontent.DeprecationHandler.THROW_UNSUPPORTED_OPERATION; +import static com.carrotsearch.randomizedtesting.RandomizedTest.randomFrom; public class FileHelper { @@ -89,6 +94,37 @@ public static String inferStoreType(String filePath) { .orElseThrow(() -> new IllegalArgumentException("Unknown keystore type for file path: " + filePath)); } + /** Writes a single {@link SecretKey} entry into a new keystore file of the given type and returns it. */ + public static TypedStore storeSecretKey(TemporaryFolder tempDir, String alias, SecretKey key, String storePassword, String keyPassword) + throws Exception { + final String storeType = randomKeyStoreType(); + final File file = tempDir.newFile(alias + "." + storeType.toLowerCase(Locale.ROOT)); + final KeyStore ks = KeyStore.getInstance(storeType); + ks.load(null, null); + ks.setKeyEntry(alias, key, keyPassword.toCharArray(), null); + try (FileOutputStream fos = new FileOutputStream(file)) { + ks.store(fos, storePassword.toCharArray()); + } + return new TypedStore(file.toPath(), storeType); + } + + /** + * Returns a randomly chosen keystore type that supports {@link SecretKey} entries. + *
      + *
    • JKS is excluded: its {@code engineSetKeyEntry} enforces {@code instanceof PrivateKey} (the + * asymmetric-key interface), so {@code SecretKey} entries are rejected with "Cannot store + * non-PrivateKeys".
    • + *
    • JCEKS was introduced specifically to extend JKS with {@code SecretKey} support.
    • + *
    • BCFKS (BC FIPS) also supports {@code SecretKey}.
    • + *
    + * Requires the calling test to run under {@code @RunWith(RandomizedRunner.class)}. + */ + public static String randomKeyStoreType() { + return FipsMode.isEnabled() // + ? randomFrom(new String[] { "bcfks" }) // + : randomFrom(new String[] { "bcfks", "jceks", "pkcs12" }); + } + public record TypedStore(Path path, String type) { } diff --git a/src/test/java/org/opensearch/security/util/BCFipsEntropyDaemonFilter.java b/src/test/java/org/opensearch/security/util/BCFipsEntropyDaemonFilter.java new file mode 100644 index 0000000000..5fb39b0160 --- /dev/null +++ b/src/test/java/org/opensearch/security/util/BCFipsEntropyDaemonFilter.java @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.util; + +import com.carrotsearch.randomizedtesting.ThreadFilter; + +/** + * Thread-leak filter for the "BC FIPS Entropy Daemon", which the framework's {@code BouncyCastleThreadFilter} + * does not yet cover. Shared by tests that touch BC FIPS keystores/crypto under {@code RandomizedRunner}. + */ +public class BCFipsEntropyDaemonFilter implements ThreadFilter { + @Override + public boolean reject(Thread t) { + return "BC FIPS Entropy Daemon".equals(t.getName()); + } +} From ec15b4bffddd1ba631a16f54b4f7ab60e095a0a7 Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Thu, 2 Jul 2026 16:44:35 +0200 Subject: [PATCH 4/9] OBO-Token keystore support Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad --- .../security/OpenSearchSecurityPlugin.java | 2 +- .../jwt/EncryptionDecryptionUtil.java | 5 +- .../security/authtoken/jwt/JwtVendor.java | 9 ++-- .../http/OnBehalfOfAuthenticator.java | 9 ++-- .../identity/SecurityTokenManager.java | 10 ++-- .../securityconf/DynamicConfigModelV7.java | 2 +- .../security/ssl/util/SSLConfigConstants.java | 4 +- .../opensearch/security/util/KeyUtils.java | 22 ++++++--- .../security/authtoken/jwt/JwtVendorTest.java | 18 +++---- .../http/OnBehalfOfAuthenticatorTest.java | 47 ++++++++++--------- .../identity/SecurityTokenManagerTest.java | 2 +- .../util/KeyUtilsLoadKeyFromKeystoreTest.java | 4 +- 12 files changed, 79 insertions(+), 55 deletions(-) diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 92d87df46d..44676562a6 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -1280,7 +1280,7 @@ public Collection createComponents( threadPool.getThreadContext() ); this.roleMapper = roleMapper; - tokenManager = new SecurityTokenManager(cs, threadPool, userService, roleMapper); + tokenManager = new SecurityTokenManager(cs, threadPool, userService, roleMapper, this.configPath); apiTokenRepository = new ApiTokenRepository(localClient, clusterService); PrivilegesConfiguration privilegesConfiguration = new PrivilegesConfiguration( diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java index 65294b0c84..26baeb9bea 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java @@ -12,6 +12,7 @@ package org.opensearch.security.authtoken.jwt; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.security.SecureRandom; import java.util.Arrays; import java.util.Base64; @@ -51,8 +52,8 @@ public class EncryptionDecryptionUtil { * * @return a util instance, or {@code null} if no key is configured */ - public static EncryptionDecryptionUtil fromSettings(final Settings settings, final String prefix) { - final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, prefix); + public static EncryptionDecryptionUtil fromSettings(final Settings settings, final String prefix, final Path configPath) { + final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, prefix, configPath); if (keystoreKey != null) { return new EncryptionDecryptionUtil(keystoreKey.getEncoded()); } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index 420fd9365b..dcdd9b905e 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -11,6 +11,7 @@ package org.opensearch.security.authtoken.jwt; +import java.nio.file.Path; import java.text.ParseException; import java.util.Base64; import java.util.Date; @@ -44,8 +45,8 @@ public class JwtVendor { private final JWK signingKey; private final JWSSigner signer; - public JwtVendor(Settings settings) { - final Tuple tuple = createJwkFromSettings(settings); + public JwtVendor(Settings settings, Path configPath) { + final Tuple tuple = createJwkFromSettings(settings, configPath); signingKey = tuple.v1(); signer = tuple.v2(); } @@ -56,10 +57,10 @@ public JwtVendor(Settings settings) { * PublicKeyUse: SIGN * Encryption Algorithm: HS512 * */ - static Tuple createJwkFromSettings(final Settings settings) { + static Tuple createJwkFromSettings(final Settings settings, final Path configPath) { final OctetSequenceKey key; - final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, SIGNING_KEY_PROPERTY_KEY); + final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, SIGNING_KEY_PROPERTY_KEY, configPath); if (keystoreKey != null) { key = new OctetSequenceKey.Builder(keystoreKey.getEncoded()).algorithm(JWSAlgorithm.HS512).keyUse(KeyUse.SIGNATURE).build(); } else if (settings.get(SIGNING_KEY_PROPERTY_KEY) != null) { diff --git a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java index efa2b85f74..71ad6ee654 100644 --- a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java @@ -11,6 +11,7 @@ package org.opensearch.security.http; +import java.nio.file.Path; import java.util.Arrays; import java.util.Base64; import java.util.Collection; @@ -58,14 +59,16 @@ public class OnBehalfOfAuthenticator implements HTTPAuthenticator { private final Settings settings; private final Boolean enabled; private final String clusterName; + private final Path configPath; private volatile boolean initialized = false; private volatile JwtParser jwtParser; private volatile EncryptionDecryptionUtil encryptionUtil; - public OnBehalfOfAuthenticator(Settings settings, String clusterName) { + public OnBehalfOfAuthenticator(Settings settings, String clusterName, Path configPath) { this.enabled = settings.getAsBoolean("enabled", Boolean.TRUE); this.settings = settings; this.clusterName = clusterName; + this.configPath = configPath; } /** @@ -78,7 +81,7 @@ private synchronized boolean ensureInitialized() { initialized = true; try { jwtParser = AccessController.doPrivileged(this::buildJwtParser); - encryptionUtil = EncryptionDecryptionUtil.fromSettings(settings, ENCRYPTION_KEY); + encryptionUtil = EncryptionDecryptionUtil.fromSettings(settings, ENCRYPTION_KEY, configPath); } catch (final RuntimeException e) { log.error("On-behalf-of authentication is misconfigured; OBO tokens will be rejected: {}", e.toString(), e); } @@ -93,7 +96,7 @@ private synchronized boolean ensureInitialized() { * and verification share the same key material. */ private JwtParser buildJwtParser() { - final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, SIGNING_KEY); + final SecretKey keystoreKey = KeyUtils.loadKeyFromKeystore(settings, SIGNING_KEY, configPath); if (keystoreKey != null) { final byte[] keyBytes = keystoreKey.getEncoded(); validateSigningKeyBitLength(keyBytes.length * Byte.SIZE); diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index c22a07f7b6..05f9fcd7a8 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -11,6 +11,7 @@ package org.opensearch.security.identity; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Date; import java.util.Set; @@ -53,6 +54,7 @@ public class SecurityTokenManager implements TokenManager { private final ThreadPool threadPool; private final UserService userService; private final RoleMapper roleMapper; + private final Path configPath; private Settings oboSettings = null; private final LongSupplier timeProvider = System::currentTimeMillis; @@ -62,12 +64,14 @@ public SecurityTokenManager( final ClusterService cs, final ThreadPool threadPool, final UserService userService, - RoleMapper roleMapper + final RoleMapper roleMapper, + final Path configPath ) { this.cs = cs; this.threadPool = threadPool; this.userService = userService; this.roleMapper = roleMapper; + this.configPath = configPath; } @Subscribe @@ -82,7 +86,7 @@ public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { /** For testing */ JwtVendor createJwtVendor(final Settings settings) { try { - return new JwtVendor(settings); + return new JwtVendor(settings, configPath); } catch (final Exception ex) { logger.error("Unable to create the JwtVendor instance", ex); return null; @@ -131,7 +135,7 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final } final OBOJwtClaimsBuilder claimsBuilder = new OBOJwtClaimsBuilder( - EncryptionDecryptionUtil.fromSettings(oboSettings, "encryption_key") + EncryptionDecryptionUtil.fromSettings(oboSettings, "encryption_key", configPath) ); // Add obo claims diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index c4c137e0c4..e144d3a02c 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -399,7 +399,7 @@ private void buildAAA() { if (signingKeyConfigured) { final AuthDomain _ad = new AuthDomain( new NoOpAuthenticationBackend(Settings.EMPTY, null), - new OnBehalfOfAuthenticator(getDynamicOnBehalfOfSettings(), this.cih.getClusterName()), + new OnBehalfOfAuthenticator(getDynamicOnBehalfOfSettings(), this.cih.getClusterName(), this.configPath), false, -1 ); diff --git a/src/main/java/org/opensearch/security/ssl/util/SSLConfigConstants.java b/src/main/java/org/opensearch/security/ssl/util/SSLConfigConstants.java index f1dfbbb741..4ab4dc78a2 100644 --- a/src/main/java/org/opensearch/security/ssl/util/SSLConfigConstants.java +++ b/src/main/java/org/opensearch/security/ssl/util/SSLConfigConstants.java @@ -17,9 +17,11 @@ package org.opensearch.security.ssl.util; +import java.security.KeyStore; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.function.Function; import org.opensearch.common.settings.Setting; @@ -47,7 +49,7 @@ public final class SSLConfigConstants { public static final String ENFORCE_CERT_RELOAD_DN_VERIFICATION = "enforce_cert_reload_dn_verification"; public static final String DEFAULT_STORE_TYPE = FipsMode.isEnabled() ? "BCFKS" // PKCS#11 must remain opt-in via explicit config - : "JKS"; + : KeyStore.getDefaultType().toUpperCase(Locale.ROOT); // JDK default (PKCS12); JKS rejects null key passwords public static final String SSL_PREFIX = "plugins.security.ssl."; public static final String KEYSTORE_TYPE = "keystore_type"; diff --git a/src/main/java/org/opensearch/security/util/KeyUtils.java b/src/main/java/org/opensearch/security/util/KeyUtils.java index 44a1a36cbe..ae6f716226 100644 --- a/src/main/java/org/opensearch/security/util/KeyUtils.java +++ b/src/main/java/org/opensearch/security/util/KeyUtils.java @@ -11,6 +11,7 @@ package org.opensearch.security.util; +import java.nio.file.Path; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; @@ -46,18 +47,20 @@ public class KeyUtils { * Expected settings: *
      *
    • {@code }{@value #KEYSTORE_ALIAS} — required; absence returns {@code null} (acts as an opt-in flag)
    • - *
    • {@code }{@value #KEYSTORE_PATH} — required for file-based keystores; may be omitted only for PKCS11
    • + *
    • {@code }{@value #KEYSTORE_PATH} — the keystore file; an absolute path is used as-is, a relative + * path is resolved against {@code configPath} (the node config directory), so the keystore can be configured + * relative to that directory like other security file settings
    • *
    • {@code }{@value #KEYSTORE_TYPE} — optional; auto-detected from file content when absent
    • - *
    • {@code }{@value #KEYSTORE_PASSWORD} — optional; absent or {@code null} means the keystore has no password
    • - *
    • {@code }{@value #KEYSTORE_KEY_PASSWORD} — optional; falls back to {@code KEYSTORE_PASSWORD} when absent; - * both may be {@code null} for keystores and entries that carry no password
    • + *
    • {@code }{@value #KEYSTORE_PASSWORD} — the keystore password
    • + *
    • {@code }{@value #KEYSTORE_KEY_PASSWORD} — the key password; falls back to {@code KEYSTORE_PASSWORD} when absent
    • *
    * + * @param configPath the node config directory; resolved against for relative keystore paths * @return the loaded {@link SecretKey}, or {@code null} if the alias setting is absent * @throws IllegalArgumentException if the alias is present but the path is missing, the keystore * cannot be loaded, or the entry at the alias is not a {@link SecretKey} */ - public static SecretKey loadKeyFromKeystore(final Settings settings, final String prefix) { + public static SecretKey loadKeyFromKeystore(final Settings settings, final String prefix, final Path configPath) { final String alias = settings.get(prefix + KEYSTORE_ALIAS); if (alias == null) { return null; @@ -65,11 +68,18 @@ public static SecretKey loadKeyFromKeystore(final Settings settings, final Strin final String type = settings.get(prefix + KEYSTORE_TYPE); final String ksPassword = settings.get(prefix + KEYSTORE_PASSWORD); final String keyPassword = settings.get(prefix + KEYSTORE_KEY_PASSWORD); - final String pathStr = settings.get(prefix + KEYSTORE_PATH); + final String pathStr = resolveKeystorePath(settings.get(prefix + KEYSTORE_PATH), configPath); return PemKeyReader.loadSecretKeyFromKeystore(pathStr, ksPassword, type, alias, keyPassword); } + private static String resolveKeystorePath(final String pathStr, final Path configPath) { + if (pathStr == null || pathStr.isEmpty()) { + return pathStr; + } + return configPath.resolve(pathStr).toAbsolutePath().toString(); + } + public static JwtParserBuilder createJwtParserBuilderFromSigningKey(final String signingKey, final Logger log) { JwtParserBuilder jwtParserBuilder = null; diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index b7cfd0f85f..dd89f0563f 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -79,7 +79,7 @@ public class JwtVendorTest { public void testCreateJwkFromSettings() { final Settings settings = Settings.builder().put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded).build(); - final Tuple jwk = JwtVendor.createJwkFromSettings(settings); + final Tuple jwk = JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath()); assertThat(jwk.v1().getAlgorithm().getName(), is("HS512")); assertThat(jwk.v1().getKeyUse().toString(), is("sig")); assertTrue(jwk.v1().toOctetSequenceKey().getKeyValue().decodeToString().startsWith(signingKey)); @@ -88,14 +88,14 @@ public void testCreateJwkFromSettings() { @Test public void testCreateJwkFromSettingsWithWeakKey() { Settings settings = Settings.builder().put(SIGNING_KEY_PROPERTY_KEY, "abcd1234").build(); - Throwable exception = assertThrows(OpenSearchException.class, () -> JwtVendor.createJwkFromSettings(settings)); + Throwable exception = assertThrows(OpenSearchException.class, () -> JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath())); assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); } @Test public void testCreateJwkFromSettingsWithoutSigningKey() { Settings settings = Settings.builder().put("jwt", "").build(); - Throwable exception = assertThrows(RuntimeException.class, () -> JwtVendor.createJwkFromSettings(settings)); + Throwable exception = assertThrows(RuntimeException.class, () -> JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath())); assertThat( exception.getMessage(), equalTo("Settings for signing key is missing. Please specify at least the option signing_key with a shared secret.") @@ -115,7 +115,7 @@ public void testCreateJwtWithRolesNoEncryptionKey() throws Exception { Settings settings = Settings.builder().put(SIGNING_KEY_PROPERTY_KEY, signingKeyB64Encoded).build(); Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); - JwtVendor OBOJwtVendor = new JwtVendor(settings); + JwtVendor OBOJwtVendor = new JwtVendor(settings, tempDir.getRoot().toPath()); final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( new OBOJwtClaimsBuilder(null).addRoles(roles) .issuer(issuer) @@ -151,7 +151,7 @@ public void testCreateJwtWithRoles() throws Exception { Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); - JwtVendor OBOJwtVendor = new JwtVendor(settings); + JwtVendor OBOJwtVendor = new JwtVendor(settings, tempDir.getRoot().toPath()); final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( new OBOJwtClaimsBuilder(new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded)).addRoles(roles) .addBackendRoles(false, backendRoles) @@ -199,7 +199,7 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { .put("encryption_key", claimsEncryptionKeyB64Encoded) .put(ConfigConstants.EXTENSIONS_BWC_PLUGIN_MODE, true) .build(); - final JwtVendor OBOJwtVendor = new JwtVendor(settings); + final JwtVendor OBOJwtVendor = new JwtVendor(settings, tempDir.getRoot().toPath()); Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( @@ -256,7 +256,7 @@ public void testCreateJwtLogsCorrectly() throws Exception { final List backendRoles = List.of("Sales", "Support"); int expirySeconds = 300; - final JwtVendor OBOJwtVendor = new JwtVendor(settings); + final JwtVendor OBOJwtVendor = new JwtVendor(settings, tempDir.getRoot().toPath()); Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); OBOJwtVendor.createJwt( new OBOJwtClaimsBuilder(new EncryptionDecryptionUtil(claimsEncryptionKeyB64Encoded)).addRoles(roles) @@ -303,7 +303,7 @@ public void testCreateJwkFromKeystoreSettings() throws Exception { .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_KEY_PASSWORD, "keypass") .build(); - final Tuple jwk = JwtVendor.createJwkFromSettings(settings); + final Tuple jwk = JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath()); assertThat(jwk.v1().getAlgorithm().getName(), is("HS512")); assertThat(jwk.v1().getKeyUse().toString(), is("sig")); assertThat(jwk.v1().toOctetSequenceKey().getKeyValue().decode(), equalTo(keyBytes)); @@ -326,7 +326,7 @@ public void testCreateJwkFromKeystoreSettingsNonexistentAlias() throws Exception .put(SIGNING_KEY_PROPERTY_KEY + KeyUtils.KEYSTORE_ALIAS, "nonexistent") .build(); - assertThrows(IllegalArgumentException.class, () -> JwtVendor.createJwkFromSettings(settings)); + assertThrows(IllegalArgumentException.class, () -> JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath())); } } diff --git a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java index 86f7b4aae3..409898e241 100644 --- a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java @@ -92,14 +92,14 @@ public class OnBehalfOfAuthenticatorTest { @Test public void testReRequestAuthenticationReturnsEmptyOptional() throws Exception { - OnBehalfOfAuthenticator authenticator = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator authenticator = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Optional result = authenticator.reRequestAuthentication(null, null); assertFalse(result.isPresent()); } @Test public void testGetTypeReturnsExpectedType() throws Exception { - OnBehalfOfAuthenticator authenticator = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator authenticator = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); String type = authenticator.getType(); assertThat(type, is("onbehalfof_jwt")); } @@ -174,7 +174,7 @@ public void testMisconfiguredKeyDeclinesWithoutThrowing() throws Exception { .put("signing_key", "testKey") // misconfigured signing key .put("encryption_key", claimsEncryptionKeyB64Encoded) .build(); - OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName); + OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName, tempDir.getRoot().toPath()); Map headers = Map.of("Authorization", "Bearer a.b.c"); AuthCredentials credentials = auth.extractCredentials(new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), null); assertNull(credentials); @@ -183,7 +183,7 @@ public void testMisconfiguredKeyDeclinesWithoutThrowing() throws Exception { @Test public void testTokenMissing() throws Exception { - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = new HashMap(); AuthCredentials credentials = jwtAuth.extractCredentials( @@ -199,7 +199,7 @@ public void testInvalid() throws Exception { String jwsToken = "123invalidtoken.."; - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = new HashMap(); headers.put("Authorization", "Bearer " + jwsToken); @@ -221,7 +221,7 @@ public void testDisabled() throws Exception { .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(disableOBOSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(disableOBOSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = new HashMap(); headers.put("Authorization", "Bearer " + jwsToken); @@ -246,7 +246,7 @@ public void testInvalidTokenException() throws Exception { String invalidToken = "invalidToken"; Settings settings = defaultSettings(); - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(settings, clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(settings, clusterName, tempDir.getRoot().toPath()); Map headers = Collections.singletonMap(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken); @@ -276,7 +276,7 @@ public void testNonSpecifyOBOSetting() throws Exception { .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(nonSpecifyOBOSetting(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(nonSpecifyOBOSetting(), clusterName, tempDir.getRoot().toPath()); Map headers = new HashMap(); headers.put("Authorization", "Bearer " + jwsToken); @@ -303,7 +303,7 @@ public void testBearer() throws Exception { .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = new HashMap(); headers.put("Authorization", "Bearer " + jwsToken); @@ -330,7 +330,7 @@ public void testBearerWrongPosition() throws Exception { .and() .signWith(secretKey, Jwts.SIG.HS512) .compact(); - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = new HashMap(); headers.put("Authorization", jwsToken + "Bearer " + " 123"); @@ -353,7 +353,7 @@ public void testBasicAuthHeader() throws Exception { .and() .signWith(secretKey, Jwts.SIG.HS512) .compact(); - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = Collections.singletonMap(HttpHeaders.AUTHORIZATION, "Basic " + jwsToken); @@ -377,7 +377,7 @@ public void testMissingBearerScheme() throws Exception { String craftedToken = "beaRerSomeActualToken"; // This token matches the BEARER pattern but doesn't contain the BEARER_PREFIX - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = Collections.singletonMap(HttpHeaders.AUTHORIZATION, craftedToken); AuthCredentials credentials = jwtAuth.extractCredentials( @@ -406,7 +406,7 @@ public void testMissingBearerPrefixInAuthHeader() throws Exception { .signWith(secretKey, Jwts.SIG.HS512) .compact(); - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = Collections.singletonMap(HttpHeaders.AUTHORIZATION, jwsToken); @@ -462,7 +462,7 @@ public void testSigningKeyFromKeystoreVerifiesToken() throws Exception { .signWith(Keys.hmacShaKeyFor(keyBytes), Jwts.SIG.HS512) .compact(); - final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName); + final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName, tempDir.getRoot().toPath()); final Map headers = Map.of("Authorization", "Bearer " + jwsToken); final AuthCredentials credentials = auth.extractCredentials( new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), @@ -494,7 +494,7 @@ public void testEncryptionKeyFromKeystoreDecryptsRoles() throws Exception { ).build(); // Simulate issuance: encrypt the roles with the same keystore-derived key - final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings(settings, "encryption_key"); + final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings(settings, "encryption_key", tempDir.getRoot().toPath()); final String encryptedRoles = issuerUtil.encrypt("role1,role2"); final String jwsToken = Jwts.builder() @@ -507,7 +507,7 @@ public void testEncryptionKeyFromKeystoreDecryptsRoles() throws Exception { .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); - final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName); + final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName, tempDir.getRoot().toPath()); final Map headers = Map.of("Authorization", "Bearer " + jwsToken); final AuthCredentials credentials = auth.extractCredentials( new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), @@ -551,7 +551,7 @@ public void testSigningAndEncryptionKeysBothFromKeystore() throws Exception { final Settings settings = builder.build(); // Simulate issuance: encrypt the roles with the same keystore-derived AES key the verifier will resolve. - final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings(settings, "encryption_key"); + final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings(settings, "encryption_key", tempDir.getRoot().toPath()); final String encryptedRoles = issuerUtil.encrypt("role1,role2"); final String jwsToken = Jwts.builder() @@ -564,7 +564,7 @@ public void testSigningAndEncryptionKeysBothFromKeystore() throws Exception { .signWith(Keys.hmacShaKeyFor(signingKeyBytes), Jwts.SIG.HS512) .compact(); - final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName); + final OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, clusterName, tempDir.getRoot().toPath()); final Map headers = Map.of("Authorization", "Bearer " + jwsToken); final AuthCredentials credentials = auth.extractCredentials( new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), @@ -605,7 +605,8 @@ public void testRolesDecryptionFromErClaimWithNoEncryptionKeyReturnsEmpty() thro // No encryption_key in settings final OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator( Settings.builder().put("enabled", enableOBO).put("signing_key", signingKeyB64Encoded).build(), - clusterName + clusterName, + tempDir.getRoot().toPath() ); final String jwsToken = Jwts.builder() .issuer(clusterName) @@ -630,7 +631,8 @@ public void testPlainTextRolesFromDrClaimWithNoEncryptionKey() throws Exception // No encryption_key in settings — dr claim should still be readable final OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator( Settings.builder().put("enabled", enableOBO).put("signing_key", signingKeyB64Encoded).build(), - clusterName + clusterName, + tempDir.getRoot().toPath() ); final String jwsToken = Jwts.builder() .issuer(clusterName) @@ -814,7 +816,7 @@ public void testDifferentIssuer() throws Exception { .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) .compact(); - OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName, tempDir.getRoot().toPath()); Map headers = new HashMap(); headers.put("Authorization", "Bearer " + jwsToken); @@ -852,7 +854,8 @@ private AuthCredentials extractCredentialsFromJwtHeader( .put("signing_key", signingKeyB64Encoded) .put("encryption_key", encryptionKey) .build(), - clusterName + clusterName, + tempDir.getRoot().toPath() ); final String jwsToken = diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index f1145b706b..6d089df02d 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -69,7 +69,7 @@ public class SecurityTokenManagerTest { @Before public void setup() { - tokenManager = spy(new SecurityTokenManager(cs, threadPool, userService, (user, caller) -> user.getSecurityRoles())); + tokenManager = spy(new SecurityTokenManager(cs, threadPool, userService, (user, caller) -> user.getSecurityRoles(), null)); } @After diff --git a/src/test/java/org/opensearch/security/util/KeyUtilsLoadKeyFromKeystoreTest.java b/src/test/java/org/opensearch/security/util/KeyUtilsLoadKeyFromKeystoreTest.java index b808a7f0ee..5a853e4f98 100644 --- a/src/test/java/org/opensearch/security/util/KeyUtilsLoadKeyFromKeystoreTest.java +++ b/src/test/java/org/opensearch/security/util/KeyUtilsLoadKeyFromKeystoreTest.java @@ -48,7 +48,7 @@ public class KeyUtilsLoadKeyFromKeystoreTest { @Test public void returnsNullWhenAliasSettingAbsent() { Settings settings = Settings.builder().build(); - assertThat(KeyUtils.loadKeyFromKeystore(settings, PREFIX), nullValue()); + assertThat(KeyUtils.loadKeyFromKeystore(settings, PREFIX, tempDir.getRoot().toPath()), nullValue()); } @Test @@ -61,7 +61,7 @@ public void loadsKeyWhenAllSettingsPresent() throws Exception { .put(PREFIX + KEYSTORE_ALIAS, "test-key") .put(PREFIX + KEYSTORE_KEY_PASSWORD, "keypass") .build(); - SecretKey loaded = KeyUtils.loadKeyFromKeystore(settings, PREFIX); + SecretKey loaded = KeyUtils.loadKeyFromKeystore(settings, PREFIX, tempDir.getRoot().toPath()); assertThat(loaded, notNullValue()); assertThat(loaded.getEncoded(), equalTo(SECRET_KEY.getEncoded())); } From 5fd32a80ce0e93a43df55414ed07c0daed4a2dfa Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Thu, 2 Jul 2026 16:50:42 +0200 Subject: [PATCH 5/9] Support PKCS#11 keystores for node TLS (SunJSSE-signed handshake) Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad --- .../security/ssl/SslConfiguration.java | 33 +++++++++++++++---- .../security/ssl/SslSettingsManager.java | 22 ++++++++++--- .../ssl/config/KeyStoreConfiguration.java | 14 ++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/opensearch/security/ssl/SslConfiguration.java b/src/main/java/org/opensearch/security/ssl/SslConfiguration.java index ad5f835f53..bb58a6dbfa 100644 --- a/src/main/java/org/opensearch/security/ssl/SslConfiguration.java +++ b/src/main/java/org/opensearch/security/ssl/SslConfiguration.java @@ -12,6 +12,8 @@ package org.opensearch.security.ssl; import java.nio.file.Path; +import java.security.Provider; +import java.security.Security; import java.util.List; import java.util.Objects; import java.util.Set; @@ -90,7 +92,7 @@ SslContext buildServerSslContext(final boolean validateCertificates) { return AccessController.doPrivilegedChecked(() -> { KeyManagerFactory kmFactory = keyStoreConfiguration.createKeyManagerFactory(validateCertificates); Set issuerDns = keyStoreConfiguration.getIssuerDns(); - return SslContextBuilder.forServer(kmFactory) + final SslContextBuilder builder = SslContextBuilder.forServer(kmFactory) .sslProvider(sslParameters.provider()) .clientAuth(sslParameters.clientAuth()) .protocols(sslParameters.allowedProtocols().toArray(new String[0])) @@ -115,20 +117,38 @@ SslContext buildServerSslContext(final boolean validateCertificates) { ApplicationProtocolNames.HTTP_1_1 ) ) - .trustManager(trustStoreConfiguration.createTrustManagerFactory(validateCertificates, issuerDns)) - .build(); + .trustManager(trustStoreConfiguration.createTrustManagerFactory(validateCertificates, issuerDns)); + routePkcs11ThroughSunJsse(builder); + return builder.build(); }); } catch (SSLException e) { throw new OpenSearchException("Failed to build server SSL context", e); } } + /** + * A PKCS#11-resident private key is non-exportable, so the BouncyCastle FIPS JSSE provider cannot sign + * with it (it fails with "no encoding for key" during the TLS CertificateVerify). SunJSSE instead + * delegates the signature operation to the key's own provider (SunPKCS11), letting the token perform it. + * This only affects the TLS engine's handshake signing; the JDK {@link io.netty.handler.ssl.SslProvider} is unchanged. + */ + private void routePkcs11ThroughSunJsse(final SslContextBuilder builder) { + if (!keyStoreConfiguration.isPkcs11()) { + return; + } + final Provider sunJSSE = Security.getProvider("SunJSSE"); + if (sunJSSE == null) { + throw new OpenSearchException("SunJSSE provider not available; required for PKCS#11 key store support"); + } + builder.sslContextProvider(sunJSSE); + } + SslContext buildClientSslContext(final boolean validateCertificates) { try { return AccessController.doPrivilegedChecked(() -> { KeyManagerFactory kmFactory = keyStoreConfiguration.createKeyManagerFactory(validateCertificates); Set issuerDns = keyStoreConfiguration.getIssuerDns(); - return SslContextBuilder.forClient() + final SslContextBuilder builder = SslContextBuilder.forClient() .sslProvider(sslParameters.provider()) .protocols(sslParameters.allowedProtocols()) .ciphers(sslParameters.allowedCiphers()) @@ -138,8 +158,9 @@ SslContext buildClientSslContext(final boolean validateCertificates) { .sslProvider(sslParameters.provider()) .keyManager(kmFactory) .trustManager(trustStoreConfiguration.createTrustManagerFactory(validateCertificates, issuerDns)) - .endpointIdentificationAlgorithm(null) - .build(); + .endpointIdentificationAlgorithm(null); + routePkcs11ThroughSunJsse(builder); + return builder.build(); }); } catch (Exception e) { throw new OpenSearchException("Failed to build client SSL context", e); diff --git a/src/main/java/org/opensearch/security/ssl/SslSettingsManager.java b/src/main/java/org/opensearch/security/ssl/SslSettingsManager.java index 0af172e95f..3f05fbc84a 100644 --- a/src/main/java/org/opensearch/security/ssl/SslSettingsManager.java +++ b/src/main/java/org/opensearch/security/ssl/SslSettingsManager.java @@ -36,6 +36,7 @@ import org.opensearch.security.ssl.config.SslCertificatesLoader; import org.opensearch.security.ssl.config.SslParameters; import org.opensearch.security.ssl.config.TrustStoreConfiguration; +import org.opensearch.security.support.PemKeyReader; import org.opensearch.watcher.FileChangesListener; import org.opensearch.watcher.FileWatcher; import org.opensearch.watcher.ResourceWatcherService; @@ -48,6 +49,7 @@ import static org.opensearch.security.ssl.util.SSLConfigConstants.EXTENDED_KEY_USAGE_ENABLED; import static org.opensearch.security.ssl.util.SSLConfigConstants.KEYSTORE_ALIAS; import static org.opensearch.security.ssl.util.SSLConfigConstants.KEYSTORE_FILEPATH; +import static org.opensearch.security.ssl.util.SSLConfigConstants.KEYSTORE_TYPE; import static org.opensearch.security.ssl.util.SSLConfigConstants.PEM_CERT_FILEPATH; import static org.opensearch.security.ssl.util.SSLConfigConstants.PEM_KEY_FILEPATH; import static org.opensearch.security.ssl.util.SSLConfigConstants.PEM_TRUSTED_CAS_FILEPATH; @@ -74,6 +76,7 @@ import static org.opensearch.security.ssl.util.SSLConfigConstants.SSL_TRANSPORT_SERVER_EXTENDED_PREFIX; import static org.opensearch.security.ssl.util.SSLConfigConstants.TRUSTSTORE_ALIAS; import static org.opensearch.security.ssl.util.SSLConfigConstants.TRUSTSTORE_FILEPATH; +import static org.opensearch.security.ssl.util.SSLConfigConstants.TRUSTSTORE_TYPE; import static org.opensearch.transport.AuxTransport.AUX_TRANSPORT_TYPES_SETTING; public class SslSettingsManager { @@ -346,7 +349,10 @@ private void validateKeyStoreSettings(CertType transportType, final Settings set final var clientAuth = ClientAuth.valueOf( transportSettings.get(CLIENT_AUTH_MODE, ClientAuth.OPTIONAL.name()).toUpperCase(Locale.ROOT) ); - if (!transportSettings.hasValue(KEYSTORE_FILEPATH)) { + // A PKCS#11 keystore/truststore loads its material from the token, so no filepath is required. + final boolean isPkcs11Keystore = PemKeyReader.PKCS11.equalsIgnoreCase(transportSettings.get(KEYSTORE_TYPE)); + final boolean isPkcs11Truststore = PemKeyReader.PKCS11.equalsIgnoreCase(transportSettings.get(TRUSTSTORE_TYPE)); + if (!isPkcs11Keystore && !transportSettings.hasValue(KEYSTORE_FILEPATH)) { throw new OpenSearchException( "Wrong " + transportType.id().toLowerCase(Locale.ROOT) @@ -355,7 +361,7 @@ private void validateKeyStoreSettings(CertType transportType, final Settings set + " must be set" ); } - if (clientAuth == ClientAuth.REQUIRE && !transportSettings.hasValue(TRUSTSTORE_FILEPATH)) { + if (clientAuth == ClientAuth.REQUIRE && !isPkcs11Truststore && !transportSettings.hasValue(TRUSTSTORE_FILEPATH)) { throw new OpenSearchException( "Wrong " + transportType.id().toLowerCase(Locale.ROOT) @@ -448,7 +454,12 @@ private void validateTransportSettings(final Settings transportSettings) { } private void verifyKeyAndTrustStoreSettings(final Settings settings) { - if (!settings.hasValue(KEYSTORE_FILEPATH) || !settings.hasValue(TRUSTSTORE_FILEPATH)) { + // PKCS#11 keystores/truststores have no filepath - their material comes from the token. + final boolean keyStorePresent = settings.hasValue(KEYSTORE_FILEPATH) + || PemKeyReader.PKCS11.equalsIgnoreCase(settings.get(KEYSTORE_TYPE)); + final boolean trustStorePresent = settings.hasValue(TRUSTSTORE_FILEPATH) + || PemKeyReader.PKCS11.equalsIgnoreCase(settings.get(TRUSTSTORE_TYPE)); + if (!keyStorePresent || !trustStorePresent) { throw new OpenSearchException( "Wrong Transport/Tran SSL configuration. One of Keystore and Truststore files or X.509 PEM certificates and " + "PKCS#8 keys groups should be set to configure Transport layer properly" @@ -461,7 +472,10 @@ private boolean hasExtendedKeyUsageEnabled(final Settings settings) { } private boolean hasKeyOrTrustStoreSettings(final Settings settings) { - return settings.hasValue(KEYSTORE_FILEPATH) || settings.hasValue(TRUSTSTORE_FILEPATH); + return settings.hasValue(KEYSTORE_FILEPATH) + || settings.hasValue(TRUSTSTORE_FILEPATH) + || PemKeyReader.PKCS11.equalsIgnoreCase(settings.get(KEYSTORE_TYPE)) + || PemKeyReader.PKCS11.equalsIgnoreCase(settings.get(TRUSTSTORE_TYPE)); } private boolean hasPemStoreSettings(final Settings settings) { diff --git a/src/main/java/org/opensearch/security/ssl/config/KeyStoreConfiguration.java b/src/main/java/org/opensearch/security/ssl/config/KeyStoreConfiguration.java index 2713c1a2e1..ecbf78ebd2 100644 --- a/src/main/java/org/opensearch/security/ssl/config/KeyStoreConfiguration.java +++ b/src/main/java/org/opensearch/security/ssl/config/KeyStoreConfiguration.java @@ -29,6 +29,7 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; +import org.opensearch.security.support.PemKeyReader; public interface KeyStoreConfiguration { @@ -36,6 +37,14 @@ public interface KeyStoreConfiguration { List loadCertificates(); + /** + * @return true when the key material is held in a PKCS#11 token. Such keys are non-exportable, so the + * TLS engine must delegate signing to the token's provider (SunPKCS11) rather than BouncyCastle FIPS. + */ + default boolean isPkcs11() { + return false; + } + default KeyManagerFactory createKeyManagerFactory(boolean validateCertificates) { final var keyStore = createKeyStore(); if (validateCertificates) { @@ -129,6 +138,11 @@ public List loadCertificates() { } } + @Override + public boolean isPkcs11() { + return PemKeyReader.PKCS11.equalsIgnoreCase(type); + } + @Override public List files() { return path != null ? List.of(path) : List.of(); From 80f50d2ad55909483c2c17f7a0c7b4253ccaf5b4 Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Fri, 3 Jul 2026 15:08:05 +0200 Subject: [PATCH 6/9] spotlessApply Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad --- .../jwt/EncryptionDecryptionUtilsTest.java | 3 +-- .../security/authtoken/jwt/JwtVendorTest.java | 10 ++++++++-- .../http/OnBehalfOfAuthenticatorTest.java | 16 ++++++++++++---- .../security/ssl/SslSettingsManagerTest.java | 2 +- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java index 50f722649c..1e679408f8 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java @@ -30,8 +30,7 @@ public class EncryptionDecryptionUtilsTest { // encryption_key is consumed Base64-decoded; a 32-char alphanumeric string decodes to only 24 bytes, // under the 32-byte AES-256 floor FIPS requires. Base64-encode the 32 bytes so it decodes back to 32. final static String key = RandomStringUtils.secure().nextAlphanumeric(32); - final static String encodedKey = Base64.getEncoder() - .encodeToString(key.getBytes(StandardCharsets.UTF_8)); + final static String encodedKey = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8)); @Test public void testEncryptDecrypt() { diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index dd89f0563f..d0a4c8ae71 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -88,14 +88,20 @@ public void testCreateJwkFromSettings() { @Test public void testCreateJwkFromSettingsWithWeakKey() { Settings settings = Settings.builder().put(SIGNING_KEY_PROPERTY_KEY, "abcd1234").build(); - Throwable exception = assertThrows(OpenSearchException.class, () -> JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath())); + Throwable exception = assertThrows( + OpenSearchException.class, + () -> JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath()) + ); assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); } @Test public void testCreateJwkFromSettingsWithoutSigningKey() { Settings settings = Settings.builder().put("jwt", "").build(); - Throwable exception = assertThrows(RuntimeException.class, () -> JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath())); + Throwable exception = assertThrows( + RuntimeException.class, + () -> JwtVendor.createJwkFromSettings(settings, tempDir.getRoot().toPath()) + ); assertThat( exception.getMessage(), equalTo("Settings for signing key is missing. Please specify at least the option signing_key with a shared secret.") diff --git a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java index 409898e241..8d4f56307b 100644 --- a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java @@ -494,7 +494,11 @@ public void testEncryptionKeyFromKeystoreDecryptsRoles() throws Exception { ).build(); // Simulate issuance: encrypt the roles with the same keystore-derived key - final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings(settings, "encryption_key", tempDir.getRoot().toPath()); + final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings( + settings, + "encryption_key", + tempDir.getRoot().toPath() + ); final String encryptedRoles = issuerUtil.encrypt("role1,role2"); final String jwsToken = Jwts.builder() @@ -551,7 +555,11 @@ public void testSigningAndEncryptionKeysBothFromKeystore() throws Exception { final Settings settings = builder.build(); // Simulate issuance: encrypt the roles with the same keystore-derived AES key the verifier will resolve. - final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings(settings, "encryption_key", tempDir.getRoot().toPath()); + final EncryptionDecryptionUtil issuerUtil = EncryptionDecryptionUtil.fromSettings( + settings, + "encryption_key", + tempDir.getRoot().toPath() + ); final String encryptedRoles = issuerUtil.encrypt("role1,role2"); final String jwsToken = Jwts.builder() @@ -858,8 +866,8 @@ private AuthCredentials extractCredentialsFromJwtHeader( tempDir.getRoot().toPath() ); - final String jwsToken = - jwtBuilder.signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512).compact(); + final String jwsToken = jwtBuilder.signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), Jwts.SIG.HS512) + .compact(); final Map headers = Map.of("Authorization", "Bearer " + jwsToken); return jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), null); } diff --git a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java index 7eb9916c0f..f5bf5ff259 100644 --- a/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java +++ b/src/test/java/org/opensearch/security/ssl/SslSettingsManagerTest.java @@ -28,10 +28,10 @@ import org.opensearch.env.TestEnvironment; import org.opensearch.security.ssl.config.CertType; import org.opensearch.security.util.BCFipsEntropyDaemonFilter; +import org.opensearch.test.BouncyCastleThreadFilter; import io.netty.handler.ssl.ClientAuth; import io.netty.handler.ssl.SslContext; -import org.opensearch.test.BouncyCastleThreadFilter; import static org.hamcrest.MatcherAssert.assertThat; import static org.opensearch.security.ssl.CertificatesUtils.privateKeyToPemObject; From 927fe2551001ff922cdd746d4919811cdf36a9ae Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Mon, 13 Jul 2026 16:08:10 +0200 Subject: [PATCH 7/9] ldap2: SNI-aware connections and JNDI socket-factory classloader for LDAPS reconnect Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad --- .../backend/LDAPAuthorizationBackend.java | 54 +--- .../ldap2/HostnameAwareConnectionFactory.java | 36 ++- .../ldap2/LDAPConnectionFactoryFactory.java | 85 ++---- .../ldap2/SNISettingTLSSocketFactory.java | 24 +- .../auth/ldap2/SniAwareConnection.java | 108 ++++++++ .../auth/ldap2/SocketFactoryClassLoader.java | 45 +++ .../util/SettingsBasedSSLConfigurator.java | 5 +- .../HostnameAwareConnectionFactoryTest.java | 31 ++- ...nnectionFactoryFactoryClassLoaderTest.java | 77 ++++++ .../ldap2/SNISettingTLSSocketFactoryTest.java | 37 +-- .../auth/ldap2/SniAwareConnectionTest.java | 260 ++++++++++++++++++ .../securityconf/impl/v7/ConfigV7Test.java | 1 + .../test/helper/cluster/ClusterHelper.java | 2 - 13 files changed, 591 insertions(+), 174 deletions(-) create mode 100644 src/main/java/org/opensearch/security/auth/ldap2/SniAwareConnection.java create mode 100644 src/main/java/org/opensearch/security/auth/ldap2/SocketFactoryClassLoader.java create mode 100644 src/test/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactoryClassLoaderTest.java create mode 100644 src/test/java/org/opensearch/security/auth/ldap2/SniAwareConnectionTest.java diff --git a/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthorizationBackend.java b/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthorizationBackend.java index fdd1540b8c..64e207290d 100755 --- a/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthorizationBackend.java +++ b/src/main/java/org/opensearch/security/auth/ldap/backend/LDAPAuthorizationBackend.java @@ -53,8 +53,8 @@ import org.opensearch.security.auth.ldap.util.LdapHelper; import org.opensearch.security.auth.ldap.util.Utils; import org.opensearch.security.auth.ldap2.SNISettingTLSSocketFactory; +import org.opensearch.security.auth.ldap2.SocketFactoryClassLoader; import org.opensearch.security.ssl.util.SSLConfigConstants; -import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.PemKeyReader; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; @@ -82,9 +82,7 @@ import org.ldaptive.ssl.AllowAnyTrustManager; import org.ldaptive.ssl.CredentialConfig; import org.ldaptive.ssl.CredentialConfigFactory; -import org.ldaptive.ssl.DefaultHostnameVerifier; import org.ldaptive.ssl.SslConfig; -import org.ldaptive.ssl.ThreadLocalTLSSocketFactory; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_TRANSPORT_KEYSTORE_PASSWORD; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_TRANSPORT_TRUSTSTORE_PASSWORD; @@ -94,9 +92,7 @@ public class LDAPAuthorizationBackend implements AuthorizationBackend { private static final AtomicInteger CONNECTION_COUNTER = new AtomicInteger(); private static final String COM_SUN_JNDI_LDAP_OBJECT_DISABLE_ENDPOINT_IDENTIFICATION = "com.sun.jndi.ldap.object.disableEndpointIdentification"; - private static final List DEFAULT_TLS_PROTOCOLS = FipsMode.isEnabled() - ? ImmutableList.of("TLSv1.2") - : ImmutableList.of("TLSv1.2", "TLSv1.1"); + private static final List DEFAULT_TLS_PROTOCOLS = ImmutableList.copyOf(SSLConfigConstants.ALLOWED_SSL_PROTOCOLS); static final int ONE_PLACEHOLDER = 1; static final int TWO_PLACEHOLDER = 2; static final String DEFAULT_ROLEBASE = ""; @@ -139,7 +135,7 @@ public static void checkConnection(final ConnectionConfig connectionConfig, Stri ClassLoader originalClassloader = null; if (isJava9OrHigher) { originalClassloader = Thread.currentThread().getContextClassLoader(); - Thread.currentThread().setContextClassLoader(new Java9CL()); + Thread.currentThread().setContextClassLoader(new SocketFactoryClassLoader()); } checkConnection0(connectionConfig, bindDn, password, originalClassloader, isJava9OrHigher); @@ -154,7 +150,7 @@ public static Connection getConnection(final Settings settings, final Path confi ClassLoader originalClassloader = null; if (isJava9OrHigher) { originalClassloader = Thread.currentThread().getContextClassLoader(); - Thread.currentThread().setContextClassLoader(new Java9CL()); + Thread.currentThread().setContextClassLoader(new SocketFactoryClassLoader()); } return getConnection0(settings, configPath, originalClassloader, isJava9OrHigher); @@ -212,9 +208,7 @@ private static void checkConnection0( DefaultConnectionFactory connFactory = new DefaultConnectionFactory(config); - // Register custom socket factory for SNI hostname verification if SSL is enabled String sniHostname = null; - boolean verifyHostname = shouldVerifyHostname(config.getSslConfig()); if (config.getLdapUrl() != null && config.getLdapUrl().startsWith("ldaps:")) { configureSNISocketFactory(connFactory); String ldapUrl = config.getLdapUrl(); @@ -231,7 +225,7 @@ private static void checkConnection0( try ( SNISettingTLSSocketFactory.SniContext ignored = StringUtils.isBlank(sniHostname) ? null - : SNISettingTLSSocketFactory.configure(sniHostname, verifyHostname) + : SNISettingTLSSocketFactory.configure(sniHostname) ) { connection = connFactory.getConnection(); connection.open(); @@ -339,9 +333,8 @@ private static Connection getConnection0( // This addresses a known issue where JNDI LDAP doesn't pass hostname to SSLSocketFactory // See: https://github.com/bcgit/bc-java/issues/460 if (enableSSL) { - boolean verifyHostname = shouldVerifyHostname(config.getSslConfig()); configureSNISocketFactory(connFactory); - try (var ignored = SNISettingTLSSocketFactory.configure(split[0], verifyHostname)) { + try (var ignored = SNISettingTLSSocketFactory.configure(split[0])) { connection = connFactory.getConnection(); connection.open(); } @@ -658,9 +651,7 @@ private static void configureSSL(final ConnectionConfig config, final Settings s sslConfig.setTrustManagers(new AllowAnyTrustManager()); } - if (verifyHostnames) { - sslConfig.setHostnameVerifier(new DefaultHostnameVerifier()); - } else { + if (!verifyHostnames) { sslConfig.setHostnameVerifier(new AllowAnyHostnameVerifier()); final String deiProp = System.getProperty(COM_SUN_JNDI_LDAP_OBJECT_DISABLE_ENDPOINT_IDENTIFICATION); @@ -677,8 +668,6 @@ private static void configureSSL(final ConnectionConfig config, final Settings s // https://www.oracle.com/technetwork/java/javase/10-0-2-relnotes-4477557.html // https://www.oracle.com/technetwork/java/javase/11-0-1-relnotes-5032023.html } - - System.setProperty(COM_SUN_JNDI_LDAP_OBJECT_DISABLE_ENDPOINT_IDENTIFICATION, "true"); } final List enabledCipherSuites = settings.getAsList(ConfigConstants.LDAPS_ENABLED_SSL_CIPHERS, Collections.emptyList()); @@ -1226,33 +1215,4 @@ private String getRoleFromEntry(final Connection ldapConnection, final LdapName return null; } - - @SuppressWarnings("rawtypes") - private final static Class clazz = ThreadLocalTLSSocketFactory.class; - - private final static class Java9CL extends ClassLoader { - - public Java9CL() { - super(); - } - - @SuppressWarnings("unused") - public Java9CL(ClassLoader parent) { - super(parent); - } - - @SuppressWarnings({ "rawtypes", "unchecked" }) - @Override - public Class loadClass(String name) throws ClassNotFoundException { - - if (name.equals("org.opensearch.security.auth.ldap2.SNISettingTLSSocketFactory")) { - return SNISettingTLSSocketFactory.class; - } - if (name.equalsIgnoreCase("org.ldaptive.ssl.ThreadLocalTLSSocketFactory")) { - return clazz; - } - - return super.loadClass(name); - } - } } diff --git a/src/main/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactory.java b/src/main/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactory.java index 1c1d9e11dd..793ed3484d 100644 --- a/src/main/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactory.java +++ b/src/main/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactory.java @@ -12,26 +12,36 @@ package org.opensearch.security.auth.ldap2; import org.ldaptive.Connection; -import org.ldaptive.ConnectionFactory; -import org.ldaptive.LdapException; +import org.ldaptive.ConnectionConfig; +import org.ldaptive.DefaultConnectionFactory; import org.ldaptive.LdapURL; /** - * Wrapper around ConnectionFactory that extracts the hostname from the LDAP URL - * and stores it in ThreadLocal for use by SNISettingTLSSocketFactory. + * {@link DefaultConnectionFactory} that extracts the hostname from the LDAP URL and returns a + * {@link SniAwareConnection}, so the SNI context is established when the connection is opened — + * the TLS socket is created at {@code open()}, not at {@code getConnection()}. * - *

    This is necessary because JNDI LDAP resolves hostnames to IP addresses before - * creating SSL sockets, making the hostname unavailable for SNI configuration. + *

    Extending {@link DefaultConnectionFactory} (rateher than merely implementing + * {@code ConnectionFactory}) lets the same class back a connection pool, which requires a + * concrete {@link DefaultConnectionFactory}, as well as serve non-pooled connections directly. + * The pool creates each physical connection via {@link #getConnection()} and then calls + * {@code open()} on it, so the SNI wrapping applies uniformly to pooled and non-pooled paths. + * + *

    This is necessary because JNDI LDAP resolves hostnames to IP addresses before creating + * SSL sockets, making the hostname unavailable for SNI configuration. */ -public record HostnameAwareConnectionFactory(ConnectionFactory delegate, String ldapUrl, boolean verifyHostname) - implements - ConnectionFactory { +public class HostnameAwareConnectionFactory extends DefaultConnectionFactory { + + private final String ldapUrl; + + public HostnameAwareConnectionFactory(ConnectionConfig config, String ldapUrl) { + super(config); + this.ldapUrl = ldapUrl; + } @Override - public Connection getConnection() throws LdapException { + public Connection getConnection() { String hostname = new LdapURL(ldapUrl).getEntry().getHostname(); - try (var ignored = SNISettingTLSSocketFactory.configure(hostname, verifyHostname)) { - return delegate.getConnection(); - } + return new SniAwareConnection(super.getConnection(), hostname); } } diff --git a/src/main/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactory.java b/src/main/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactory.java index 732239f38e..5b7f711167 100644 --- a/src/main/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactory.java +++ b/src/main/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactory.java @@ -14,6 +14,7 @@ import java.nio.file.Path; import java.security.GeneralSecurityException; import java.time.Duration; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -39,7 +40,6 @@ import org.ldaptive.Credential; import org.ldaptive.DefaultConnectionFactory; import org.ldaptive.LdapAttribute; -import org.ldaptive.LdapURL; import org.ldaptive.RandomConnectionStrategy; import org.ldaptive.ReturnAttributes; import org.ldaptive.RoundRobinConnectionStrategy; @@ -80,52 +80,33 @@ public LDAPConnectionFactoryFactory(Settings settings, Path configPath) throws S } public ConnectionFactory createConnectionFactory(ConnectionPool connectionPool) { - ConnectionFactory factory; if (connectionPool != null) { - factory = new PooledConnectionFactory(connectionPool); - } else { - factory = createHostnameAwareConnectionFactory(); + return new PooledConnectionFactory(connectionPool); } - - // PooledConnectionFactory wraps a pool whose connections are already hostname-aware - // Non-pooled factory is already wrapped by createHostnameAwareConnectionFactory() - return factory; + return createHostnameAwareConnectionFactory(); } /** - * Creates a connection factory wrapped with HostnameAwareConnectionFactory - * to ensure SNI hostname is set for each connection. - * This must be used for both pooled and non-pooled connections. + * Creates a {@link HostnameAwareConnectionFactory} that returns SNI-aware connections, so the + * SNI context is set when the connection is opened (at {@code open()}, not + * {@code getConnection()}). Being a {@link DefaultConnectionFactory}, it serves both non-pooled + * connections directly and backs a connection pool. */ - private ConnectionFactory createHostnameAwareConnectionFactory() { - DefaultConnectionFactory basicFactory = createBasicConnectionFactory(); - String ldapUrl = getLdapUrlString(); - boolean verifyHostname = sslConfig != null && sslConfig.isHostnameVerificationEnabled(); - return new HostnameAwareConnectionFactory(basicFactory, ldapUrl, verifyHostname); + private DefaultConnectionFactory createHostnameAwareConnectionFactory() { + return configureFactory(new HostnameAwareConnectionFactory(getConnectionConfig(), getLdapUrlString())); + } + + public DefaultConnectionFactory createBasicConnectionFactory() { + return configureFactory(new DefaultConnectionFactory(getConnectionConfig())); } /** - * Creates a DefaultConnectionFactory that sets hostname in ThreadLocal before each connection. - * This is needed for connection pools which require DefaultConnectionFactory (not just ConnectionFactory). + * Applies the shared provider (privileged, for the JNDI socket-factory classloader) and SSL + * wiring that every factory this class builds needs. */ - private DefaultConnectionFactory createHostnameAwareDefaultConnectionFactory() { - final String ldapUrl = getLdapUrlString(); - - // Create a custom DefaultConnectionFactory that sets hostname before creating connections - DefaultConnectionFactory factory = new DefaultConnectionFactory(getConnectionConfig()) { - @Override - public Connection getConnection() { - String hostname = new LdapURL(ldapUrl).getEntry().getHostname(); - boolean verifyHostname = sslConfig != null && sslConfig.isHostnameVerificationEnabled(); - try (var ignored = SNISettingTLSSocketFactory.configure(hostname, verifyHostname)) { - return super.getConnection(); - } - } - }; - - @SuppressWarnings("unchecked") - Provider provider = (Provider) factory.getProvider(); - factory.setProvider(new PrivilegedProvider(provider)); + @SuppressWarnings("unchecked") + private T configureFactory(T factory) { + factory.setProvider(new PrivilegedProvider((Provider) factory.getProvider())); if (this.sslConfig != null) { configureSSLinConnectionFactory(factory); @@ -134,19 +115,6 @@ public Connection getConnection() { return factory; } - @SuppressWarnings("unchecked") - public DefaultConnectionFactory createBasicConnectionFactory() { - DefaultConnectionFactory result = new DefaultConnectionFactory(getConnectionConfig()); - - result.setProvider(new PrivilegedProvider((Provider) result.getProvider())); - - if (this.sslConfig != null) { - configureSSLinConnectionFactory(result); - } - - return result; - } - public ConnectionPool createConnectionPool() { if (!this.settings.getAsBoolean(ConfigConstants.LDAP_POOL_ENABLED, false)) { @@ -170,7 +138,7 @@ public ConnectionPool createConnectionPool() { // Use hostname-aware DefaultConnectionFactory for pool to ensure SNI is set for all connections // including those created during pool initialization - DefaultConnectionFactory hostnameAwareFactory = createHostnameAwareDefaultConnectionFactory(); + DefaultConnectionFactory hostnameAwareFactory = createHostnameAwareConnectionFactory(); if ("blocking".equals(this.settings.get(ConfigConstants.LDAP_POOL_TYPE))) { result = new BlockingConnectionPool(poolConfig, hostnameAwareFactory); @@ -374,11 +342,15 @@ private void configureSSL(ConnectionConfig config) { } } - if (this.sslConfig.getSupportedCipherSuites() != null && this.sslConfig.getSupportedCipherSuites().length > 0) { - ldaptiveSslConfig.setEnabledCipherSuites(this.sslConfig.getSupportedCipherSuites()); + final String[] enabledCipherSuites = this.sslConfig.getSupportedCipherSuites(); + if (enabledCipherSuites != null && enabledCipherSuites.length > 0) { + ldaptiveSslConfig.setEnabledCipherSuites(enabledCipherSuites); + log.debug("enabled ssl cipher suites for ldaps {}", Arrays.toString(enabledCipherSuites)); } - ldaptiveSslConfig.setEnabledProtocols(this.sslConfig.getSupportedProtocols()); + final String[] enabledProtocols = this.sslConfig.getSupportedProtocols(); + log.debug("enabled ssl/tls protocols for ldaps {}", Arrays.toString(enabledProtocols)); + ldaptiveSslConfig.setEnabledProtocols(enabledProtocols); if (this.sslConfig.isTrustAllEnabled()) { ldaptiveSslConfig.setTrustManagers(new AllowAnyTrustManager()); @@ -419,7 +391,8 @@ private void configureSSLinConnectionFactory(DefaultConnectionFactory connection // See: https://github.com/bcgit/bc-java/issues/460 props.put("java.naming.ldap.factory.socket", "org.opensearch.security.auth.ldap2.SNISettingTLSSocketFactory"); - connectionFactory.getProvider().getProviderConfig().setProperties(props); - + JndiProviderConfig providerConfig = (JndiProviderConfig) connectionFactory.getProvider().getProviderConfig(); + providerConfig.setProperties(props); + providerConfig.setClassLoader(new SocketFactoryClassLoader(SNISettingTLSSocketFactory.class.getClassLoader())); } } diff --git a/src/main/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactory.java b/src/main/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactory.java index e7826ce688..a8a0155194 100644 --- a/src/main/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactory.java +++ b/src/main/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactory.java @@ -43,12 +43,8 @@ public class SNISettingTLSSocketFactory extends SSLSocketFactory { private static final Logger log = LogManager.getLogger(SNISettingTLSSocketFactory.class); - // ThreadLocal to store the hostname for the current LDAP connection private static final ThreadLocal hostnameThreadLocal = new ThreadLocal<>(); - // ThreadLocal to control whether endpoint identification should be enabled - private static final ThreadLocal verifyHostnameThreadLocal = ThreadLocal.withInitial(() -> false); - private final SSLSocketFactory delegate; /** @@ -73,19 +69,18 @@ public interface SniContext extends AutoCloseable { } /** - * Sets SNI hostname and endpoint identification flag for the current thread, returning an - * {@link SniContext} that clears both on close. Intended for use in try-with-resources: + * Sets the SNI hostname for the current thread, returning an {@link SniContext} that clears it + * on close. Intended for use in try-with-resources: * *

    {@code
    -     * try (var ignored = SNISettingTLSSocketFactory.configure(hostname, verifyHostname)) {
    +     * try (var ignored = SNISettingTLSSocketFactory.configure(hostname)) {
          *     connection.open();
          * }
          * }
    */ - public static SniContext configure(String hostname, boolean verifyHostname) { + public static SniContext configure(String hostname) { hostnameThreadLocal.set(hostname); - verifyHostnameThreadLocal.set(verifyHostname); - log.debug("Configured SNI context: hostname={}, verifyHostname={}", hostname, verifyHostname); + log.debug("Configured SNI context: hostname={}", hostname); return SNISettingTLSSocketFactory::clearContext; } @@ -95,7 +90,6 @@ static String getHostname() { static void clearContext() { hostnameThreadLocal.remove(); - verifyHostnameThreadLocal.remove(); } SNISettingTLSSocketFactory(SSLSocketFactory delegate) { @@ -148,7 +142,9 @@ public Socket createSocket(InetAddress address, int port, InetAddress localAddre } /** - * Configures SNI and hostname verification on a newly created socket. + * Sets the SNI {@code server_name} on a newly created socket from the hostname carried on the + * ThreadLocal. Hostname verification is handled by JNDI's endpoint identification (and + * ldaptive's verifier) — not here. * * @param socket the created socket * @return the configured socket @@ -169,10 +165,6 @@ protected Socket configureSocket(Socket socket) { } SSLParameters params = sslSocket.getSSLParameters(); - if (verifyHostnameThreadLocal.get()) { - params.setEndpointIdentificationAlgorithm("LDAPS"); - } - if (!IPAddress.isValid(hostname)) { log.debug("Configuring SNI for hostname: {} on socket: {}", hostname, socket.getClass().getName()); params.setServerNames(Collections.singletonList(new SNIHostName(hostname))); diff --git a/src/main/java/org/opensearch/security/auth/ldap2/SniAwareConnection.java b/src/main/java/org/opensearch/security/auth/ldap2/SniAwareConnection.java new file mode 100644 index 0000000000..880d2fac9b --- /dev/null +++ b/src/main/java/org/opensearch/security/auth/ldap2/SniAwareConnection.java @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import org.ldaptive.BindRequest; +import org.ldaptive.Connection; +import org.ldaptive.ConnectionConfig; +import org.ldaptive.LdapException; +import org.ldaptive.Response; +import org.ldaptive.control.RequestControl; +import org.ldaptive.provider.ProviderConnection; + +/** + * {@link Connection} decorator that establishes the {@link SNISettingTLSSocketFactory} SNI + * context for the duration of every socket-creating call ({@code open} / {@code reopen}). + * + *

    ldaptive's {@code getConnection()} returns an unopened connection; the TLS socket + * is created later, at {@code open()}. Setting the SNI ThreadLocal only around + * {@code getConnection()} therefore clears it before the socket exists, so the socket factory + * sees no hostname (no SNI) and no verify flag (hostname verification skipped). Wrapping + * {@code open()}/{@code reopen()} keeps the hostname and verify flag available exactly when the + * socket is created — for non-pooled and pooled connections alike, since a pool opens + * connections on the thread that initialises/borrows them. + * + *

    This whole mechanism (this decorator + the SNI ThreadLocal in + * {@link SNISettingTLSSocketFactory} + {@link HostnameAwareConnectionFactory}) is a workaround for + * the JNDI LDAP provider resolving hostnames to IPs before socket creation (bc-java#460). ldaptive + * 2.x's native (Netty) transport opens sockets with the real hostname, giving SNI and hostname + * verification natively — migrating off the JNDI provider would remove this class, + * {@link SNISettingTLSSocketFactory}, and {@link HostnameAwareConnectionFactory}. + */ +class SniAwareConnection implements Connection { + + private final Connection delegate; + private final String hostname; + + SniAwareConnection(Connection delegate, String hostname) { + this.delegate = delegate; + this.hostname = hostname; + } + + /** The SNI hostname this connection establishes at {@code open()}. Package-private for tests. */ + String hostname() { + return hostname; + } + + @Override + public Response open() throws LdapException { + try (var ignored = SNISettingTLSSocketFactory.configure(hostname)) { + return delegate.open(); + } + } + + @Override + public Response open(BindRequest request) throws LdapException { + try (var ignored = SNISettingTLSSocketFactory.configure(hostname)) { + return delegate.open(request); + } + } + + @Override + public Response reopen() throws LdapException { + try (var ignored = SNISettingTLSSocketFactory.configure(hostname)) { + return delegate.reopen(); + } + } + + @Override + public Response reopen(BindRequest request) throws LdapException { + try (var ignored = SNISettingTLSSocketFactory.configure(hostname)) { + return delegate.reopen(request); + } + } + + @Override + public ConnectionConfig getConnectionConfig() { + return delegate.getConnectionConfig(); + } + + @Override + public boolean isOpen() { + return delegate.isOpen(); + } + + @Override + public ProviderConnection getProviderConnection() { + return delegate.getProviderConnection(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public void close(RequestControl[] controls) { + delegate.close(controls); + } +} diff --git a/src/main/java/org/opensearch/security/auth/ldap2/SocketFactoryClassLoader.java b/src/main/java/org/opensearch/security/auth/ldap2/SocketFactoryClassLoader.java new file mode 100644 index 0000000000..e2faf0d1f5 --- /dev/null +++ b/src/main/java/org/opensearch/security/auth/ldap2/SocketFactoryClassLoader.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import org.ldaptive.ssl.ThreadLocalTLSSocketFactory; + +/** + * Classloader that resolves the JNDI LDAP socket-factory classes by name. + * + *

    On Java 9+ the module system means JNDI's {@code com.sun.jndi.ldap.Connection.getSocketFactory} + * loads the class named by {@code java.naming.ldap.factory.socket} through the {@code java.naming} + * module loader, which cannot see application/plugin classes on the classpath. Installing this as the + * thread-context classloader lets JNDI resolve {@link SNISettingTLSSocketFactory} (and ldaptive's + * {@link ThreadLocalTLSSocketFactory}) by name; everything else delegates to the parent loader. + */ +public final class SocketFactoryClassLoader extends ClassLoader { + + public SocketFactoryClassLoader() { + super(); + } + + public SocketFactoryClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + if (SNISettingTLSSocketFactory.class.getName().equals(name)) { + return SNISettingTLSSocketFactory.class; + } + if (ThreadLocalTLSSocketFactory.class.getName().equalsIgnoreCase(name)) { + return ThreadLocalTLSSocketFactory.class; + } + return super.loadClass(name); + } +} diff --git a/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfigurator.java b/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfigurator.java index 95b3fcbb03..6706462e69 100644 --- a/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfigurator.java +++ b/src/main/java/org/opensearch/security/util/SettingsBasedSSLConfigurator.java @@ -45,7 +45,6 @@ import org.opensearch.common.settings.Settings; import org.opensearch.security.ssl.util.SSLConfigConstants; -import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.PemKeyReader; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_TRANSPORT_KEYSTORE_PASSWORD; @@ -79,9 +78,7 @@ public class SettingsBasedSSLConfigurator { public static final String VERIFY_HOSTNAMES = "verify_hostnames"; public static final String TRUST_ALL = "trust_all"; - private static final List DEFAULT_TLS_PROTOCOLS = FipsMode.isEnabled() - ? ImmutableList.of("TLSv1.3", "TLSv1.2") - : ImmutableList.of("TLSv1.2", "TLSv1.1"); + private static final List DEFAULT_TLS_PROTOCOLS = ImmutableList.copyOf(SSLConfigConstants.ALLOWED_SSL_PROTOCOLS); private SSLContextBuilder sslContextBuilder; private final Settings settings; diff --git a/src/test/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactoryTest.java b/src/test/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactoryTest.java index 24293d2145..de6f0667a1 100644 --- a/src/test/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactoryTest.java +++ b/src/test/java/org/opensearch/security/auth/ldap2/HostnameAwareConnectionFactoryTest.java @@ -14,9 +14,12 @@ import org.junit.After; import org.junit.Test; -import org.ldaptive.LdapException; +import org.ldaptive.Connection; +import org.ldaptive.ConnectionConfig; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; public class HostnameAwareConnectionFactoryTest { @@ -26,18 +29,24 @@ public void clearThreadLocal() { } @Test - public void getConnection_setsHostnameBeforeDelegating() throws LdapException { - String[] captured = { null }; - new HostnameAwareConnectionFactory(() -> { - captured[0] = SNISettingTLSSocketFactory.getHostname(); - return null; - }, "ldaps://example.com:636", true).getConnection(); - - assertEquals("example.com", captured[0]); + public void getConnection_wrapsWithHostnameFromUrl_withoutSettingContextAtBuild() { + HostnameAwareConnectionFactory factory = new HostnameAwareConnectionFactory( + new ConnectionConfig("ldaps://example.com:636"), + "ldaps://example.com:636" + ); + + Connection connection = factory.getConnection(); + + // The connection is wrapped with the hostname parsed from the LDAP URL; the wrapper then + // establishes it as the SNI context at open() — see SniAwareConnectionTest. + assertTrue(connection instanceof SniAwareConnection); + assertEquals("example.com", ((SniAwareConnection) connection).hostname()); + // Building the connection must NOT set the context — the socket isn't created yet. + assertNull(SNISettingTLSSocketFactory.getHostname()); } @Test(expected = IllegalArgumentException.class) - public void getConnection_throwsOnUnparseableUrl() throws LdapException { - new HostnameAwareConnectionFactory(() -> null, "not-a-valid-url", false).getConnection(); + public void getConnection_throwsOnUnparseableUrl() { + new HostnameAwareConnectionFactory(new ConnectionConfig("ldaps://example.com:636"), "not-a-valid-url").getConnection(); } } diff --git a/src/test/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactoryClassLoaderTest.java b/src/test/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactoryClassLoaderTest.java new file mode 100644 index 0000000000..944c97e3b4 --- /dev/null +++ b/src/test/java/org/opensearch/security/auth/ldap2/LDAPConnectionFactoryFactoryClassLoaderTest.java @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import org.junit.Test; + +import org.opensearch.common.settings.Settings; +import org.opensearch.security.auth.ldap.util.ConfigConstants; +import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.test.helper.file.FileHelper; + +import org.ldaptive.DefaultConnectionFactory; +import org.ldaptive.provider.Provider; +import org.ldaptive.provider.jndi.JndiProviderConfig; +import org.ldaptive.ssl.ThreadLocalTLSSocketFactory; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.Assert.assertThrows; + +public class LDAPConnectionFactoryFactoryClassLoaderTest { + + private static final String SNI_FACTORY = "org.opensearch.security.auth.ldap2.SNISettingTLSSocketFactory"; + + /** Builds a real factory and returns the classloader ldap2 sets on the JNDI provider config. */ + private static ClassLoader factoryProvidedClassLoader() throws Exception { + Settings settings = Settings.builder() + .putList(ConfigConstants.LDAP_HOSTS, "localhost:636") + .put(ConfigConstants.LDAPS_ENABLE_SSL, true) + .put(SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, FileHelper.resolveStore("ldap/truststore").path()) + .put("path.home", ".") + .build(); + + DefaultConnectionFactory connectionFactory = new LDAPConnectionFactoryFactory(settings, null).createBasicConnectionFactory(); + + @SuppressWarnings("unchecked") + Provider provider = (Provider) connectionFactory.getProvider(); + return provider.getProviderConfig().getClassLoader(); + } + + @Test + public void providerConfig_carriesClassLoaderThatResolvesSniSocketFactory() throws Exception { + ClassLoader factoryClassLoader = factoryProvidedClassLoader(); + assertThat(factoryClassLoader.loadClass(SNI_FACTORY), is(sameInstance(SNISettingTLSSocketFactory.class))); + } + + @Test + public void providerConfig_classLoaderResolvesThreadLocalTlsSocketFactory() throws Exception { + ClassLoader factoryClassLoader = factoryProvidedClassLoader(); + assertThat( + factoryClassLoader.loadClass(ThreadLocalTLSSocketFactory.class.getName()), + is(sameInstance(ThreadLocalTLSSocketFactory.class)) + ); + } + + @Test + public void providerConfig_classLoaderDelegatesUnknownClassesToParent() throws Exception { + ClassLoader factoryClassLoader = factoryProvidedClassLoader(); + assertThrows(ClassNotFoundException.class, () -> factoryClassLoader.loadClass("com.example.DoesNotExist")); + } + + @Test + public void noArgConstructor_resolvesSniSocketFactory() throws Exception { + ClassLoader loader = new SocketFactoryClassLoader(); + assertThat(loader.loadClass(SNI_FACTORY), is(sameInstance(SNISettingTLSSocketFactory.class))); + } +} diff --git a/src/test/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactoryTest.java b/src/test/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactoryTest.java index 0eccde4ad7..47b2723936 100644 --- a/src/test/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactoryTest.java +++ b/src/test/java/org/opensearch/security/auth/ldap2/SNISettingTLSSocketFactoryTest.java @@ -39,40 +39,27 @@ public void clearThreadLocal() { // --- configureSocket --- @Test - public void configureSocket_setsSniAndEndpointIdentification_whenVerifyHostname() throws Exception { + public void configureSocket_setsSni() throws Exception { SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); - SNISettingTLSSocketFactory.configure("example.com", true); - - factory.configureSocket(socket); - - List serverNames = socket.getSSLParameters().getServerNames(); - assertEquals(1, serverNames.size()); - assertEquals("example.com", ((SNIHostName) serverNames.get(0)).getAsciiName()); - assertEquals("LDAPS", socket.getSSLParameters().getEndpointIdentificationAlgorithm()); - } - - @Test - public void configureSocket_setsSniButNotEndpointIdentification_whenVerifyHostnameDisabled() throws Exception { - SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); - SNISettingTLSSocketFactory.configure("example.com", false); + SNISettingTLSSocketFactory.configure("example.com"); factory.configureSocket(socket); List serverNames = socket.getSSLParameters().getServerNames(); assertEquals(1, serverNames.size()); assertEquals("example.com", ((SNIHostName) serverNames.get(0)).getAsciiName()); + // Endpoint identification (hostname verification) is JNDI's job, not the factory's. assertNull(socket.getSSLParameters().getEndpointIdentificationAlgorithm()); } @Test - public void configureSocket_skipsSnForIpAddress() throws Exception { + public void configureSocket_skipsSniForIpAddress() throws Exception { SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); - SNISettingTLSSocketFactory.configure("192.168.1.1", true); + SNISettingTLSSocketFactory.configure("192.168.1.1"); factory.configureSocket(socket); assertNull(socket.getSSLParameters().getServerNames()); - assertEquals("LDAPS", socket.getSSLParameters().getEndpointIdentificationAlgorithm()); } @Test @@ -88,7 +75,7 @@ public void configureSocket_skipsWhenNoHostname() throws Exception { @Test(expected = IllegalArgumentException.class) public void configureSocket_throwsOnInvalidHostname() throws Exception { SSLSocket socket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); - SNISettingTLSSocketFactory.configure("invalid..hostname", true); + SNISettingTLSSocketFactory.configure("invalid..hostname"); factory.configureSocket(socket); } @@ -96,7 +83,7 @@ public void configureSocket_throwsOnInvalidHostname() throws Exception { @Test public void configureSocket_passesThroughNonSslSocket() { Socket socket = new Socket(); - SNISettingTLSSocketFactory.configure("example.com", true); + SNISettingTLSSocketFactory.configure("example.com"); Socket result = factory.configureSocket(socket); @@ -127,7 +114,7 @@ public void getSupportedCipherSuites_delegatesToDelegate() throws Exception { public void createSocket_wrappingSocket_configuresSni() throws Exception { SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); - SNISettingTLSSocketFactory.configure("example.com", false); + SNISettingTLSSocketFactory.configure("example.com"); Socket result = f.createSocket(new Socket(), "example.com", 636, true); @@ -139,7 +126,7 @@ public void createSocket_wrappingSocket_configuresSni() throws Exception { public void createSocket_stringHost_configuresSni() throws Exception { SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); - SNISettingTLSSocketFactory.configure("example.com", false); + SNISettingTLSSocketFactory.configure("example.com"); Socket result = f.createSocket("example.com", 636); @@ -151,7 +138,7 @@ public void createSocket_stringHost_configuresSni() throws Exception { public void createSocket_stringHostWithLocalAddress_configuresSni() throws Exception { SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); - SNISettingTLSSocketFactory.configure("example.com", false); + SNISettingTLSSocketFactory.configure("example.com"); Socket result = f.createSocket("example.com", 636, InetAddress.getLoopbackAddress(), 0); @@ -163,7 +150,7 @@ public void createSocket_stringHostWithLocalAddress_configuresSni() throws Excep public void createSocket_inetAddress_configuresSni() throws Exception { SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); - SNISettingTLSSocketFactory.configure("example.com", false); + SNISettingTLSSocketFactory.configure("example.com"); Socket result = f.createSocket(InetAddress.getLoopbackAddress(), 636); @@ -175,7 +162,7 @@ public void createSocket_inetAddress_configuresSni() throws Exception { public void createSocket_inetAddressWithLocalAddress_configuresSni() throws Exception { SSLSocket sslSocket = (SSLSocket) SSLContext.getDefault().getSocketFactory().createSocket(); SNISettingTLSSocketFactory f = new SNISettingTLSSocketFactory(stubDelegate(sslSocket)); - SNISettingTLSSocketFactory.configure("example.com", false); + SNISettingTLSSocketFactory.configure("example.com"); Socket result = f.createSocket(InetAddress.getLoopbackAddress(), 636, InetAddress.getLoopbackAddress(), 0); diff --git a/src/test/java/org/opensearch/security/auth/ldap2/SniAwareConnectionTest.java b/src/test/java/org/opensearch/security/auth/ldap2/SniAwareConnectionTest.java new file mode 100644 index 0000000000..d5bd3fb3ac --- /dev/null +++ b/src/test/java/org/opensearch/security/auth/ldap2/SniAwareConnectionTest.java @@ -0,0 +1,260 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.auth.ldap2; + +import org.junit.After; +import org.junit.Test; + +import org.ldaptive.BindRequest; +import org.ldaptive.Connection; +import org.ldaptive.ConnectionConfig; +import org.ldaptive.LdapException; +import org.ldaptive.Response; +import org.ldaptive.ResultCode; +import org.ldaptive.control.RequestControl; +import org.ldaptive.provider.ProviderConnection; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class SniAwareConnectionTest { + + private static final String HOST = "example.com"; + + @After + public void clearThreadLocal() { + SNISettingTLSSocketFactory.clearContext(); + } + + // --- socket-creating calls: SNI context is live during the delegate call and cleared afterwards --- + + @Test + public void open_setsSniDuringCall_returnsDelegateResponse_clearsAfter() throws LdapException { + RecordingConnection delegate = new RecordingConnection(); + SniAwareConnection connection = new SniAwareConnection(delegate, HOST); + + // Before open(), no context is set — the socket isn't created yet. + assertNull(SNISettingTLSSocketFactory.getHostname()); + + Response response = connection.open(); + + assertEquals(HOST, delegate.hostnameAtOpen); // hostname was live during the delegate's open() + assertSame(delegate.response, response); // delegate's response is passed straight through + assertNull(SNISettingTLSSocketFactory.getHostname()); // and cleared afterwards (no ThreadLocal leak) + } + + @Test + public void openWithBindRequest_setsSniDuringCall_forwardsRequest_clearsAfter() throws LdapException { + RecordingConnection delegate = new RecordingConnection(); + SniAwareConnection connection = new SniAwareConnection(delegate, HOST); + BindRequest request = new BindRequest(); + + Response response = connection.open(request); + + assertEquals(HOST, delegate.hostnameAtOpen); + assertSame(request, delegate.bindRequest); + assertSame(delegate.response, response); + assertNull(SNISettingTLSSocketFactory.getHostname()); + } + + @Test + public void reopen_setsSniDuringCall_returnsDelegateResponse_clearsAfter() throws LdapException { + RecordingConnection delegate = new RecordingConnection(); + SniAwareConnection connection = new SniAwareConnection(delegate, HOST); + + Response response = connection.reopen(); + + assertEquals(HOST, delegate.hostnameAtOpen); + assertSame(delegate.response, response); + assertNull(SNISettingTLSSocketFactory.getHostname()); + } + + @Test + public void reopenWithBindRequest_setsSniDuringCall_forwardsRequest_clearsAfter() throws LdapException { + RecordingConnection delegate = new RecordingConnection(); + SniAwareConnection connection = new SniAwareConnection(delegate, HOST); + BindRequest request = new BindRequest(); + + Response response = connection.reopen(request); + + assertEquals(HOST, delegate.hostnameAtOpen); + assertSame(request, delegate.bindRequest); + assertSame(delegate.response, response); + assertNull(SNISettingTLSSocketFactory.getHostname()); + } + + // --- socket-creating calls clear the SNI context even when the delegate fails --- + + @Test + public void open_clearsSniContext_whenDelegateThrows() { + assertSniContextClearedOnFailure(SniAwareConnection::open); + } + + @Test + public void openWithBindRequest_clearsSniContext_whenDelegateThrows() { + assertSniContextClearedOnFailure(connection -> connection.open(new BindRequest())); + } + + @Test + public void reopen_clearsSniContext_whenDelegateThrows() { + assertSniContextClearedOnFailure(SniAwareConnection::reopen); + } + + @Test + public void reopenWithBindRequest_clearsSniContext_whenDelegateThrows() { + assertSniContextClearedOnFailure(connection -> connection.reopen(new BindRequest())); + } + + /** + * Invokes a socket-creating call whose delegate throws, and asserts the failure propagates while + * the SNI context was live during the call and is still cleared afterwards (try-with-resources). + */ + private void assertSniContextClearedOnFailure(SocketCall call) { + RecordingConnection delegate = new RecordingConnection(); + delegate.failure = new LdapException("simulated open failure"); + SniAwareConnection connection = new SniAwareConnection(delegate, HOST); + + LdapException thrown = assertThrows(LdapException.class, () -> call.invoke(connection)); + + assertSame(delegate.failure, thrown); // the delegate's exception propagates unchanged + assertEquals(HOST, delegate.hostnameAtOpen); // context was live when the delegate ran + assertNull(SNISettingTLSSocketFactory.getHostname()); // and still cleared despite the failure + } + + @FunctionalInterface + private interface SocketCall { + void invoke(SniAwareConnection connection) throws LdapException; + } + + // --- pass-through methods: no SNI context, plain delegation --- + + @Test + public void hostname_returnsConfiguredHostname() { + assertEquals(HOST, new SniAwareConnection(new RecordingConnection(), HOST).hostname()); + } + + @Test + public void getConnectionConfig_delegates() { + RecordingConnection delegate = new RecordingConnection(); + assertSame(delegate.connectionConfig, new SniAwareConnection(delegate, HOST).getConnectionConfig()); + } + + @Test + public void isOpen_delegates() { + RecordingConnection delegate = new RecordingConnection(); + SniAwareConnection connection = new SniAwareConnection(delegate, HOST); + + delegate.open = true; + assertTrue(connection.isOpen()); + + delegate.open = false; + assertFalse(connection.isOpen()); + } + + @Test + public void getProviderConnection_delegates() { + RecordingConnection delegate = new RecordingConnection(); + SniAwareConnection connection = new SniAwareConnection(delegate, HOST); + + assertNull(connection.getProviderConnection()); + assertTrue(delegate.getProviderConnectionCalled); + } + + @Test + public void close_delegates() { + RecordingConnection delegate = new RecordingConnection(); + new SniAwareConnection(delegate, HOST).close(); + assertEquals(1, delegate.closeCalls); + } + + @Test + public void closeWithControls_delegates() { + RecordingConnection delegate = new RecordingConnection(); + RequestControl[] controls = new RequestControl[0]; + + new SniAwareConnection(delegate, HOST).close(controls); + + assertSame(controls, delegate.closeControls); + } + + /** + * Recording ldaptive {@link Connection} double: captures the SNI hostname observed during + * open()/reopen() and records the arguments/invocations of the pass-through methods. + */ + private static final class RecordingConnection implements Connection { + final Response response = new Response<>(null, ResultCode.SUCCESS); + final ConnectionConfig connectionConfig = new ConnectionConfig("ldaps://example.com:636"); + String hostnameAtOpen; + BindRequest bindRequest; + boolean open; + boolean getProviderConnectionCalled; + int closeCalls; + RequestControl[] closeControls; + LdapException failure; + + @Override + public Response open() throws LdapException { + hostnameAtOpen = SNISettingTLSSocketFactory.getHostname(); + if (failure != null) { + throw failure; + } + return response; + } + + @Override + public Response open(BindRequest request) throws LdapException { + bindRequest = request; + return open(); + } + + @Override + public Response reopen() throws LdapException { + return open(); + } + + @Override + public Response reopen(BindRequest request) throws LdapException { + bindRequest = request; + return open(); + } + + @Override + public ConnectionConfig getConnectionConfig() { + return connectionConfig; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public ProviderConnection getProviderConnection() { + getProviderConnectionCalled = true; + return null; + } + + @Override + public void close() { + closeCalls++; + } + + @Override + public void close(RequestControl[] controls) { + closeControls = controls; + } + } +} diff --git a/src/test/java/org/opensearch/security/securityconf/impl/v7/ConfigV7Test.java b/src/test/java/org/opensearch/security/securityconf/impl/v7/ConfigV7Test.java index cf044d5bc8..4dacdc6ccc 100644 --- a/src/test/java/org/opensearch/security/securityconf/impl/v7/ConfigV7Test.java +++ b/src/test/java/org/opensearch/security/securityconf/impl/v7/ConfigV7Test.java @@ -27,6 +27,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; +import static org.hamcrest.core.StringContains.containsString; @RunWith(Parameterized.class) public class ConfigV7Test { diff --git a/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java b/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java index a93343e261..996e120891 100644 --- a/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java +++ b/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java @@ -65,8 +65,6 @@ import org.opensearch.node.PluginAwareNode; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.FipsMode; -import org.opensearch.security.support.ConfigConstants; -import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.AbstractSecurityUnitTest; import org.opensearch.security.test.NodeSettingsSupplier; import org.opensearch.security.test.SingleClusterTest; From 528c028720bed2e75b51580997c79bbfdcc000bf Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Thu, 16 Jul 2026 17:30:50 +0200 Subject: [PATCH 8/9] Enforce password-length floor, FIPS-approved RNG, and green the integ suites - Reject/auto-inject SECURITY_RESTAPI_PASSWORD_MIN_LENGTH below the 14-char PBKDF2 floor - Use Randomness.createSecure() for OBO, api-token, and user-password RNG; FIPS-length generated passwords - Validate encryption_key key material (>=32 bytes) with a precise error message - TlsTests: use the FIPS-approved CBC suite and pin the server to TLSv1.2 - Adapt password-rule and OBO integration tests to the FIPS password floor Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad --- .../org/opensearch/security/TlsTests.java | 10 +- .../security/api/CreateResetPasswordTest.java | 2 +- ...xpPasswordRulesRestApiIntegrationTest.java | 2 +- .../InternalUsersRestApiIntegrationTest.java | 32 +++- ...edPasswordRulesRestApiIntegrationTest.java | 9 +- .../http/OnBehalfOfJwtAuthenticationTest.java | 10 +- .../security/OpenSearchSecurityPlugin.java | 16 ++ .../action/apitokens/ApiTokenRepository.java | 3 +- .../jwt/EncryptionDecryptionUtil.java | 8 +- .../rest/validation/PasswordValidator.java | 11 ++ .../opensearch/security/user/UserService.java | 27 ++-- .../AdvancedSecurityMigrationTests.java | 17 +- .../InitializationIntegrationTests.java | 3 + ...earchSecurityPluginFIPSValidationTest.java | 33 ++++ .../security/UserServiceUnitTests.java | 80 +++------- .../RestApiComplianceAuditlogTest.java | 3 + .../jwt/EncryptionDecryptionUtilsTest.java | 2 +- .../security/ccstest/RemoteReindexTests.java | 6 +- .../filter/SecurityRestFilterTests.java | 4 +- .../security/httpclient/HttpClientTest.java | 15 +- .../identity/SecurityTokenManagerTest.java | 13 +- .../security/ssl/CertificatesRule.java | 4 +- .../org/opensearch/security/ssl/SSLTest.java | 5 +- .../config/PemSslCertificatesLoaderTest.java | 4 +- .../ssl/util/SSLConfigConstantsTest.java | 11 +- .../test/AbstractSecurityUnitTest.java | 9 +- .../test/helper/cluster/ClusterHelper.java | 11 +- .../security/test/helper/file/FileHelper.java | 12 +- .../test/helper/file/FipsHashAdapter.java | 149 ++++++++++++++++++ .../security/test/helper/rest/RestHelper.java | 6 +- 30 files changed, 396 insertions(+), 121 deletions(-) create mode 100644 src/test/java/org/opensearch/security/test/helper/file/FipsHashAdapter.java diff --git a/src/integrationTest/java/org/opensearch/security/TlsTests.java b/src/integrationTest/java/org/opensearch/security/TlsTests.java index 8c5f4cc0eb..b4be3d2ce1 100644 --- a/src/integrationTest/java/org/opensearch/security/TlsTests.java +++ b/src/integrationTest/java/org/opensearch/security/TlsTests.java @@ -38,6 +38,7 @@ import static org.hamcrest.Matchers.instanceOf; import static org.opensearch.security.auditlog.AuditLog.Origin.REST; import static org.opensearch.security.ssl.util.SSLConfigConstants.SECURITY_SSL_HTTP_ENABLED_CIPHERS; +import static org.opensearch.security.ssl.util.SSLConfigConstants.SECURITY_SSL_HTTP_ENABLED_PROTOCOLS; import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS; import static org.opensearch.test.framework.audit.AuditMessagePredicate.auditPredicate; @@ -55,7 +56,14 @@ public class TlsTests { @ClassRule public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.THREE_CLUSTER_MANAGERS) .anonymousAuth(false) - .nodeSettings(Map.of(SECURITY_SSL_HTTP_ENABLED_CIPHERS, List.of(SUPPORTED_CIPHER_SUIT))) + .nodeSettings( + Map.of( + SECURITY_SSL_HTTP_ENABLED_CIPHERS, + List.of(SUPPORTED_CIPHER_SUIT), + SECURITY_SSL_HTTP_ENABLED_PROTOCOLS, + List.of("TLSv1.2") + ) + ) .authc(AUTHC_HTTPBASIC_INTERNAL) .users(USER_ADMIN) .audit( diff --git a/src/integrationTest/java/org/opensearch/security/api/CreateResetPasswordTest.java b/src/integrationTest/java/org/opensearch/security/api/CreateResetPasswordTest.java index 643a6d0bee..235d99d1ac 100644 --- a/src/integrationTest/java/org/opensearch/security/api/CreateResetPasswordTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/CreateResetPasswordTest.java @@ -40,7 +40,7 @@ public class CreateResetPasswordTest { public static final String INVALID_PASSWORD_REGEX = "user 1 fair password"; - public static final String VALID_WEAK_PASSWORD = "Asdfghjkl1!"; + public static final String VALID_WEAK_PASSWORD = "Password1234!!"; public static final String VALID_SIMILAR_PASSWORD = "456Additional00001_1234!"; diff --git a/src/integrationTest/java/org/opensearch/security/api/InternalUsersRegExpPasswordRulesRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/InternalUsersRegExpPasswordRulesRestApiIntegrationTest.java index 9bfad5aab4..bd7294d391 100644 --- a/src/integrationTest/java/org/opensearch/security/api/InternalUsersRegExpPasswordRulesRestApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/InternalUsersRegExpPasswordRulesRestApiIntegrationTest.java @@ -80,7 +80,7 @@ public void canNotCreateUsersWithPassword() throws Exception { internalUsers(), patch( addOp("testuser1", internalUserWithPassword("$aA1234567890ab")), - addOp("testuser2", internalUserWithPassword("testpassword2")) + addOp("testuser2", internalUserWithPassword("testpassword23")) ) ); assertThat(r, isBadRequest("/reason", PASSWORD_VALIDATION_ERROR_MESSAGE)); diff --git a/src/integrationTest/java/org/opensearch/security/api/InternalUsersRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/InternalUsersRestApiIntegrationTest.java index a580328b67..b612b72832 100644 --- a/src/integrationTest/java/org/opensearch/security/api/InternalUsersRestApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/InternalUsersRestApiIntegrationTest.java @@ -25,6 +25,7 @@ import com.google.common.collect.ImmutableMap; import org.apache.http.HttpStatus; import org.junit.Assert; +import org.junit.Assume; import org.junit.ClassRule; import org.junit.Test; @@ -33,6 +34,7 @@ import org.opensearch.core.xcontent.ToXContentObject; import org.opensearch.security.DefaultObjectMapper; import org.opensearch.security.dlic.rest.api.Endpoint; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.cluster.LocalCluster; import org.opensearch.test.framework.cluster.TestRestClient; @@ -129,6 +131,26 @@ public void availableForRESTAdminUser() throws Exception { super.availableForRESTAdminUser(localCluster); } + @Test + public void changingPasswordBelowFipsFloorIsRejected() throws Exception { + Assume.assumeTrue("FIPS password floor only applies under FIPS", FipsMode.isEnabled()); + try (TestRestClient client = localCluster.getAdminCertRestClient()) { + final var username = randomAsciiAlphanumOfLength(10); + + // 1. Create the user with a password that clears the 14-char FIPS floor. + assertThat( + client.putJson(apiPath(username), internalUserWithPassword(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH))), + isCreated() + ); + + // 2. Change the password to one below the floor -> clean 400, not a hang. + assertThat( + client.putJson(apiPath(username), internalUserWithPassword(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH - 1))), + isBadRequest() + ); + } + } + static ToXContentObject internalUserWithPassword(final String password) { return internalUser(null, null, null, password, null, null, null); } @@ -811,11 +833,17 @@ public void restrictedUsernameContents() throws Exception { URLEncoder.encode(randomAsciiAlphanumOfLength(4) + ":" + randomAsciiAlphanumOfLength(3), StandardCharsets.UTF_8) )) { assertThat( - client.putJson(apiPath(username), internalUserWithPassword(randomAsciiAlphanumOfLength(10))), + client.putJson( + apiPath(username), + internalUserWithPassword(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH)) + ), isBadRequest("/message", restrictedTerm) ); assertThat( - client.patch(apiPath(), patch(addOp(username, internalUserWithPassword(randomAsciiAlphanumOfLength(10))))), + client.patch( + apiPath(), + patch(addOp(username, internalUserWithPassword(randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH)))) + ), isBadRequest("/message", restrictedTerm) ); } diff --git a/src/integrationTest/java/org/opensearch/security/api/InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest.java index dc95c04118..33d2e04678 100644 --- a/src/integrationTest/java/org/opensearch/security/api/InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest.java @@ -32,7 +32,10 @@ public class InternalUsersScoreBasedPasswordRulesRestApiIntegrationTest extends AbstractApiIntegrationTest { @ClassRule - public static LocalCluster localCluster = clusterBuilder().nodeSetting(ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH, 9).build(); + public static LocalCluster localCluster = clusterBuilder().nodeSetting( + ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH, + FIPS_MIN_PASSWORD_LENGTH + ).build(); String internalUsers(String... path) { final var fullPath = new StringJoiner("/").add(super.apiPath("internalusers")); @@ -53,14 +56,14 @@ ToXContentObject internalUserWithPassword(final String password) { @Test public void canNotCreateUsersWithPassword() throws Exception { try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { - final var r1 = client.putJson(internalUsers("admin"), internalUserWithPassword("password89")); + final var r1 = client.putJson(internalUsers("admin"), internalUserWithPassword("password123456789")); assertThat(r1, isBadRequest()); assertThat( r1.getTextFromJsonBody("/reason"), org.hamcrest.Matchers.containsString(RequestContentValidator.ValidationError.WEAK_PASSWORD.message()) ); - final var r2 = client.putJson(internalUsers("admin"), internalUserWithPassword("A123456789")); + final var r2 = client.putJson(internalUsers("admin"), internalUserWithPassword("ABCDE123456789")); assertThat(r2, isBadRequest()); assertThat( r2.getTextFromJsonBody("/reason"), diff --git a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java index eb7bfeb9a5..85257f1858 100644 --- a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java +++ b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; import java.util.Arrays; import java.util.Base64; import java.util.Map; @@ -72,7 +73,14 @@ public class OnBehalfOfJwtAuthenticationTest { "alternativeSigningKeyalternativeSigningKeyalternativeSigningKeyalternativeSigningKey".getBytes(StandardCharsets.UTF_8) ); - private static final String encryptionKey = Base64.getEncoder().encodeToString("encryptionKey!!".getBytes(StandardCharsets.UTF_8)); + private static final String encryptionKey = fipsCompatibleEncryptionKey(); + + private static String fipsCompatibleEncryptionKey() { + final byte[] keyMaterial = new byte[32]; + new SecureRandom().nextBytes(keyMaterial); + return Base64.getEncoder().encodeToString(keyMaterial); + } + public static final String ADMIN_USER_NAME = "admin"; public static final String OBO_USER_NAME_WITH_PERM = "obo_user"; public static final String OBO_USER_NAME_NO_PERM = "obo_user_no_perm"; diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 44676562a6..6eb74e0ad1 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -393,6 +393,19 @@ static void validateFipsMode(final Settings settings, final boolean isFipsMode, ); } + // A configured minimum below that floor would let the API accept passwords the hasher then refuses at runtime. + final int minPasswordLength = settings.getAsInt(ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH, -1); + if (minPasswordLength >= 0 && minPasswordLength < PasswordValidator.FIPS_MIN_PASSWORD_LENGTH) { + violations.add( + "Password minimum length '%s' is set to %d, but FIPS mode requires at least %d characters because shorter passwords cannot be hashed with PBKDF2." + .formatted( + ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH, + minPasswordLength, + PasswordValidator.FIPS_MIN_PASSWORD_LENGTH + ) + ); + } + if (!violations.isEmpty()) { throw new IllegalStateException( "FIPS mode is enabled (OPENSEARCH_FIPS_MODE=true) but the following configuration issues were found:\n- " @@ -1474,6 +1487,9 @@ public Settings additionalSettings() { if (!SSLConfig.isSslOnlyMode()) { builder.put(NetworkModule.TRANSPORT_TYPE_KEY, "org.opensearch.security.ssl.http.netty.SecuritySSLNettyTransport"); builder.put(NetworkModule.HTTP_TYPE_KEY, "org.opensearch.security.http.SecurityHttpServerTransport"); + if (FipsMode.isEnabled() && !settings.hasValue(ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH)) { + builder.put(ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH, PasswordValidator.FIPS_MIN_PASSWORD_LENGTH); + } } return builder.build(); } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index 52604f4675..b5493e8950 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -30,6 +30,7 @@ import org.opensearch.ExceptionsHelper; import org.opensearch.OpenSearchSecurityException; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.Randomness; import org.opensearch.core.action.ActionListener; import org.opensearch.index.IndexNotFoundException; import org.opensearch.security.configuration.TokenListener; @@ -41,7 +42,7 @@ public class ApiTokenRepository { public static final String TOKEN_PREFIX = "os_"; - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final SecureRandom SECURE_RANDOM = Randomness.createSecure(); private final ApiTokenIndexHandler apiTokenIndexHandler; private final List tokenListener = new ArrayList<>(); diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java index 26baeb9bea..aafb626ed8 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java @@ -24,6 +24,7 @@ import org.bouncycastle.crypto.KDFCalculator; import org.bouncycastle.crypto.fips.FipsKDF; +import org.opensearch.common.Randomness; import org.opensearch.common.settings.Settings; import org.opensearch.security.support.FipsMode; import org.opensearch.security.util.KeyUtils; @@ -43,7 +44,7 @@ public class EncryptionDecryptionUtil { private static final int MINIMUM_IKM_BYTES = AES_KEY_LENGTH_BYTES; private final SecretKey aesKey; - private final SecureRandom secureRandom = new SecureRandom(); + private final SecureRandom secureRandom = Randomness.createSecure(); /** * Resolves the encryption key from {@code } in the given settings, supporting either a keystore @@ -110,7 +111,10 @@ public String decrypt(final String encryptedString) { private static SecretKey deriveKey(final byte[] secretBytes) { if (FipsMode.isEnabled() && secretBytes.length < MINIMUM_IKM_BYTES) { throw new IllegalArgumentException( - "Configured encryption_key is not strong enough for FIPS mode. Please configure an encryption_key with more key material." + "Configured encryption_key decodes to %d bytes of key material, but FIPS mode requires at least %d bytes.".formatted( + secretBytes.length, + MINIMUM_IKM_BYTES + ) ); } diff --git a/src/main/java/org/opensearch/security/dlic/rest/validation/PasswordValidator.java b/src/main/java/org/opensearch/security/dlic/rest/validation/PasswordValidator.java index ebb3a4e88b..ecd3eacf6e 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/validation/PasswordValidator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/validation/PasswordValidator.java @@ -37,6 +37,17 @@ public class PasswordValidator { private static final int MAX_LENGTH = 100; + /** + * Minimum password length required under FIPS mode, {@value} characters. + * + *

    BC FIPS derives PBKDF2 keys from the password and rejects key material below 112 bits — fewer than + * 14 ASCII characters — throwing a {@link org.bouncycastle.crypto.fips.FipsUnapprovedOperationError} at + * hashing time. To avoid that, under FIPS the node raises the configurable + * {@value org.opensearch.security.support.ConfigConstants#SECURITY_RESTAPI_PASSWORD_MIN_LENGTH} to at least + * this value (see {@link org.opensearch.security.OpenSearchSecurityPlugin#additionalSettings}). + */ + public static final int FIPS_MIN_PASSWORD_LENGTH = 14; + /** * Checks a username similarity and a password * names and passwords like: diff --git a/src/main/java/org/opensearch/security/user/UserService.java b/src/main/java/org/opensearch/security/user/UserService.java index 778fcb0ae2..7110bfebdc 100644 --- a/src/main/java/org/opensearch/security/user/UserService.java +++ b/src/main/java/org/opensearch/security/user/UserService.java @@ -29,12 +29,12 @@ import com.google.common.collect.ImmutableList; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.bouncycastle.crypto.fips.FipsDRBG; import org.opensearch.ExceptionsHelper; import org.opensearch.action.index.IndexRequest; import org.opensearch.action.support.WriteRequest; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.Randomness; import org.opensearch.common.inject.Inject; import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentHelper; @@ -50,6 +50,7 @@ import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.InternalUserV7; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.support.SecurityJsonNode; import org.opensearch.transport.client.Client; @@ -241,25 +242,17 @@ private void verifyServiceAccount(SecurityJsonNode securityJsonNode, String acco private static final String ALL_CHARS = LOWERCASE + UPPERCASE + DIGITS; /** - * Generate a 20 - 27 character password with 1+ lowercase, 1+ uppercase, 1+ digit. - * Uses a FIPS SP 800-90A HMAC-SHA-256 DRBG; the returned {@code char[]} must be zeroed by the caller after use. + * Generate a random password with 1+ lowercase, 1+ uppercase, 1+ digit. Under FIPS the length is 20-27 + * characters (≥119 bits over the 62-char alphabet, exceeding the FIPS 112-bit requirement); otherwise 8-15. + * Uses {@link Randomness#createSecure()}, which resolves to the FIPS-approved SP 800-90A DRBG when FIPS is + * enabled. The returned {@code char[]} must be zeroed by the caller after use. * - * @return A password for a service account exceeding the FIPS 112-bit entropy requirement. + * @return A generated service-account password. */ public static char[] generatePassword() { - SecureRandom seed = new SecureRandom(); - SecureRandom drbg = FipsDRBG.SHA256_HMAC // - .fromEntropySource(seed, false) // - .build(seed.generateSeed(32), false); - return generatePassword(drbg); - } - - /** - * Generate a password using the supplied {@link SecureRandom}. - * Exposed so callers can inject a seeded instance (e.g. in tests). - */ - public static char[] generatePassword(SecureRandom random) { - int length = random.nextInt(8) + 20; // 20-27 chars → ≥119 bits with 62-char alphabet + final SecureRandom random = Randomness.createSecure(); + final int base = FipsMode.isEnabled() ? 20 : 8; + int length = random.nextInt(8) + base; // FIPS: 20-27 chars (≥119 bits); otherwise: 8-15 chars char[] password = new char[length]; // Guarantee at least one of each required type diff --git a/src/test/java/org/opensearch/security/AdvancedSecurityMigrationTests.java b/src/test/java/org/opensearch/security/AdvancedSecurityMigrationTests.java index 99782324dc..ff59cf8837 100644 --- a/src/test/java/org/opensearch/security/AdvancedSecurityMigrationTests.java +++ b/src/test/java/org/opensearch/security/AdvancedSecurityMigrationTests.java @@ -12,19 +12,24 @@ package org.opensearch.security; import java.io.File; +import java.nio.file.Files; import java.util.Arrays; +import java.util.Objects; import org.apache.hc.core5.http.Header; import org.apache.http.HttpStatus; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.opensearch.common.settings.Settings; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.test.SingleClusterTest; import org.opensearch.security.test.helper.cluster.ClusterConfiguration; import org.opensearch.security.test.helper.cluster.ClusterHelper; +import org.opensearch.security.test.helper.file.FipsHashAdapter; import org.opensearch.security.test.helper.rest.RestHelper; import static org.hamcrest.MatcherAssert.assertThat; @@ -32,9 +37,17 @@ public class AdvancedSecurityMigrationTests extends SingleClusterTest { + @Rule + public TemporaryFolder configDirectory = new TemporaryFolder(); + @Before - public void setupBefore() { - ClusterHelper.updateDefaultDirectory(new File(TEST_RESOURCE_RELATIVE_PATH + "security_passive").getAbsolutePath()); + public void setupBefore() throws Exception { + final File source = new File(TEST_RESOURCE_RELATIVE_PATH + "security_passive"); + for (final File yml : Objects.requireNonNull(source.listFiles((dir, name) -> name.endsWith(".yml")))) { + Files.copy(yml.toPath(), configDirectory.getRoot().toPath().resolve(yml.getName())); + } + FipsHashAdapter.adaptConfigFile(new File(configDirectory.getRoot(), "internal_users.yml")); + ClusterHelper.updateDefaultDirectory(configDirectory.getRoot().getAbsolutePath()); } @After diff --git a/src/test/java/org/opensearch/security/InitializationIntegrationTests.java b/src/test/java/org/opensearch/security/InitializationIntegrationTests.java index 76ccf94c92..a9434982d6 100644 --- a/src/test/java/org/opensearch/security/InitializationIntegrationTests.java +++ b/src/test/java/org/opensearch/security/InitializationIntegrationTests.java @@ -35,6 +35,7 @@ import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.http.HttpStatus; import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; import org.opensearch.action.admin.cluster.health.ClusterHealthRequest; @@ -52,6 +53,7 @@ import org.opensearch.security.action.configupdate.ConfigUpdateResponse; import org.opensearch.security.ssl.util.SSLConfigConstants; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.DynamicSecurityConfig; import org.opensearch.security.test.SingleClusterTest; import org.opensearch.security.test.helper.cluster.ClusterHelper; @@ -282,6 +284,7 @@ public void testConfigHotReload() throws Exception { @Test public void testDefaultConfig() throws Exception { + Assume.assumeFalse("Shipped default config is not FIPS-compatible", FipsMode.isEnabled()); final Settings settings = Settings.builder().put(ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, true).build(); setup(Settings.EMPTY, null, settings, false); RestHelper rh = nonSslRestHelper(); diff --git a/src/test/java/org/opensearch/security/OpenSearchSecurityPluginFIPSValidationTest.java b/src/test/java/org/opensearch/security/OpenSearchSecurityPluginFIPSValidationTest.java index 65f8073d82..a530eab695 100644 --- a/src/test/java/org/opensearch/security/OpenSearchSecurityPluginFIPSValidationTest.java +++ b/src/test/java/org/opensearch/security/OpenSearchSecurityPluginFIPSValidationTest.java @@ -83,6 +83,39 @@ public void testFipsModeDisabledAllowsAnyAlgorithm() { OpenSearchSecurityPlugin.validateFipsMode(settings, false, () -> false); } + @Test + public void testFipsModeWithPasswordMinLengthBelowFloorThrows() { + Settings settings = Settings.builder() + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "pbkdf2") + .put(ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH, 8) + .build(); + + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> true) + ); + assertThat(ex.getMessage(), containsString("FIPS mode requires at least 14 characters")); + } + + @Test + public void testFipsModeWithPasswordMinLengthAtFloorSucceeds() { + Settings settings = Settings.builder() + .put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "pbkdf2") + .put(ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH, 14) + .build(); + + // At the floor is acceptable + OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> true); + } + + @Test + public void testFipsModeWithPasswordMinLengthUnsetSucceeds() { + // Unset (-1) is fine: additionalSettings() defaults it to the FIPS floor at boot. + Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "pbkdf2").build(); + + OpenSearchSecurityPlugin.validateFipsMode(settings, true, () -> true); + } + @Test public void testFipsModeEnabledWithoutApprovedOnlyModeThrows() { Settings settings = Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, "pbkdf2").build(); diff --git a/src/test/java/org/opensearch/security/UserServiceUnitTests.java b/src/test/java/org/opensearch/security/UserServiceUnitTests.java index 9157539cbf..dcd1fb0d28 100644 --- a/src/test/java/org/opensearch/security/UserServiceUnitTests.java +++ b/src/test/java/org/opensearch/security/UserServiceUnitTests.java @@ -14,25 +14,23 @@ import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.security.SecureRandom; -import java.util.Arrays; -import java.util.Base64; import java.util.Optional; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.junit.Assume; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; -import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.hasher.PasswordHasher; import org.opensearch.security.hasher.PasswordHasherFactory; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.user.UserFilterType; import org.opensearch.security.user.UserService; import org.opensearch.transport.client.Client; @@ -44,7 +42,6 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; @@ -127,58 +124,21 @@ public void restrictedFromUsername() { @Test public void testGeneratedPasswordContents() { + final int min = FipsMode.isEnabled() ? 20 : 8; + final int max = FipsMode.isEnabled() ? 27 : 15; char[] password = UserService.generatePassword(); - try { - assertThat(password.length >= 20 && password.length <= 27, is(true)); - assertThat(new String(password).chars().anyMatch(Character::isLowerCase), is(true)); - assertThat(new String(password).chars().anyMatch(Character::isUpperCase), is(true)); - assertThat(new String(password).chars().anyMatch(Character::isDigit), is(true)); - - char[] password2 = UserService.generatePassword(); - try { - assertNotEquals(new String(password), new String(password2)); - } finally { - Arrays.fill(password2, '\0'); - } - } finally { - Arrays.fill(password, '\0'); - } - } + assertThat(password.length >= min && password.length <= max, is(true)); + assertThat(new String(password).chars().anyMatch(Character::isLowerCase), is(true)); + assertThat(new String(password).chars().anyMatch(Character::isUpperCase), is(true)); + assertThat(new String(password).chars().anyMatch(Character::isDigit), is(true)); - @Test - public void testGeneratedPasswordWithInjectedRandom() { - SecureRandom seededRandom = new SecureRandom(new byte[] { 1, 2, 3, 4 }); - char[] passwordChars = UserService.generatePassword(seededRandom); - String password = new String(passwordChars); - try { - assertThat(password.length() >= 20 && password.length() <= 27, is(true)); - assertThat(password.chars().anyMatch(Character::isLowerCase), is(true)); - assertThat(password.chars().anyMatch(Character::isUpperCase), is(true)); - assertThat(password.chars().anyMatch(Character::isDigit), is(true)); - - // password bytes serve as IKM for BC FIPS HKDF → AES-256 key derivation - byte[] passwordBytes = password.getBytes(StandardCharsets.UTF_8); - String base64Secret = Base64.getEncoder().encodeToString(passwordBytes); - Arrays.fill(passwordBytes, (byte) 0); - - EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(base64Secret); - String testData = "FIPS entropy test payload"; - assertEquals("AES round-trip failed with password-derived key", testData, util.decrypt(util.encrypt(testData))); - - SecureRandom seededRandom2 = new SecureRandom(new byte[] { 5, 6, 7, 8 }); - char[] password2Chars = UserService.generatePassword(seededRandom2); - try { - assertNotEquals(password, new String(password2Chars)); - } finally { - Arrays.fill(password2Chars, '\0'); - } - } finally { - Arrays.fill(passwordChars, '\0'); - } + char[] password2 = UserService.generatePassword(); + assertNotEquals(new String(password), new String(password2)); } @Test public void testGeneratedPasswordEntropyMeetsFipsRequirement() { + Assume.assumeTrue("Password entropy floor only applies under FIPS", FipsMode.isEnabled()); // FIPS 140-2/3 requires minimum 112 bits of entropy for cryptographic keys final double FIPS_MIN_ENTROPY_BITS = 112.0; final int CHARSET_SIZE = 62; // a-z (26) + A-Z (26) + 0-9 (10) @@ -193,17 +153,13 @@ public void testGeneratedPasswordEntropyMeetsFipsRequirement() { ); char[] password = UserService.generatePassword(); - try { - assertTrue("Generated password must be >= " + MIN_PASSWORD_LENGTH + " chars", password.length >= MIN_PASSWORD_LENGTH); - - double actualEntropy = entropyPerChar * password.length; - assertTrue( - String.format("Actual password entropy %.2f bits must be >= %.2f bits", actualEntropy, FIPS_MIN_ENTROPY_BITS), - actualEntropy >= FIPS_MIN_ENTROPY_BITS - ); - } finally { - Arrays.fill(password, '\0'); - } + assertTrue("Generated password must be >= " + MIN_PASSWORD_LENGTH + " chars", password.length >= MIN_PASSWORD_LENGTH); + + double actualEntropy = entropyPerChar * password.length; + assertTrue( + String.format("Actual password entropy %.2f bits must be >= %.2f bits", actualEntropy, FIPS_MIN_ENTROPY_BITS), + actualEntropy >= FIPS_MIN_ENTROPY_BITS + ); } } diff --git a/src/test/java/org/opensearch/security/auditlog/compliance/RestApiComplianceAuditlogTest.java b/src/test/java/org/opensearch/security/auditlog/compliance/RestApiComplianceAuditlogTest.java index 386f92b24e..1d1c6c25e3 100644 --- a/src/test/java/org/opensearch/security/auditlog/compliance/RestApiComplianceAuditlogTest.java +++ b/src/test/java/org/opensearch/security/auditlog/compliance/RestApiComplianceAuditlogTest.java @@ -16,6 +16,7 @@ import org.apache.http.HttpStatus; import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; import org.opensearch.common.settings.Settings; @@ -24,6 +25,7 @@ import org.opensearch.security.auditlog.impl.AuditMessage; import org.opensearch.security.auditlog.integration.TestAuditlogImpl; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.DynamicSecurityConfig; import org.opensearch.security.test.helper.cluster.ClusterConfiguration; import org.opensearch.security.test.helper.rest.RestHelper.HttpResponse; @@ -379,6 +381,7 @@ public void testPBKDF2HashRedaction() { @Test public void testArgon2HashRedaction() { + Assume.assumeFalse("Argon2 is not supported in FIPS mode", FipsMode.isEnabled()); final Settings settings = Settings.builder() .put("plugins.security.audit.type", TestAuditlogImpl.class.getName()) .put(ConfigConstants.SECURITY_RESTAPI_ROLES_ENABLED, "opendistro_security_all_access") diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java index 1e679408f8..9435b5321b 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java @@ -99,7 +99,7 @@ public void testFipsModeRejectsWeakEncryptionKey() { String weakSecret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); IllegalArgumentException ex = Assert.assertThrows(IllegalArgumentException.class, () -> new EncryptionDecryptionUtil(weakSecret)); - assertThat(ex.getMessage(), containsString("not strong enough for FIPS mode")); + assertThat(ex.getMessage(), containsString("decodes to 16 bytes of key material, but FIPS mode requires at least 32 bytes")); } @Test diff --git a/src/test/java/org/opensearch/security/ccstest/RemoteReindexTests.java b/src/test/java/org/opensearch/security/ccstest/RemoteReindexTests.java index 4d247f2c79..3ba42387ca 100644 --- a/src/test/java/org/opensearch/security/ccstest/RemoteReindexTests.java +++ b/src/test/java/org/opensearch/security/ccstest/RemoteReindexTests.java @@ -40,6 +40,7 @@ import org.opensearch.security.test.helper.cluster.ClusterConfiguration; import org.opensearch.security.test.helper.cluster.ClusterHelper; import org.opensearch.security.test.helper.cluster.ClusterInfo; +import org.opensearch.security.test.helper.file.FipsHashAdapter; import org.opensearch.security.test.helper.rest.RestHelper; import org.opensearch.security.test.helper.rest.RestHelper.HttpResponse; import org.opensearch.transport.client.Client; @@ -120,7 +121,10 @@ public void testNonSSLReindex() throws Exception { + cl2Info.httpPort + "\"," + "\"username\": \"nagilum\"," - + "\"password\": \"nagilum\"" + // Pad under FIPS so the remote credential matches cl2's adapted (PBKDF2) fixture hash. + + "\"password\": \"" + + FipsHashAdapter.adaptPassword("nagilum") + + "\"" + "}," + "\"index\": \"twitter\"," + "\"size\": 10" diff --git a/src/test/java/org/opensearch/security/filter/SecurityRestFilterTests.java b/src/test/java/org/opensearch/security/filter/SecurityRestFilterTests.java index 0cfcb2a105..0dd756a090 100644 --- a/src/test/java/org/opensearch/security/filter/SecurityRestFilterTests.java +++ b/src/test/java/org/opensearch/security/filter/SecurityRestFilterTests.java @@ -345,10 +345,10 @@ public void testHasPermissionCheckParam_AccessNotAllowedCase() throws Exception rh.keystore = "restapi/kirk-keystore"; rh.sendAdminCertificate = true; - String createUserBody = "{" + "\"password\": \"test-pass\"," + "\"backend_roles\": []" + "}"; + String createUserBody = "{" + "\"password\": \"test-pass-1234x\"," + "\"backend_roles\": []" + "}"; response = rh.executePutRequest("_plugins/_security/api/internalusers/test_user", createUserBody, adminCredsHeader); - Header testUserHeader = encodeBasicHeader("test_user", "test-pass"); + Header testUserHeader = encodeBasicHeader("test_user", "test-pass-1234x"); rh.sendAdminCertificate = false; // test_user has no permissions to GET /_cluster/health response accessAllowed:false diff --git a/src/test/java/org/opensearch/security/httpclient/HttpClientTest.java b/src/test/java/org/opensearch/security/httpclient/HttpClientTest.java index 59051e9a02..5a97260e73 100644 --- a/src/test/java/org/opensearch/security/httpclient/HttpClientTest.java +++ b/src/test/java/org/opensearch/security/httpclient/HttpClientTest.java @@ -19,9 +19,12 @@ import org.opensearch.security.test.DynamicSecurityConfig; import org.opensearch.security.test.SingleClusterTest; import org.opensearch.security.test.helper.file.FileHelper; +import org.opensearch.security.test.helper.file.FipsHashAdapter; public class HttpClientTest extends SingleClusterTest { + private static final String ADMIN_PASSWORD = FipsHashAdapter.adaptPassword("admin"); + @Override protected String getResourceFolder() { return "auditlog"; @@ -41,7 +44,7 @@ public void testPlainConnection() throws Exception { try ( final HttpClient httpClient = HttpClient.builder(clusterInfo.httpHost + ":" + clusterInfo.httpPort) - .setBasicCredentials("admin", "admin") + .setBasicCredentials("admin", ADMIN_PASSWORD) .build() ) { Assert.assertTrue(httpClient.index("{\"a\":5}", "index", "type", false)); @@ -49,7 +52,7 @@ public void testPlainConnection() throws Exception { Assert.assertTrue(httpClient.index("{\"a\":5}", "index", "type", true)); } - try (final HttpClient httpClient = HttpClient.builder("unknownhost:6654").setBasicCredentials("admin", "admin").build()) { + try (final HttpClient httpClient = HttpClient.builder("unknownhost:6654").setBasicCredentials("admin", ADMIN_PASSWORD).build()) { Assert.assertFalse(httpClient.index("{\"a\":5}", "index", "type", false)); Assert.assertFalse(httpClient.index("{\"a\":5}", "index", "type", true)); Assert.assertFalse(httpClient.index("{\"a\":5}", "index", "type", true)); @@ -58,7 +61,7 @@ public void testPlainConnection() throws Exception { try ( final HttpClient httpClient = HttpClient.builder("unknownhost:6654", clusterInfo.httpHost + ":" + clusterInfo.httpPort) .enableSsl(FileHelper.getKeystoreFromClassPath("auditlog/truststore", "changeit"), false) - .setBasicCredentials("admin", "admin") + .setBasicCredentials("admin", ADMIN_PASSWORD) .build() ) { Assert.assertFalse(httpClient.index("{\"a\":5}", "index", "type", false)); @@ -68,7 +71,7 @@ public void testPlainConnection() throws Exception { try ( final HttpClient httpClient = HttpClient.builder("unknownhost:6654", clusterInfo.httpHost + ":" + clusterInfo.httpPort) - .setBasicCredentials("admin", "admin") + .setBasicCredentials("admin", ADMIN_PASSWORD) .build() ) { Assert.assertTrue(httpClient.index("{\"a\":5}", "index", "type", false)); @@ -96,7 +99,7 @@ public void testSslConnection() throws Exception { try ( final HttpClient httpClient = HttpClient.builder(clusterInfo.httpHost + ":" + clusterInfo.httpPort) .enableSsl(FileHelper.getKeystoreFromClassPath("auditlog/truststore", "changeit"), false) - .setBasicCredentials("admin", "admin") + .setBasicCredentials("admin", ADMIN_PASSWORD) .build() ) { Assert.assertTrue(httpClient.index("{\"a\":5}", "index", "type", false)); @@ -106,7 +109,7 @@ public void testSslConnection() throws Exception { try ( final HttpClient httpClient = HttpClient.builder(clusterInfo.httpHost + ":" + clusterInfo.httpPort) - .setBasicCredentials("admin", "admin") + .setBasicCredentials("admin", ADMIN_PASSWORD) .build() ) { Assert.assertFalse(httpClient.index("{\"a\":5}", "index", "type", false)); diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index 6d089df02d..25924e6822 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; import java.util.Base64; import org.junit.After; @@ -106,7 +107,7 @@ private DynamicConfigModel createMockJwtVendorInTokenManager(boolean includeEncr final Settings settings = Settings.builder() .put("enabled", true) .put("signing_key", signingKeyB64Encoded) - .put("encryption_key", (includeEncryptionKey ? "1234567890" : null)) + .put("encryption_key", (includeEncryptionKey ? fipsCompatibleEncryptionKey() : null)) .build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); @@ -115,6 +116,16 @@ private DynamicConfigModel createMockJwtVendorInTokenManager(boolean includeEncr return dcm; } + /** + * Returns a base64-encoded key with 32 bytes of material, so the AES-256 key derived by + * {@code EncryptionDecryptionUtil} clears the BC FIPS IKM floor (>= 112 bits; 32 bytes for AES-256). + */ + private static String fipsCompatibleEncryptionKey() { + final byte[] keyMaterial = new byte[32]; + new SecureRandom().nextBytes(keyMaterial); + return Base64.getEncoder().encodeToString(keyMaterial); + } + @Test public void issueServiceAccountToken_error() throws Exception { final String expectedAccountName = "abc-123"; diff --git a/src/test/java/org/opensearch/security/ssl/CertificatesRule.java b/src/test/java/org/opensearch/security/ssl/CertificatesRule.java index 97855c1543..bb4010f14f 100644 --- a/src/test/java/org/opensearch/security/ssl/CertificatesRule.java +++ b/src/test/java/org/opensearch/security/ssl/CertificatesRule.java @@ -53,15 +53,13 @@ import org.opensearch.common.collect.Tuple; import org.opensearch.security.test.helper.file.FileHelper; +import static org.opensearch.security.dlic.rest.validation.PasswordValidator.FIPS_MIN_PASSWORD_LENGTH; import static org.opensearch.security.ssl.CertificatesUtils.privateKeyToPemObject; import static org.opensearch.security.ssl.CertificatesUtils.writePemContent; import static org.opensearch.security.ssl.util.SSLConfigConstants.DEFAULT_STORE_TYPE; public class CertificatesRule extends ExternalResource { - /** BC FIPS requires at least 14 characters for PKCS8 key-encryption passwords in approved-only mode. */ - public static final int FIPS_MIN_PASSWORD_LENGTH = 14; - private final TemporaryFolder temporaryFolder = new TemporaryFolder(); final static String DEFAULT_SUBJECT_NAME = "CN=some_access,OU=client,O=client,L=test,C=de"; diff --git a/src/test/java/org/opensearch/security/ssl/SSLTest.java b/src/test/java/org/opensearch/security/ssl/SSLTest.java index 30b436322f..88c074ee83 100644 --- a/src/test/java/org/opensearch/security/ssl/SSLTest.java +++ b/src/test/java/org/opensearch/security/ssl/SSLTest.java @@ -66,6 +66,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.opensearch.common.network.NetworkModule.TRANSPORT_SSL_ENFORCE_HOSTNAME_VERIFICATION_KEY; +import static org.opensearch.security.dlic.rest.validation.PasswordValidator.FIPS_MIN_PASSWORD_LENGTH; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_HTTP_KEYSTORE_KEYPASSWORD; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_HTTP_PEMKEY_PASSWORD; import static org.opensearch.security.ssl.SecureSSLSettings.SSLSetting.SECURITY_SSL_TRANSPORT_KEYSTORE_KEYPASSWORD; @@ -423,7 +424,7 @@ public void testHttpsAndNodeSSLPKCS1Pem() throws Exception { @Test public void testHttpsAndNodeSSLPemEnc() throws Exception { - final String keyPassword = randomAsciiAlphanumOfLength(CertificatesRule.FIPS_MIN_PASSWORD_LENGTH); + final String keyPassword = randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH); final CertificatesRule.PemFiles pem = certificatesRule.generatePemFiles( "CN=node-0.example.com,OU=SSL,O=Test,L=Test,C=DE", keyPassword @@ -468,7 +469,7 @@ public void testHttpsAndNodeSSLPemEnc() throws Exception { @Test public void testSSLPemEncWithInsecureSettings() throws Exception { - final String keyPassword = randomAsciiAlphanumOfLength(CertificatesRule.FIPS_MIN_PASSWORD_LENGTH); + final String keyPassword = randomAsciiAlphanumOfLength(FIPS_MIN_PASSWORD_LENGTH); final CertificatesRule.PemFiles pem = certificatesRule.generatePemFiles( "CN=node-0.example.com,OU=SSL,O=Test,L=Test,C=DE", keyPassword diff --git a/src/test/java/org/opensearch/security/ssl/config/PemSslCertificatesLoaderTest.java b/src/test/java/org/opensearch/security/ssl/config/PemSslCertificatesLoaderTest.java index 0f0ba9820f..d06cd9a593 100644 --- a/src/test/java/org/opensearch/security/ssl/config/PemSslCertificatesLoaderTest.java +++ b/src/test/java/org/opensearch/security/ssl/config/PemSslCertificatesLoaderTest.java @@ -23,10 +23,10 @@ import org.opensearch.common.settings.MockSecureSettings; import org.opensearch.env.TestEnvironment; -import org.opensearch.security.ssl.CertificatesRule; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.security.dlic.rest.validation.PasswordValidator.FIPS_MIN_PASSWORD_LENGTH; import static org.opensearch.security.ssl.CertificatesUtils.privateKeyToPemObject; import static org.opensearch.security.ssl.CertificatesUtils.writePemContent; import static org.opensearch.security.ssl.util.SSLConfigConstants.ENABLED; @@ -120,7 +120,7 @@ public void loadExtendedTransportSslConfigurationFromPemFiles() throws Exception final var clientCaCertificatePath = "client_ca_certificate.pem"; final var clientKeyCertificatePath = "client_key_certificate.pem"; final var clientPrivateKeyCertificatePath = "client_private_key_certificate.pem"; - final var clientPrivateKeyPassword = RandomStringUtils.secure().nextAlphanumeric(CertificatesRule.FIPS_MIN_PASSWORD_LENGTH); + final var clientPrivateKeyPassword = RandomStringUtils.secure().nextAlphanumeric(FIPS_MIN_PASSWORD_LENGTH); writePemContent(path(clientCaCertificatePath), clientCaCertificate); writePemContent(path(clientKeyCertificatePath), keyAndCertificate.v2()); diff --git a/src/test/java/org/opensearch/security/ssl/util/SSLConfigConstantsTest.java b/src/test/java/org/opensearch/security/ssl/util/SSLConfigConstantsTest.java index 9a8da523d8..f6e5fda9bb 100644 --- a/src/test/java/org/opensearch/security/ssl/util/SSLConfigConstantsTest.java +++ b/src/test/java/org/opensearch/security/ssl/util/SSLConfigConstantsTest.java @@ -16,6 +16,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.security.ssl.config.CertType; +import org.opensearch.security.support.FipsMode; import static org.opensearch.security.ssl.util.SSLConfigConstants.SECURITY_SSL_HTTP_ENABLED_PROTOCOLS; import static org.opensearch.security.ssl.util.SSLConfigConstants.SECURITY_SSL_TRANSPORT_ENABLED_PROTOCOLS; @@ -26,13 +27,19 @@ public class SSLConfigConstantsTest { @Test public void testDefaultTLSProtocols() { final var tlsDefaultProtocols = SSLConfigConstants.getSecureSSLProtocols(Settings.EMPTY, CertType.TRANSPORT); - assertArrayEquals(new String[] { "TLSv1.3", "TLSv1.2", "TLSv1.1" }, tlsDefaultProtocols); + final var expected = FipsMode.isEnabled() + ? new String[] { "TLSv1.3", "TLSv1.2" } + : new String[] { "TLSv1.3", "TLSv1.2", "TLSv1.1" }; + assertArrayEquals(expected, tlsDefaultProtocols); } @Test public void testDefaultSSLProtocols() { final var sslDefaultProtocols = SSLConfigConstants.getSecureSSLProtocols(Settings.EMPTY, CertType.HTTP); - assertArrayEquals(new String[] { "TLSv1.3", "TLSv1.2", "TLSv1.1" }, sslDefaultProtocols); + final var expected = FipsMode.isEnabled() + ? new String[] { "TLSv1.3", "TLSv1.2" } + : new String[] { "TLSv1.3", "TLSv1.2", "TLSv1.1" }; + assertArrayEquals(expected, sslDefaultProtocols); } @Test diff --git a/src/test/java/org/opensearch/security/test/AbstractSecurityUnitTest.java b/src/test/java/org/opensearch/security/test/AbstractSecurityUnitTest.java index 06f95aacf1..5a4f8555dc 100644 --- a/src/test/java/org/opensearch/security/test/AbstractSecurityUnitTest.java +++ b/src/test/java/org/opensearch/security/test/AbstractSecurityUnitTest.java @@ -88,6 +88,7 @@ import org.opensearch.security.test.helper.cluster.ClusterHelper; import org.opensearch.security.test.helper.cluster.ClusterInfo; import org.opensearch.security.test.helper.file.FileHelper; +import org.opensearch.security.test.helper.file.FipsHashAdapter; import org.opensearch.security.test.helper.rest.RestHelper.HttpResponse; import org.opensearch.security.test.helper.rules.SecurityTestWatcher; import org.opensearch.threadpool.ThreadPool; @@ -144,10 +145,10 @@ public abstract class AbstractSecurityUnitTest extends RandomizedTest { public final TestWatcher testWatcher = new SecurityTestWatcher(); public static Header encodeBasicHeader(final String username, final String password) { + final String adaptedPassword = FipsHashAdapter.adaptPassword(Objects.requireNonNull(password)); return new BasicHeader( "Authorization", - "Basic " - + Base64.getEncoder().encodeToString((username + ":" + Objects.requireNonNull(password)).getBytes(StandardCharsets.UTF_8)) + "Basic " + Base64.getEncoder().encodeToString((username + ":" + adaptedPassword).getBytes(StandardCharsets.UTF_8)) ); } @@ -314,6 +315,10 @@ protected Settings.Builder minimumSecuritySettingsBuilder(int node, boolean sslO builder.put(ConfigConstants.SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST, false); } builder.put("cluster.routing.allocation.disk.threshold_enabled", false); + + if (FipsMode.isEnabled()) { + builder.put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.PBKDF2); + } builder.put(other); return builder; } diff --git a/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java b/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java index 996e120891..c11272ca22 100644 --- a/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java +++ b/src/test/java/org/opensearch/security/test/helper/cluster/ClusterHelper.java @@ -63,6 +63,7 @@ import org.opensearch.http.HttpInfo; import org.opensearch.node.Node; import org.opensearch.node.PluginAwareNode; +import org.opensearch.security.OpenSearchSecurityPlugin; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.FipsMode; import org.opensearch.security.test.AbstractSecurityUnitTest; @@ -208,6 +209,7 @@ public final synchronized ClusterInfo startCluster( tcpPortsAllIt.next(), httpPortsIt.next() ); + applyFipsPasswordHashing(nodeSettingsBuilder, setting); final Settings settingsForNode; if (nodeSettingsSupplier != null) { final Settings suppliedSettings = nodeSettingsSupplier.get(nodeNum); @@ -246,6 +248,7 @@ public void run() { tcpPortsAllIt.next(), httpPortsIt.next() ); + applyFipsPasswordHashing(nodeSettingsBuilder, setting); final Settings settingsForNode; if (nodeSettingsSupplier != null) { final Settings suppliedSettings = nodeSettingsSupplier.get(nodeNum); @@ -486,11 +489,13 @@ private Settings.Builder getMinimumNonSecurityNodeSettingsBuilder( .put("path.home", "./target") .put("bootstrap.serial_filter", true); - if (FipsMode.isEnabled()) { + return builder; + } + + private static void applyFipsPasswordHashing(final Settings.Builder builder, final NodeSettings setting) { + if (FipsMode.isEnabled() && setting.getPlugins().contains(OpenSearchSecurityPlugin.class)) { builder.put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.PBKDF2); } - - return builder; } private enum ClusterState { diff --git a/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java b/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java index 13209af82d..9af226afe4 100644 --- a/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java +++ b/src/test/java/org/opensearch/security/test/helper/file/FileHelper.java @@ -202,7 +202,11 @@ public static BytesReference readYamlContent(final String file) { XContentParser parser = null; try { parser = XContentType.YAML.xContent() - .createParser(NamedXContentRegistry.EMPTY, THROW_UNSUPPORTED_OPERATION, new StringReader(loadFile(file))); + .createParser( + NamedXContentRegistry.EMPTY, + THROW_UNSUPPORTED_OPERATION, + new StringReader(FipsHashAdapter.adaptConfig(loadFile(file))) + ); parser.nextToken(); final XContentBuilder builder = XContentFactory.jsonBuilder(); builder.copyCurrentStructure(parser); @@ -225,7 +229,11 @@ public static BytesReference readYamlContentFromString(final String yaml) { XContentParser parser = null; try { parser = XContentType.YAML.xContent() - .createParser(NamedXContentRegistry.EMPTY, THROW_UNSUPPORTED_OPERATION, new StringReader(yaml)); + .createParser( + NamedXContentRegistry.EMPTY, + THROW_UNSUPPORTED_OPERATION, + new StringReader(FipsHashAdapter.adaptConfig(yaml)) + ); parser.nextToken(); final XContentBuilder builder = XContentFactory.jsonBuilder(); builder.copyCurrentStructure(parser); diff --git a/src/test/java/org/opensearch/security/test/helper/file/FipsHashAdapter.java b/src/test/java/org/opensearch/security/test/helper/file/FipsHashAdapter.java new file mode 100644 index 0000000000..8868f22b54 --- /dev/null +++ b/src/test/java/org/opensearch/security/test/helper/file/FipsHashAdapter.java @@ -0,0 +1,149 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.test.helper.file; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.opensearch.common.settings.Settings; +import org.opensearch.security.hasher.PasswordHasher; +import org.opensearch.security.hasher.PasswordHasherFactory; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.FipsMode; + +/** + * Test-only helper that rewrites the static bcrypt fixtures and their short demo passwords into + * FIPS-legal PBKDF2 form, applying the same padding to both the stored hash and the sent password. + * No-op outside FIPS. + */ +public final class FipsHashAdapter { + + private FipsHashAdapter() {} + + /** Suffix appended to fixture passwords under FIPS so they clear the 14-char (112-bit) PBKDF2 floor. */ + static final String FIPS_PASSWORD_PADDING = "_fips_pw_padding"; + + /** Fixture bcrypt hash -> the plaintext it encodes, covering every user any test logs in as. */ + private static final Map BCRYPT_HASH_TO_PLAINTEXT = Map.ofEntries( + // top-level internal_users.yml and the various /internal_users.yml fixtures + Map.entry("$2a$04$idGSEpNOhFbyiRL6toGPT.orh7ENOEU8kAqwkRFaXWRdA6wVgyqUu", "user_b"), + Map.entry("$2a$04$jQcEXpODnTFoGDuA7DPdSevA84CuH/7MOYkb80M3XZIrH76YMWS9G", "user_c"), + Map.entry("$2a$04$NDy7mGbRNrmPMh9nSnIB.OTMFkcioEd69A04ReSGkJDd7QHxnCcVC", "user_a"), + Map.entry("$2a$12$4AcgAt3xwOWadA5s5blL6ev39OXDNhmOesEoo33eZtrq2N0YrU3H.", "kibanaserver"), + Map.entry("$2a$12$61vXe3cXy32p0cjsW0Y/SeZa7kEVSWuQK0jg98D9d5zOGXfo5NgyC", "crusherw"), + Map.entry("$2a$12$6.4Y6L//xeKQ7t8YEG0s6OH4F4q9gMw0J8E0GjmUMNZeyIWu1IRWS", "user_role01_role02_role03"), + Map.entry("$2a$12$A41IxPXV1/Dx46C6i1ufGubv.p3qYX7xVcY46q33sylYbIqQVwTMu", "worf"), + Map.entry("$2a$12$bP0CO5d5nhmaTOj7mGteHugXQQ8jlSV0dxcl5//moZ1xnI.pVPXfe", "abc:abc"), + Map.entry("$2a$12$GI9JXffO3WUjTsU7Yy3E4.LBxC2ILo66Zg/rr79BpikSL2IIRezQa", "spock"), + Map.entry("$2a$12$Ioo1uXmH.Nq/lS5dUVBEsePSmZ5pSIpVO/xKHaquU/Jvq97I7nAgG", "sarek"), + Map.entry("$2a$12$JU2QjYVTlI24Q/enEOpf2uTLCPGchN.eXWCsrBiieUcRoeh53NB0y", "restoreuser"), + Map.entry("$2a$12$LZvbDVnegkTbEFTu9hHnWO4HIrdB9rGaKcEOID5n0VV4j58cnvyZ.", "writer"), + Map.entry("$2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m", "nagilum"), + Map.entry("$2a$12$P.QbiwOsnxgz7kLBT10F7u6GhY7//Keyz7Xwf7lNzskRxpo9.zxFS", "theindexadmin"), + Map.entry("$2a$12$wkY2BsRneCU5za1OPYlzsehQit6gu2vprVv/4jHiSEEBv2ThunaTS", "picard"), + Map.entry("$2a$12$XrBfLQh2T8wIzpxE5vzhUOPjjGfONcD8UEjd5IT5KveG8ULZaj04.", "user_role01"), + Map.entry("$2a$12$xZOcnwYPYQ3zIadnlQIJ0eNhX1ngwMkTN.oMwkKxoGvDVPn4/6XtO", "kirk"), + Map.entry("$2a$12$9Zr4IgoJRqK6xJq4xjoa6OZAnY4QOQ6xIhcCxeYoQtB/HriMkeJSC", "dlsnoinvest"), + Map.entry("$2a$12$VcCDgh2NDk07JGN0rjGbM.Ad41qVR/YFJcgHp0UGns5JDymv..TOG", "admin"), + Map.entry("$2a$12$1HqHxm3QTfzwkse7vwzhFOV4gDv787cZ8BwmCwNEyJhn0CZoo8VVu", "test"), + // FLS/DLS caching + indexing fixtures (dlsfls/, cache/) + Map.entry("$2a$12$7QIoVBGdO41qSCNoecU3L.yyXb9vGrCvEtVlpnC4oWLt/q0AsAN52", "hr_employee"), + Map.entry("$2a$12$JJSXNfTowz7Uu5ttXfeYpeYE0arACvcwlPBStB1F.MI7f0U9Z4DGC", "kibanaro"), + Map.entry("$2a$12$YCBrpxYyFusK609FurY5Ee3BlmuzWw0qHwpwqEyNhM2.XnQY3Bxpe", "password"), + Map.entry("$2y$12$SP9z.rBgEHTlueKkiqSK/OxqB2PLJN/eRoNJ8WOPoHWIpirvbFAAy", "password"), + Map.entry("$2a$12$30rb6oabnodiSdysdWJnhO.4sVRkyNudPC1woYCJFhXja3rkyXbam", "dlsflsuser"), + Map.entry("$2a$12$Kv.4sU5r1zy2ZqnSDm99Ae6ImCMKtjJq4enT.9d3c55cA0O2LGNH6", "finance_employee"), + Map.entry("$2a$12$c26Pnq6yiZcgi8PxNEyp5O3wIn1G1eJfCvJFifEKosQJeojUZf/D6", "finance_trainee"), + Map.entry("$2a$12$LRNG7ETwMcO68VNh14B3AuKPkvOaC0k26.QnSrv9AvbmT1JRNMJum", "hr.employee"), + Map.entry("$2a$12$s6rC7o345lvXp.JpTrA91O6xYAGVCCxKdVclsNkWJTaquW4GK9E9u", "hr_trainee"), + Map.entry("$2a$12$gxsE8oEicXy3mNBkGEO5K.P3J/CDq3GHXDYeQTmVI/v3AA84vqIXm", "no_roles"), + Map.entry("$2a$12$6sre4JH7O4Rgh7ubWmeyWus6UIIA13MqW8eR8KD5Qbxn06CDbJG/G", "snapshotrestore"), + // rest-api admin fixtures (restapi/) + Map.entry("$2y$12$ft8tXtxb.dyO/5MrDXHLc.e1o3dktEQJMvR2e.sgVDyD/gR7G9dLS", "admin_all_access"), + Map.entry("$2y$12$W5AdCO/j08KiDu7EF/1Zf.nkcQM/7s.TtAdN2pRpbDM31xXcIIJUq", "rest_api_admin_allowlist"), + Map.entry("$2y$12$xFUIepz0vILRMzMkZMGY1Ow1P1eJo8TJ2oGiaFXaenGrOMsmDnKZS", "rest_api_admin_nodesdn"), + Map.entry("$2y$12$X5ZamIheHYc2bihGTbK66Oe1.1vJ19akH0OFGF7TvI2BhbbED.KcO", "rest_api_admin_user"), + Map.entry("$2y$12$aHkyhk95XbrMCByYYVAlrek1thXpTDuVKJW01vdLYPh6kyR36j7x6", "user_rest_api_access"), + Map.entry("$2y$12$xgJfGiHpYOkRpF9W9dXYZOpJJ4bHz3VTwdv7ZZYTwlvx7NbH62qUi", "user_tenant_parameters_substitution"), + Map.entry("$2y$12$capXg1HNP49Vxeb6ijzRnu5BLMUE0ZePq1l3MhF8tjnuxg614uaY6", "rest_api_admin_config_update"), + Map.entry("$2y$12$pUn1a6jdIeR.stkvEqNe5uK3rOY7Dj3uQfE8Cvd2bjNjTQ2HbsBMK", "rest_api_admin_internalusers"), + Map.entry("$2y$12$BR.CBsElNLj8v2dzpHJ7bOKVLwWKWjKDhlEvBIvAe9b6/m0xWy2Bq", "rest_api_admin_roles"), + Map.entry("$2y$12$irI4k0eKE8z9OXEd1jO4eeQfPV8WRMfttzutAhEeRBWy5XNXOlpr.", "rest_api_admin_ssl_info"), + Map.entry("$2y$12$DxNdaBBMvTq5wO5XlnwlTeGSaC7yNoFoJt2N5TVtraopxPnGjMol2", "rest_api_admin_ssl_reloadcerts"), + Map.entry("$2y$12$q05T7m7DFtkLLj.MVJ6jjuZkAywG4ZwaNi9fiYn6XCJelN2TUXCy2", "rest_api_admin_tenants"), + // index-pattern / protected-indices / system-indices fixtures + Map.entry("$2y$12$93KcWlQxeify28LSx8EjYOnHv1AQ6vJZXSRUVTnfSN7AxTfvCBfu.", "indexAccessNoRoleUser"), + Map.entry("$2y$12$LavzCpUFiFwXD22rc0n.SOVExdnzDcn6lHY48XKPl6KKdvAHm0awm", "protectedIndexUser"), + Map.entry("$2y$12$15ZXoaH/sB.0nESo6VABt.V02HkpA2lQ5QvIFcVqNelUoAdXv1g3O", "negated_regex_user"), + Map.entry("$2y$12$eaSv29maDe1Y0FQXMHi1legXq8Ec/YbWujMq5Mg2RhYZEc9Pzw19y", "negative_lookahead_user"), + Map.entry("$2y$12$d1ONiqarfTF9xOuqKeNukeze1bVBXC1FyXJSpuC3B6/Ekbfu3ULHm", "normal_user"), + Map.entry("$2y$12$V3.ACgUpHP9TlSbV3CNekOGB1NVov1C6Rq3QtXWCvACKVeQnkBCgG", "normal_user_without_system_index") + ); + + /** Distinct plaintext passwords the fixtures use; only these are padded on the credential side. */ + private static final Set KNOWN_PLAINTEXTS = Set.copyOf(BCRYPT_HASH_TO_PLAINTEXT.values()); + + private static volatile Map bcryptToPbkdf2; + + /** Pads a known fixture password to FIPS-legal length under FIPS; leaves everything else untouched. */ + public static String adaptPassword(final String password) { + if (password == null || !FipsMode.isEnabled() || !KNOWN_PLAINTEXTS.contains(password)) { + return password; + } + return password + FIPS_PASSWORD_PADDING; + } + + /** Rewrites known bcrypt fixture hashes to PBKDF2 of the padded plaintext under FIPS; no-op otherwise. */ + public static String adaptConfig(final String content) { + if (content == null || content.isEmpty() || !FipsMode.isEnabled()) { + return content; + } + String adapted = content; + for (final Map.Entry replacement : pbkdf2Replacements().entrySet()) { + if (adapted.contains(replacement.getKey())) { + adapted = adapted.replace(replacement.getKey(), replacement.getValue()); + } + } + return adapted; + } + + /** Rewrites a config file's hashes to PBKDF2 in place under FIPS, for init paths that read it from disk. */ + public static void adaptConfigFile(final File file) throws IOException { + Files.writeString(file.toPath(), adaptConfig(Files.readString(file.toPath(), StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + } + + /** Lazily builds the {@code bcrypt -> PBKDF2} table using the FIPS node's default PBKDF2 params. */ + private static Map pbkdf2Replacements() { + Map local = bcryptToPbkdf2; + if (local == null) { + synchronized (FipsHashAdapter.class) { + local = bcryptToPbkdf2; + if (local == null) { + final PasswordHasher hasher = PasswordHasherFactory.createPasswordHasher( + Settings.builder().put(ConfigConstants.SECURITY_PASSWORD_HASHING_ALGORITHM, ConfigConstants.PBKDF2).build() + ); + final Map table = new HashMap<>(); + for (final Map.Entry entry : BCRYPT_HASH_TO_PLAINTEXT.entrySet()) { + table.put(entry.getKey(), hasher.hash(adaptPassword(entry.getValue()).toCharArray())); + } + bcryptToPbkdf2 = local = Map.copyOf(table); + } + } + } + return local; + } +} diff --git a/src/test/java/org/opensearch/security/test/helper/rest/RestHelper.java b/src/test/java/org/opensearch/security/test/helper/rest/RestHelper.java index b49b3cf412..5ab90321eb 100644 --- a/src/test/java/org/opensearch/security/test/helper/rest/RestHelper.java +++ b/src/test/java/org/opensearch/security/test/helper/rest/RestHelper.java @@ -85,6 +85,7 @@ import org.opensearch.security.DefaultObjectMapper; import org.opensearch.security.test.helper.cluster.ClusterInfo; import org.opensearch.security.test.helper.file.FileHelper; +import org.opensearch.security.test.helper.file.FipsHashAdapter; import tools.jackson.databind.JsonNode; @@ -321,7 +322,10 @@ protected final CloseableHttpAsyncClient getHTTPClient() throws Exception { final HttpAsyncClientBuilder hcb = HttpAsyncClients.custom(); if (sendHTTPClientCredentials) { - UsernamePasswordCredentials credentials = new UsernamePasswordCredentials("sarek", "sarek".toCharArray()); + UsernamePasswordCredentials credentials = new UsernamePasswordCredentials( + "sarek", + FipsHashAdapter.adaptPassword("sarek").toCharArray() + ); BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); credentialsProvider.setCredentials(new AuthScope(null, -1), credentials); hcb.setDefaultCredentialsProvider(credentialsProvider); From fd9ed3036dee6174acc70bfec26dd470ab7abd03 Mon Sep 17 00:00:00 2001 From: Iwan Igonin Date: Thu, 16 Jul 2026 17:32:40 +0200 Subject: [PATCH 9/9] Fix FIPS timing flakiness in UserBruteForce and ResourceFocused tests - UserBruteForce: widen the rate-limit window 3s->30s so slow FIPS PBKDF2 logins still cluster into one block - ResourceFocused: scale down concurrency/request counts under FIPS so the BCTLS handshake pipeline keeps up Signed-off-by: Iwan Igonin Co-authored-by: Benny Goerzig Co-authored-by: Karsten Schnitter Co-authored-by: Kai Sternad --- .../org/opensearch/security/ResourceFocusedTests.java | 9 +++++---- .../security/UserBruteForceAttacksPreventionTests.java | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/integrationTest/java/org/opensearch/security/ResourceFocusedTests.java b/src/integrationTest/java/org/opensearch/security/ResourceFocusedTests.java index f9b4b6fd83..6c850df63a 100644 --- a/src/integrationTest/java/org/opensearch/security/ResourceFocusedTests.java +++ b/src/integrationTest/java/org/opensearch/security/ResourceFocusedTests.java @@ -37,6 +37,7 @@ import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; import org.opensearch.http.HttpTransportSettings; +import org.opensearch.security.support.FipsMode; import org.opensearch.test.framework.AsyncActions; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.TestSecurityConfig.User; @@ -110,8 +111,8 @@ public void testUnauthenticatedManyMedium() { // Tweaks: final RequestBodySize size = RequestBodySize.Medium; final String requestPath = "/*/_search"; - final int parallelism = 20; - final int totalNumberOfRequests = 10_000; + final int parallelism = FipsMode.isEnabled() ? 8 : 20; + final int totalNumberOfRequests = FipsMode.isEnabled() ? 2_000 : 10_000; runResourceTest(size, requestPath, parallelism, totalNumberOfRequests); runResourceTestWithGenericClient(size, requestPath, parallelism, totalNumberOfRequests); @@ -122,8 +123,8 @@ public void testUnauthenticatedTonsSmall() { // Tweaks: final RequestBodySize size = RequestBodySize.Small; final String requestPath = "/*/_search"; - final int parallelism = 100; - final int totalNumberOfRequests = 15_000; + final int parallelism = FipsMode.isEnabled() ? 8 : 100; + final int totalNumberOfRequests = FipsMode.isEnabled() ? 2_000 : 15_000; runResourceTest(size, requestPath, parallelism, totalNumberOfRequests); runResourceTestWithGenericClient(size, requestPath, parallelism, totalNumberOfRequests); diff --git a/src/integrationTest/java/org/opensearch/security/UserBruteForceAttacksPreventionTests.java b/src/integrationTest/java/org/opensearch/security/UserBruteForceAttacksPreventionTests.java index cb2bea42b2..b1d4c6e618 100644 --- a/src/integrationTest/java/org/opensearch/security/UserBruteForceAttacksPreventionTests.java +++ b/src/integrationTest/java/org/opensearch/security/UserBruteForceAttacksPreventionTests.java @@ -39,8 +39,8 @@ public class UserBruteForceAttacksPreventionTests { private static final User USER_5 = new User("simple-user-5").roles(ALL_ACCESS); public static final int ALLOWED_TRIES = 3; - public static final int TIME_WINDOW_SECONDS = 3; - public static final int LOCK_RELEASE_TIMEOUT_SECONDS = TIME_WINDOW_SECONDS * 3; + public static final int TIME_WINDOW_SECONDS = 30; + public static final int LOCK_RELEASE_TIMEOUT_SECONDS = 9; private static final AuthFailureListeners listener = new AuthFailureListeners().addRateLimit( new RateLimiting("internal_authentication_backend_limiting").type("username") .authenticationBackend("intern")