Skip to content

Commit

Permalink
Add pinentry
Browse files Browse the repository at this point in the history
  • Loading branch information
cstamas committed Oct 10, 2024
1 parent 1bcbbc2 commit 90831d4
Show file tree
Hide file tree
Showing 4 changed files with 330 additions and 1 deletion.
12 changes: 12 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,16 @@
<properties>
<javaVersion>17</javaVersion>
<project.build.outputTimestamp>2024-09-29T15:16:00Z</project.build.outputTimestamp>

<version.slf4j>2.0.16</version.slf4j>
</properties>

<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${version.slf4j}</version>
</dependency>
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-cipher</artifactId>
Expand All @@ -62,6 +69,11 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${version.slf4j}</version>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.codehaus.plexus.components.secdispatcher;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static java.util.Objects.requireNonNull;

/**
* Inspired by <a href="https://velvetcache.org/2023/03/26/a-peek-inside-pinentry/">A peek inside pinentry</a>
*/
public class PinEntry {
public enum Outcome {
SUCCESS,
TIMEOUT,
NOT_CONFIRMED,
CANCELED,
FAILED;
}

public record Result<T>(Outcome outcome, T payload) {}

private final Logger logger = LoggerFactory.getLogger(getClass());
private final String cmd;
private final LinkedHashMap<String, String> commands;

public PinEntry(String cmd) {
this.cmd = requireNonNull(cmd);
this.commands = new LinkedHashMap<>();
}

public PinEntry setKeyInfo(String keyInfo) {
requireNonNull(keyInfo);
commands.put("SETKEYINFO", keyInfo);
commands.put("OPTION", "allow-external-password-cache");
return this;
}

public PinEntry setOk(String msg) {
requireNonNull(msg);
commands.put("SETOK", msg);
return this;
}

public PinEntry setCancel(String msg) {
requireNonNull(msg);
commands.put("SETCANCEL", msg);
return this;
}

public PinEntry setTitle(String title) {
requireNonNull(title);
commands.put("SETTITLE", title);
return this;
}

public PinEntry setDescription(String desc) {
requireNonNull(desc);
commands.put("SETDESC", desc);
return this;
}

public PinEntry setPrompt(String prompt) {
requireNonNull(prompt);
commands.put("SETPROMPT", prompt);
return this;
}

public PinEntry confirmPin() {
commands.put("SETREPEAT", cmd);
return this;
}

public PinEntry setTimeout(Duration timeout) {
long seconds = timeout.toSeconds();
if (seconds < 0) {
throw new IllegalArgumentException("Set timeout is 0 seconds");
}
commands.put("SETTIMEOUT", String.valueOf(seconds));
return this;
}

public Result<String> getPin() throws IOException {
commands.put("GETPIN", null);
return execute();
}

public Result<String> confirm() throws IOException {
commands.put("CONFIRM", null);
return execute();
}

public Result<String> message() throws IOException {
commands.put("MESSAGE", null);
return execute();
}

private Result<String> execute() throws IOException {
Process process = new ProcessBuilder(cmd).start();
BufferedReader reader = process.inputReader();
BufferedWriter writer = process.outputWriter();
expectOK(process.inputReader());
Map.Entry<String, String> lastEntry = commands.entrySet().iterator().next();
for (Map.Entry<String, String> entry : commands.entrySet()) {
String cmd;
if (entry.getValue() != null) {
cmd = entry.getKey() + " " + entry.getValue();
} else {
cmd = entry.getKey();
}
logger.debug("> {}", cmd);
writer.write(cmd);
writer.newLine();
writer.flush();
if (entry != lastEntry) {
expectOK(reader);
}
}
Result<String> result = lastExpect(reader);
writer.write("BYE");
writer.newLine();
writer.flush();
try {
process.waitFor(5, TimeUnit.SECONDS);
int exitCode = process.exitValue();
if (exitCode != 0) {
return new Result<>(Outcome.FAILED, "Exit code: " + exitCode);
} else {
return result;
}
} catch (Exception e) {
return new Result<>(Outcome.FAILED, e.getMessage());
}
}

private void expectOK(BufferedReader in) throws IOException {
String response = in.readLine();
logger.debug("< {}", response);
if (!response.startsWith("OK")) {
throw new IOException("Expected OK but got this instead: " + response);
}
}

private Result<String> lastExpect(BufferedReader in) throws IOException {
while (true) {
String response = in.readLine();
logger.debug("< {}", response);
if (response.startsWith("#")) {
continue;
}
if (response.startsWith("S")) {
continue;
}
if (response.startsWith("ERR")) {
if (response.contains("83886142")) {
return new Result<>(Outcome.TIMEOUT, response);
}
if (response.contains("83886179")) {
return new Result<>(Outcome.CANCELED, response);
}
if (response.contains("83886194")) {
return new Result<>(Outcome.NOT_CONFIRMED, response);
}
}
if (response.startsWith("D")) {
return new Result<>(Outcome.SUCCESS, response.substring(2));
}
if (response.startsWith("OK")) {
return new Result<>(Outcome.SUCCESS, response);
}
}
}

public static void main(String[] args) throws IOException {
Result<String> pinResult = new PinEntry("/usr/bin/pinentry-gnome3")
.setTimeout(Duration.ofSeconds(5))
.setKeyInfo("maven:masterPassword")
.setTitle("Maven Master Password")
.setDescription("Please enter the Maven master password")
.setPrompt("Master password")
.setOk("Her you go!")
.setCancel("Uh oh, rather not")
// .confirmPin() (will not let you through if you cannot type same thing twice)
.getPin();
if (pinResult.outcome() == Outcome.SUCCESS) {
Result<String> confirmResult = new PinEntry("/usr/bin/pinentry-gnome3")
.setTitle("Password confirmation")
.setDescription("Please confirm that the password you entered is correct")
.setPrompt("Is the password '" + pinResult.payload() + "' the one you want?")
.confirm();
if (confirmResult.outcome() == Outcome.SUCCESS) {
new PinEntry("/usr/bin/pinentry-gnome3")
.setTitle("Password confirmed")
.setDescription("You confirmed your password")
.setPrompt("The password '" + pinResult.payload() + "' is confirmed.")
.confirm();
} else {
System.out.println(confirmResult);
}
} else {
System.out.println(pinResult);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.codehaus.plexus.components.secdispatcher.internal.sources;

import javax.inject.Named;
import javax.inject.Singleton;

import java.io.IOException;
import java.time.Duration;
import java.util.Optional;

import org.codehaus.plexus.components.secdispatcher.MasterSource;
import org.codehaus.plexus.components.secdispatcher.MasterSourceMeta;
import org.codehaus.plexus.components.secdispatcher.PinEntry;
import org.codehaus.plexus.components.secdispatcher.SecDispatcherException;

/**
* Inspired by <a href="https://velvetcache.org/2023/03/26/a-peek-inside-pinentry/">A peek inside pinentry</a>
*/
@Singleton
@Named(PinEntryMasterSource.NAME)
public class PinEntryMasterSource extends PrefixMasterSourceSupport implements MasterSource, MasterSourceMeta {
public static final String NAME = "pinentry-prompt";

public PinEntryMasterSource() {
super(NAME + ":");
}

@Override
public String description() {
return "Secure PinEntry prompt";
}

@Override
public Optional<String> configTemplate() {
return Optional.of(NAME + ":" + "$pinentryPath");
}

@Override
public String doHandle(String s) throws SecDispatcherException {
try {
PinEntry.Result<String> result = new PinEntry(s)
.setTimeout(Duration.ofSeconds(30))
.setKeyInfo("maven:masterPassword")
.setTitle("Maven Master Password")
.setDescription("Please enter the Maven master password")
.setPrompt("Maven master password")
.setOk("Ok")
.setCancel("Cancel")
.getPin();
if (result.outcome() == PinEntry.Outcome.SUCCESS) {
return result.payload();
} else if (result.outcome() == PinEntry.Outcome.CANCELED) {
throw new SecDispatcherException("User canceled the operation");
} else if (result.outcome() == PinEntry.Outcome.TIMEOUT) {
throw new SecDispatcherException("Timeout");
} else {
throw new SecDispatcherException("Failure: " + result.payload());
}
} catch (IOException e) {
throw new SecDispatcherException("Could not collect the password", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ void env() {
@Test
void gpgAgent() {
GpgAgentMasterSource source = new GpgAgentMasterSource();
// ypu may adjust path, this is Fedora40 WS. Ubuntu does `.gpg/S.gpg-agent`
// you may adjust path, this is Fedora40 WS. Ubuntu does `.gpg/S.gpg-agent`
assertEquals("masterPw", source.handle("gpg-agent:/run/user/1000/gnupg/S.gpg-agent"));
}

@Disabled("enable and type in 'masterPw'")
@Test
void pinEntry() {
PinEntryMasterSource source = new PinEntryMasterSource();
// ypu may adjust path, this is Fedora40 WS + gnome
assertEquals("masterPw", source.handle("pinentry-prompt:/usr/bin/pinentry-gnome3"));
}
}

0 comments on commit 90831d4

Please sign in to comment.