Skip to content

Commit 90831d4

Browse files
committed
Add pinentry
1 parent 1bcbbc2 commit 90831d4

File tree

4 files changed

+330
-1
lines changed

4 files changed

+330
-1
lines changed

pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,16 @@
3535
<properties>
3636
<javaVersion>17</javaVersion>
3737
<project.build.outputTimestamp>2024-09-29T15:16:00Z</project.build.outputTimestamp>
38+
39+
<version.slf4j>2.0.16</version.slf4j>
3840
</properties>
3941

4042
<dependencies>
43+
<dependency>
44+
<groupId>org.slf4j</groupId>
45+
<artifactId>slf4j-api</artifactId>
46+
<version>${version.slf4j}</version>
47+
</dependency>
4148
<dependency>
4249
<groupId>org.codehaus.plexus</groupId>
4350
<artifactId>plexus-cipher</artifactId>
@@ -62,6 +69,11 @@
6269
<artifactId>junit-jupiter</artifactId>
6370
<scope>test</scope>
6471
</dependency>
72+
<dependency>
73+
<groupId>org.slf4j</groupId>
74+
<artifactId>slf4j-simple</artifactId>
75+
<version>${version.slf4j}</version>
76+
</dependency>
6577
</dependencies>
6678

6779
<build>
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.codehaus.plexus.components.secdispatcher;
20+
21+
import java.io.BufferedReader;
22+
import java.io.BufferedWriter;
23+
import java.io.IOException;
24+
import java.time.Duration;
25+
import java.util.LinkedHashMap;
26+
import java.util.Map;
27+
import java.util.concurrent.TimeUnit;
28+
29+
import org.slf4j.Logger;
30+
import org.slf4j.LoggerFactory;
31+
32+
import static java.util.Objects.requireNonNull;
33+
34+
/**
35+
* Inspired by <a href="https://velvetcache.org/2023/03/26/a-peek-inside-pinentry/">A peek inside pinentry</a>
36+
*/
37+
public class PinEntry {
38+
public enum Outcome {
39+
SUCCESS,
40+
TIMEOUT,
41+
NOT_CONFIRMED,
42+
CANCELED,
43+
FAILED;
44+
}
45+
46+
public record Result<T>(Outcome outcome, T payload) {}
47+
48+
private final Logger logger = LoggerFactory.getLogger(getClass());
49+
private final String cmd;
50+
private final LinkedHashMap<String, String> commands;
51+
52+
public PinEntry(String cmd) {
53+
this.cmd = requireNonNull(cmd);
54+
this.commands = new LinkedHashMap<>();
55+
}
56+
57+
public PinEntry setKeyInfo(String keyInfo) {
58+
requireNonNull(keyInfo);
59+
commands.put("SETKEYINFO", keyInfo);
60+
commands.put("OPTION", "allow-external-password-cache");
61+
return this;
62+
}
63+
64+
public PinEntry setOk(String msg) {
65+
requireNonNull(msg);
66+
commands.put("SETOK", msg);
67+
return this;
68+
}
69+
70+
public PinEntry setCancel(String msg) {
71+
requireNonNull(msg);
72+
commands.put("SETCANCEL", msg);
73+
return this;
74+
}
75+
76+
public PinEntry setTitle(String title) {
77+
requireNonNull(title);
78+
commands.put("SETTITLE", title);
79+
return this;
80+
}
81+
82+
public PinEntry setDescription(String desc) {
83+
requireNonNull(desc);
84+
commands.put("SETDESC", desc);
85+
return this;
86+
}
87+
88+
public PinEntry setPrompt(String prompt) {
89+
requireNonNull(prompt);
90+
commands.put("SETPROMPT", prompt);
91+
return this;
92+
}
93+
94+
public PinEntry confirmPin() {
95+
commands.put("SETREPEAT", cmd);
96+
return this;
97+
}
98+
99+
public PinEntry setTimeout(Duration timeout) {
100+
long seconds = timeout.toSeconds();
101+
if (seconds < 0) {
102+
throw new IllegalArgumentException("Set timeout is 0 seconds");
103+
}
104+
commands.put("SETTIMEOUT", String.valueOf(seconds));
105+
return this;
106+
}
107+
108+
public Result<String> getPin() throws IOException {
109+
commands.put("GETPIN", null);
110+
return execute();
111+
}
112+
113+
public Result<String> confirm() throws IOException {
114+
commands.put("CONFIRM", null);
115+
return execute();
116+
}
117+
118+
public Result<String> message() throws IOException {
119+
commands.put("MESSAGE", null);
120+
return execute();
121+
}
122+
123+
private Result<String> execute() throws IOException {
124+
Process process = new ProcessBuilder(cmd).start();
125+
BufferedReader reader = process.inputReader();
126+
BufferedWriter writer = process.outputWriter();
127+
expectOK(process.inputReader());
128+
Map.Entry<String, String> lastEntry = commands.entrySet().iterator().next();
129+
for (Map.Entry<String, String> entry : commands.entrySet()) {
130+
String cmd;
131+
if (entry.getValue() != null) {
132+
cmd = entry.getKey() + " " + entry.getValue();
133+
} else {
134+
cmd = entry.getKey();
135+
}
136+
logger.debug("> {}", cmd);
137+
writer.write(cmd);
138+
writer.newLine();
139+
writer.flush();
140+
if (entry != lastEntry) {
141+
expectOK(reader);
142+
}
143+
}
144+
Result<String> result = lastExpect(reader);
145+
writer.write("BYE");
146+
writer.newLine();
147+
writer.flush();
148+
try {
149+
process.waitFor(5, TimeUnit.SECONDS);
150+
int exitCode = process.exitValue();
151+
if (exitCode != 0) {
152+
return new Result<>(Outcome.FAILED, "Exit code: " + exitCode);
153+
} else {
154+
return result;
155+
}
156+
} catch (Exception e) {
157+
return new Result<>(Outcome.FAILED, e.getMessage());
158+
}
159+
}
160+
161+
private void expectOK(BufferedReader in) throws IOException {
162+
String response = in.readLine();
163+
logger.debug("< {}", response);
164+
if (!response.startsWith("OK")) {
165+
throw new IOException("Expected OK but got this instead: " + response);
166+
}
167+
}
168+
169+
private Result<String> lastExpect(BufferedReader in) throws IOException {
170+
while (true) {
171+
String response = in.readLine();
172+
logger.debug("< {}", response);
173+
if (response.startsWith("#")) {
174+
continue;
175+
}
176+
if (response.startsWith("S")) {
177+
continue;
178+
}
179+
if (response.startsWith("ERR")) {
180+
if (response.contains("83886142")) {
181+
return new Result<>(Outcome.TIMEOUT, response);
182+
}
183+
if (response.contains("83886179")) {
184+
return new Result<>(Outcome.CANCELED, response);
185+
}
186+
if (response.contains("83886194")) {
187+
return new Result<>(Outcome.NOT_CONFIRMED, response);
188+
}
189+
}
190+
if (response.startsWith("D")) {
191+
return new Result<>(Outcome.SUCCESS, response.substring(2));
192+
}
193+
if (response.startsWith("OK")) {
194+
return new Result<>(Outcome.SUCCESS, response);
195+
}
196+
}
197+
}
198+
199+
public static void main(String[] args) throws IOException {
200+
Result<String> pinResult = new PinEntry("/usr/bin/pinentry-gnome3")
201+
.setTimeout(Duration.ofSeconds(5))
202+
.setKeyInfo("maven:masterPassword")
203+
.setTitle("Maven Master Password")
204+
.setDescription("Please enter the Maven master password")
205+
.setPrompt("Master password")
206+
.setOk("Her you go!")
207+
.setCancel("Uh oh, rather not")
208+
// .confirmPin() (will not let you through if you cannot type same thing twice)
209+
.getPin();
210+
if (pinResult.outcome() == Outcome.SUCCESS) {
211+
Result<String> confirmResult = new PinEntry("/usr/bin/pinentry-gnome3")
212+
.setTitle("Password confirmation")
213+
.setDescription("Please confirm that the password you entered is correct")
214+
.setPrompt("Is the password '" + pinResult.payload() + "' the one you want?")
215+
.confirm();
216+
if (confirmResult.outcome() == Outcome.SUCCESS) {
217+
new PinEntry("/usr/bin/pinentry-gnome3")
218+
.setTitle("Password confirmed")
219+
.setDescription("You confirmed your password")
220+
.setPrompt("The password '" + pinResult.payload() + "' is confirmed.")
221+
.confirm();
222+
} else {
223+
System.out.println(confirmResult);
224+
}
225+
} else {
226+
System.out.println(pinResult);
227+
}
228+
}
229+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.codehaus.plexus.components.secdispatcher.internal.sources;
20+
21+
import javax.inject.Named;
22+
import javax.inject.Singleton;
23+
24+
import java.io.IOException;
25+
import java.time.Duration;
26+
import java.util.Optional;
27+
28+
import org.codehaus.plexus.components.secdispatcher.MasterSource;
29+
import org.codehaus.plexus.components.secdispatcher.MasterSourceMeta;
30+
import org.codehaus.plexus.components.secdispatcher.PinEntry;
31+
import org.codehaus.plexus.components.secdispatcher.SecDispatcherException;
32+
33+
/**
34+
* Inspired by <a href="https://velvetcache.org/2023/03/26/a-peek-inside-pinentry/">A peek inside pinentry</a>
35+
*/
36+
@Singleton
37+
@Named(PinEntryMasterSource.NAME)
38+
public class PinEntryMasterSource extends PrefixMasterSourceSupport implements MasterSource, MasterSourceMeta {
39+
public static final String NAME = "pinentry-prompt";
40+
41+
public PinEntryMasterSource() {
42+
super(NAME + ":");
43+
}
44+
45+
@Override
46+
public String description() {
47+
return "Secure PinEntry prompt";
48+
}
49+
50+
@Override
51+
public Optional<String> configTemplate() {
52+
return Optional.of(NAME + ":" + "$pinentryPath");
53+
}
54+
55+
@Override
56+
public String doHandle(String s) throws SecDispatcherException {
57+
try {
58+
PinEntry.Result<String> result = new PinEntry(s)
59+
.setTimeout(Duration.ofSeconds(30))
60+
.setKeyInfo("maven:masterPassword")
61+
.setTitle("Maven Master Password")
62+
.setDescription("Please enter the Maven master password")
63+
.setPrompt("Maven master password")
64+
.setOk("Ok")
65+
.setCancel("Cancel")
66+
.getPin();
67+
if (result.outcome() == PinEntry.Outcome.SUCCESS) {
68+
return result.payload();
69+
} else if (result.outcome() == PinEntry.Outcome.CANCELED) {
70+
throw new SecDispatcherException("User canceled the operation");
71+
} else if (result.outcome() == PinEntry.Outcome.TIMEOUT) {
72+
throw new SecDispatcherException("Timeout");
73+
} else {
74+
throw new SecDispatcherException("Failure: " + result.payload());
75+
}
76+
} catch (IOException e) {
77+
throw new SecDispatcherException("Could not collect the password", e);
78+
}
79+
}
80+
}

src/test/java/org/codehaus/plexus/components/secdispatcher/internal/sources/SourcesTest.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,15 @@ void env() {
3838
@Test
3939
void gpgAgent() {
4040
GpgAgentMasterSource source = new GpgAgentMasterSource();
41-
// ypu may adjust path, this is Fedora40 WS. Ubuntu does `.gpg/S.gpg-agent`
41+
// you may adjust path, this is Fedora40 WS. Ubuntu does `.gpg/S.gpg-agent`
4242
assertEquals("masterPw", source.handle("gpg-agent:/run/user/1000/gnupg/S.gpg-agent"));
4343
}
44+
45+
@Disabled("enable and type in 'masterPw'")
46+
@Test
47+
void pinEntry() {
48+
PinEntryMasterSource source = new PinEntryMasterSource();
49+
// ypu may adjust path, this is Fedora40 WS + gnome
50+
assertEquals("masterPw", source.handle("pinentry-prompt:/usr/bin/pinentry-gnome3"));
51+
}
4452
}

0 commit comments

Comments
 (0)