# HG changeset patch # User David Barts # Date 1662937897 25200 # Node ID a6cfdffcaa94ad28124ed38678f71ad7d436ea19 Initial commit, incomplete but it runs sorta. diff -r 000000000000 -r a6cfdffcaa94 .hgignore --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.hgignore Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,9 @@ +~$ +\.bak$ +\.class$ +\.dylib$ +\.o$ +^\.idea/ +^target/ +^out/ +^\#.*\# diff -r 000000000000 -r a6cfdffcaa94 PassMan.iml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/PassMan.iml Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 design.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design.txt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,60 @@ +* First, decide that for some reason the standard pass password manager is + unacceptable. One reason may be the risk of compromise or bad commits + (by a hostile actor). Another may be matadata leakage: pass stores the + name of the (username, password) pair in plaintext. + + Regarding the latter, my ccrypt-based solution is better than that, and + it immediately struck me as a bad point. Was already thinking about what + I would do, and it involved sqlite with all fields except for time stamps + encrypted. + +* Using sqlite (sqlite3) and one of {Kotlin, Golang, Swift} (in rough order + of likelihood) would probably be the way to go. +* Use a table of the following form to store the passwords (call it passwords): + id integer not null primary key + name blob + username blob + password blob + notes blob (can be null) + created integer + modified integer + accessed (read) integer + + id will be the low 64 bits of the MD5 hash of the encrypted name + case-folded to lowercase (encrypt with a zero IV, don't prepend). + + all blobs will be AES-encrypted with prepended IV, a'la EncryptedProperties. + + timestamps are seconds or milliseconds since the epoch +* Use four other tables to store configuration parameters (as needed) + integers, reals, strings, blobs. + + Each will have an implicit rowid field, a string name field, and a + value field of type integer, real, text, or blob as appropriate. + + Probably only put those in the schema on an as-needed basis, i.e. + a reals table will only exist if we have configuration parameters +* Four major subcommands: create, read, update, delete. + + Error to create a password whose name (case insensitive) already exists. + + Error to read, update, or delete one whose name does not exist. + + Read should support a --like option to do inexact matching. If one + match, act much like read w/o --like. If multiple matches, just list + the names. + + Read should also support a --clip option that deposits the password + into the clipboard without printing it. + + Read should also support a --verbose or --long option that causes a + stanza-like, key: value dump of the full record. If --clip is used + along with --verbose, password prints as "(in clipboard)" + +* Use same password everywhere. + + Use a dummy config param to ensure correct password (attempt decrypt of + it first, write it base64'ed). + + Could theoretically use different p/w's for different rows, but causes + headaches matching names later, so avoid. + +* Misc: + + Include a password generator along the lines of genpass. + - This should be as a --generate option to the create and update + subcommands. + - --generate allows four other options: --length, --symbols, --clip, + --verbose. + - Default length should be 12 (default no symbols). + - If --clip supplied, copy new password to clipboard. + - If --verbose supplied, print new password. + - Default to be silent and not put password in clipboard, just change + in database. + + Command-line only, no GUI. diff -r 000000000000 -r a6cfdffcaa94 nyt.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/nyt.html Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,186 @@ + + + + + As More Vote by Mail, Faulty Ballots Could Impact Elections - The New York Times + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Error and Fraud at Issue as Absentee Voting Rises

An absentee ballot in Florida. Almost 2 percent of mailed ballots are rejected, double the rate for in-person voting.
Credit...Sarah Beth Glicksteen for The New York Times

TALLAHASSEE, Fla. — On the morning of the primary here in August, the local elections board met to decide which absentee ballots to count. It was not an easy job.

The board tossed out some ballots because they arrived without the signature required on the outside of the return envelope. It rejected one that said “see inside” where the signature should have been. And it debated what to do with ballots in which the signature on the envelope did not quite match the one in the county’s files.

“This ‘r’ is not like that ‘r,’ ” Judge Augustus D. Aikens Jr. said, suggesting that a ballot should be rejected.

Ion Sancho, the elections supervisor here, disagreed. “This ‘k’ is like that ‘k,’ ” he replied, and he persuaded his colleagues to count the vote.

Scenes like this will play out in many elections next month, because Florida and other states are swiftly moving from voting at a polling place toward voting by mail. In the last general election in Florida, in 2010, 23 percent of voters cast absentee ballots, up from 15 percent in the midterm election four years before. Nationwide, the use of absentee ballots and other forms of voting by mail has more than tripled since 1980 and now accounts for almost 20 percent of all votes.

Yet votes cast by mail are less likely to be counted, more likely to be compromised and more likely to be contested than those cast in a voting booth, statistics show. Election officials reject almost 2 percent of ballots cast by mail, double the rate for in-person voting.

“The more people you force to vote by mail,” Mr. Sancho said, “the more invalid ballots you will generate.”

Election experts say the challenges created by mailed ballots could well affect outcomes this fall and beyond. If the contests next month are close enough to be within what election lawyers call the margin of litigation, the grounds on which they will be fought will not be hanging chads but ballots cast away from the voting booth.

In 2008, 18 percent of the votes in the nine states likely to decide this year’s presidential election were cast by mail. That number will almost certainly rise this year, and voters in two-thirds of the states have already begun casting absentee ballots. In four Western states, voting by mail is the exclusive or dominant way to cast a ballot.

The trend will probably result in more uncounted votes, and it increases the potential for fraud. While fraud in voting by mail is far less common than innocent errors, it is vastly more prevalent than the in-person voting fraud that has attracted far more attention, election administrators say.

In Florida, absentee-ballot scandals seem to arrive like clockwork around election time. Before this year’s primary, for example, a woman in Hialeah was charged with forging an elderly voter’s signature, a felony, and possessing 31 completed absentee ballots, 29 more than allowed under a local law.

The flaws of absentee voting raise questions about the most elementary promises of democracy. “The right to have one’s vote counted is as important as the act of voting itself,” Justice Paul H. Anderson of the Minnesota Supreme Court wrote while considering disputed absentee ballots in the close 2008 Senate election between Al Franken and Norm Coleman.

Voting by mail is now common enough and problematic enough that election experts say there have been multiple elections in which no one can say with confidence which candidate was the deserved winner. The list includes the 2000 presidential election, in which problems with absentee ballots in Florida were a little-noticed footnote to other issues.

In the last presidential election, 35.5 million voters requested absentee ballots, but only 27.9 million absentee votes were counted, according to a study by Charles Stewart III, a political scientist at the Massachusetts Institute of Technology. He calculated that 3.9 million ballots requested by voters never reached them; that another 2.9 million ballots received by voters did not make it back to election officials; and that election officials rejected 800,000 ballots. That suggests an overall failure rate of as much as 21 percent.

Some voters presumably decided not to vote after receiving ballots, but Mr. Stewart said many others most likely tried to vote and were thwarted. “If 20 percent, or even 10 percent, of voters who stood in line on Election Day were turned away,” he wrote in the study, published in The Journal of Legislation and Public Policy, “there would be national outrage.”

The list of very close elections includes the 2008 Senate race in Minnesota, in which Mr. Franken’s victory over Mr. Coleman, the Republican incumbent, helped give Democrats the 60 votes in the Senate needed to pass President Obama’s health care bill. Mr. Franken won by 312 votes, while state officials rejected 12,000 absentee ballots. Recent primary elections in New York involving Republican state senators who had voted to allow same-sex marriage also hinged on absentee ballots.

There are, of course, significant advantages to voting by mail. It makes life easier for the harried, the disabled and the elderly. It is cheaper to administer, makes for shorter lines on election days and allows voters more time to think about ballots that list many races. By mailing ballots, those away from home can vote. Its availability may also increase turnout in local elections, though it does not seem to have had much impact on turnout in federal ones.

Still, voting in person is more reliable, particularly since election administrators made improvements to voting equipment after the 2000 presidential election.

There have been other and more controversial changes since then, also in the name of reliability and efficiency. Lawmakers have cut back on early voting in person, cracked down on voter registration drives, imposed identification requirements, made it harder for students to cast ballots and proposed purging voter rolls in a way that critics have said would eliminate people who are eligible to vote.

But almost nothing has been done about the distinctive challenges posed by absentee ballots. To the contrary, Ohio’s Republican secretary of state recently sent absentee ballot applications to every registered voter in the state. And Republican lawmakers in Florida recently revised state law to allow ballots to be mailed wherever voters want, rather than typically to only their registered addresses.

“This is the only area in Florida where we’ve made it easier to cast a ballot,” Daniel A. Smith, a political scientist at the University of Florida, said of absentee voting.

He posited a reason that Republican officials in particular have pushed to expand absentee voting. “The conventional wisdom is that Republicans use absentee ballots and Democrats vote early,” he said.

Republicans are in fact more likely than Democrats to vote absentee. In the 2008 general election in Florida, 47 percent of absentee voters were Republicans and 36 percent were Democrats.

There is a bipartisan consensus that voting by mail, whatever its impact, is more easily abused than other forms. In a 2005 report signed by President Jimmy Carter and James A. Baker III, who served as secretary of state under the first President George Bush, the Commission on Federal Election Reform concluded, “Absentee ballots remain the largest source of potential voter fraud.”

On the most basic level, absentee voting replaces the oversight that exists at polling places with something akin to an honor system.

“Absentee voting is to voting in person,” Judge Richard A. Posner of the United States Court of Appeals for the Seventh Circuit has written, “as a take-home exam is to a proctored one.”

Fraud Easier Via Mail

Election administrators have a shorthand name for a central weakness of voting by mail. They call it granny farming.

“The problem,” said Murray A. Greenberg, a former county attorney in Miami, “is really with the collection of absentee ballots at the senior citizen centers.” In Florida, people affiliated with political campaigns “help people vote absentee,” he said. “And help is in quotation marks.”

Voters in nursing homes can be subjected to subtle pressure, outright intimidation or fraud. The secrecy of their voting is easily compromised. And their ballots can be intercepted both coming and going.

The problem is not limited to the elderly, of course. Absentee ballots also make it much easier to buy and sell votes. In recent years, courts have invalidated mayoral elections in Illinois and Indiana because of fraudulent absentee ballots.

Voting by mail also played a crucial role in the 2000 presidential election in Florida, when the margin between George W. Bush and Al Gore was razor thin and hundreds of absentee ballots were counted in apparent violation of state law. The flawed ballots, from Americans living abroad, included some without postmarks, some postmarked after the election, some without witness signatures, some mailed from within the United States and some sent by people who voted twice. All would have been disqualified had the state’s election laws been strictly enforced.

In the recent primary here, almost 40 percent of ballots were not cast in the voting booth on the day of the election. They were split between early votes cast at polling places, which Mr. Sancho, the Leon County elections supervisor, favors, and absentee ballots, which make him nervous.

“There has been not one case of fraud in early voting,” Mr. Sancho said. “The only cases of election fraud have been in absentee ballots.”

Efforts to prevent fraud at polling places have an ironic consequence, Justin Levitt, a professor at Loyola Law School, told the Senate Judiciary Committee September last year. They will, he said, “drive more voters into the absentee system, where fraud and coercion have been documented to be real and legitimate concerns.”

“That is,” he said, “a law ostensibly designed to reduce the incidence of fraud is likely to increase the rate at which voters utilize a system known to succumb to fraud more frequently.”

Clarity Brings Better Results

In 2008, Minnesota officials rejected 12,000 absentee ballots, about 4 percent of all such votes, for the myriad reasons that make voting by mail far less reliable than voting in person.

The absentee ballot itself could be blamed for some of the problems. It had to be enclosed in envelopes containing various information and signatures, including one from a witness who had to attest to handling the logistics of seeing that “the voter marked the ballots in that individual’s presence without showing how they were marked.” Such witnesses must themselves be registered voters, with a few exceptions.

Absentee ballots have been rejected in Minnesota and elsewhere for countless reasons. Signatures from older people, sloppy writers or stroke victims may not match those on file. The envelopes and forms may not have been configured in the right sequence. People may have moved, and addresses may not match. Witnesses may not be registered to vote. The mail may be late.

But it is certainly possible to improve the process and reduce the error rate.

Here in Leon County, the rejection rate for absentee ballots is less than 1 percent. The instructions it provides to voters are clear, and the outer envelope is a model of graphic design, with a large signature box at its center.

The envelope requires only standard postage, and Mr. Sancho has made arrangements with the post office to pay for ballots that arrive without stamps.

Still, he would prefer that voters visit a polling place on Election Day or beforehand so that errors and misunderstandings can be corrected and the potential for fraud minimized.

“If you vote by mail, where is that coming from?” he asked. “Is there intimidation going on?”

Last November, Gov. Rick Scott, a Republican, suspended a school board member in Madison County, not far from here, after she was arrested on charges including absentee ballot fraud.

The board member, Abra Hill Johnson, won the school board race “by what appeared to be a disproportionate amount of absentee votes,” the arrest affidavit said. The vote was 675 to 647, but Ms. Johnson had 217 absentee votes to her opponent’s 86. Officials said that 80 absentee ballots had been requested at just nine addresses. Law enforcement agents interviewed 64 of the voters whose ballots were sent; only two recognized the address.

Ms. Johnson has pleaded not guilty.

Election law experts say that pulling off in-person voter fraud on a scale large enough to swing an election, with scores if not hundreds of people committing a felony in public by pretending to be someone else, is hard to imagine, to say nothing of exceptionally risky.

There are much simpler and more effective alternatives to commit fraud on such a scale, said Heather Gerken, a law professor at Yale.

“You could steal some absentee ballots or stuff a ballot box or bribe an election administrator or fiddle with an electronic voting machine,” she said. That explains, she said, “why all the evidence of stolen elections involves absentee ballots and the like.”

+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 pom.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pom.xml Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,141 @@ + + + 4.0.0 + + PassMan + name.blackcap + 1.0-SNAPSHOT + jar + + consoleApp + + + UTF-8 + official + 1.8 + + + + + mavenCentral + https://repo1.maven.org/maven2/ + + + mvnRepository + https://mvnrepository.com/artifact/ + + + + + src/main/kotlin + src/test/kotlin + + + src/main/resources + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + 1.7.10 + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.2 + + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + 11 + + + + org.apache.maven.plugins + maven-assembly-plugin + + + package + + single + + + + + + name.blackcap.passman.MainKt + + + + + jar-with-dependencies + + + + + + + + + + + org.jetbrains.kotlin + kotlin-test-junit5 + 1.7.10 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.8.2 + test + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + 1.7.10 + + + org.jetbrains.kotlin + kotlin-reflect + 1.7.10 + + + org.xerial + sqlite-jdbc + 3.36.0.3 + + + commons-cli + commons-cli + 1.5.0 + + + + \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Clipboard.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Clipboard.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,41 @@ +package name.blackcap.passman + +import java.awt.Toolkit +import java.awt.datatransfer.Clipboard +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException + +private val CLIPBOARD = Toolkit.getDefaultToolkit().systemClipboard + +private class ClipboardData(val item: String): Transferable { + private val FLAVORS = arrayOf(DataFlavor.stringFlavor) + + override fun getTransferDataFlavors(): Array = FLAVORS + + override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean = + FLAVORS.contains(flavor) + + override fun getTransferData(flavor: DataFlavor?): Any { + if (!isDataFlavorSupported(flavor)) { + throw UnsupportedFlavorException(flavor) + } + return item + } + +} + +private class ClipboardOwner(): java.awt.datatransfer.ClipboardOwner { + override fun lostOwnership(clipboard: Clipboard?, contents: Transferable?) { + /* we don't care */ + } +} + +/* xxx: this often makes a string out of a password */ +fun writeToClipboard(charArray: CharArray) { + CLIPBOARD.setContents(ClipboardData(String(charArray)), ClipboardOwner()) +} + +fun writeToClipboard(string: String) { + CLIPBOARD.setContents(ClipboardData(string), ClipboardOwner()) +} diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Console.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Console.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,50 @@ +package name.blackcap.passman + +fun readLine(prompt: String): String = + doConsoleIo({ System.console()?.readLine(prompt) }, "unable to read line") + +fun getPassword(prompt: String, verify: Boolean = false): CharArray { + while (true) { + val pw1 = _getPassword(prompt) + if (!verify) { + return pw1 + } + val pw2 = _getPassword("Verification: ") + if (pw1 contentEquals pw2) { + return pw1 + } + error("mismatch, try again") + } +} + +fun mustReadLine(prompt: String): String = must({ readLine(prompt) }, { it.isNotEmpty() }) + +fun mustGetPassword(prompt: String, verify: Boolean = false): CharArray = + must({ getPassword(prompt, verify) }, { it.isNotEmpty() }) + +fun printPassword(password: CharArray) { + print("Password: ") + password.forEach { print(it) } + println() +} + +private fun _getPassword(prompt: String): CharArray = + doConsoleIo({ System.console()?.readPassword(prompt) }, "unable to read password") + +private fun must(getter: () -> T, checker: (T) -> Boolean): T { + while (true) { + var got = getter() + if (checker(got)) { + return got + } + error("entry must not be empty, try again") + } +} + +private fun doConsoleIo(getter: () -> T?, message: String): T { + val ret = getter() + if (ret == null) { + die(message) + } + return ret!! +} diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/CreateSubcommand.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/CreateSubcommand.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,98 @@ +package name.blackcap.passman + +import org.apache.commons.cli.CommandLine +import org.apache.commons.cli.DefaultParser +import org.apache.commons.cli.Options +import org.apache.commons.cli.ParseException +import kotlin.system.exitProcess + +class CreateSubcommand(): Subcommand() { + private lateinit var commandLine: CommandLine + + override fun run(args: Array) { + val options = Options().apply { + addOption("g", "generate", false, "Use password generator.") + addOption("l", "length", true, "Length of generated password.") + addOption("s", "symbols", false, "Use symbol characters in generated password.") + addOption("v", "verbose", false, "Print the generated password.") + } + try { + commandLine = DefaultParser().parse(options, args) + } catch (e: ParseException) { + die(e.message ?: "syntax error", 2) + } + checkArguments() + val db = Database.open() + + val entry = if (commandLine.hasOption("generate")) { + val rawLength = commandLine.getOptionValue("length") + val length = try { + rawLength?.toInt() ?: DEFAULT_GENERATED_LENGTH + } catch (e: NumberFormatException) { + die("$rawLength - invalid length") + -1 /* will never happen */ + } + val symbols = commandLine.hasOption("symbols") + val verbose = commandLine.hasOption("verbose") + Entry.withGeneratedPassword(length, symbols, verbose) + } else { + Entry.withPromptedPassword() + } + val id = db.makeKey(entry.name) + + db.connection.prepareStatement("select count(*) from passwords where id = ?").use { + it.setLong(1, id) + val result = it.executeQuery() + result.next() + val count = result.getInt(1) + if (count > 0) { + die("record matching ${entry.name} already exists") + } + } + + try { + if (entry.notes.isBlank()) { + db.connection.prepareStatement("insert into passwords (id, name, username, password, created) values (?, ?, ?, ?, ?)") + .use { + it.setLong(1, id) + it.setEncryptedString(2, entry.name, db.encryption) + it.setEncryptedString(3, entry.username, db.encryption) + it.setEncrypted(4, entry.password, db.encryption) + it.setLong(5, System.currentTimeMillis()) + it.execute() + } + } else { + db.connection.prepareStatement("insert into passwords (id, name, username, password, notes, created) values (?, ?, ?, ?, ?, ?)") + .use { + it.setLong(1, db.makeKey(entry.name)) + it.setEncryptedString(2, entry.name, db.encryption) + it.setEncryptedString(3, entry.username, db.encryption) + it.setEncrypted(4, entry.password, db.encryption) + it.setEncryptedString(5, entry.notes, db.encryption) + it.setLong(6, System.currentTimeMillis()) + it.execute() + } + } + } finally { + entry.password.clear() + } + } + + private fun checkArguments(): Unit { + var bad = false + if (!commandLine.hasOption("generate")) { + for (option in listOf("length", "symbols", "verbose")) { + if (commandLine.hasOption(option)) { + error("--$option requires --generate") + bad = true + } + } + } + if (commandLine.args.isNotEmpty()) { + error("unexpected trailing arguments") + } + if (bad) { + exitProcess(2); + } + } +} diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Database.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Database.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,134 @@ +package name.blackcap.passman + +import java.nio.file.Files +import java.nio.file.Path +import java.security.GeneralSecurityException +import java.security.SecureRandom +import java.sql.* + +class Database private constructor(val connection: Connection, val encryption: Encryption){ + + companion object { + private const val PLAINTEXT = "This is a test." + private const val SALT_LENGTH = 16 + private const val DEFAULT_PROMPT = "Decryption key: " + + fun open(passwordPrompt: String = DEFAULT_PROMPT, fileName: String = DB_FILE, + create: Boolean = true): Database { + val exists = Files.exists(Path.of(fileName)) + if (!exists) { + if (create) { + error("initializing database $fileName") + } else { + die("$fileName not found") + } + } + val masterPassword = getPassword(passwordPrompt, !exists) + val conn = DriverManager.getConnection("jdbc:sqlite:$fileName") + val enc = if (exists) { reuse(conn, masterPassword) } else { init(conn, masterPassword) } + val ret = Database(conn, enc) + verifyPassword(ret) + return ret + } + + private fun reuse(connection: Connection, masterPassword: CharArray): Encryption { + try { + connection.prepareStatement("select value from blobs where name = ?").use { + it.setString(1, "salt") + val result = it.executeQuery() + if (!result.next()) { + die("corrupt database, missing salt parameter") + } + val salt = result.getBytes(1) + return Encryption(masterPassword, salt) + } + } catch (e: SQLException) { + e.printStackTrace() + die("unable to reopen database") + throw RuntimeException("this will never happen") + } + } + + private fun init(connection: Connection, masterPassword: CharArray): Encryption { + try { + connection.createStatement().use { stmt -> + stmt.executeUpdate("create table integers ( name string not null, value integer )") + stmt.executeUpdate("create table reals ( name string not null, value integer )") + stmt.executeUpdate("create table strings ( name string not null, value real )") + stmt.executeUpdate("create table blobs ( name string not null, value blob )") + stmt.executeUpdate( + "create table passwords (" + + "id integer not null primary key, " + + "name blob not null, " + + "username blob not null, " + + "password blob not null, " + + "notes blob, " + + "created integer, " + + "modified integer, " + + "accessed integer )" + ) + } + val salt = ByteArray(SALT_LENGTH).also { SecureRandom().nextBytes(it) } + val encryption = Encryption(masterPassword, salt) + connection.prepareStatement("insert into blobs (name, value) values (?, ?)").use { + it.setString(1, "salt") + it.setBytes(2, salt) + it.execute() + } + connection.prepareStatement("insert into blobs (name, value) values (?, ?)").use { stmt -> + stmt.setString(1, "test") + stmt.setEncryptedString(2, PLAINTEXT, encryption) + stmt.execute() + } + return encryption + } catch (e: SQLException) { + e.printStackTrace() + die("unable to initialize database") + throw RuntimeException("this will never happen") + } + } + + private fun verifyPassword(database: Database) { + try { + database.connection.prepareStatement("select value from blobs where name = ?").use { stmt -> + stmt.setString(1, "test") + val result = stmt.executeQuery() + if (!result.next()) { + die("corrupt database, missing test parameter") + } + val readFromDb = result.getDecryptedString(1, database.encryption) + if (readFromDb != PLAINTEXT) { + /* might also get thrown by getDecryptedString if bad */ + println(" got: " + dump(readFromDb)) + println("expected: " + dump(PLAINTEXT)) + throw GeneralSecurityException("bad key!") + } + } + } catch (e: SQLException) { + e.printStackTrace() + die("unable to verify decryption key") + } catch (e: GeneralSecurityException) { + die("invalid decryption key") + } + } + } + + fun makeKey(name: String): Long = Hashing.hash(encryption.encryptFromString0(name.lowercase())) +} + +public fun ResultSet.getDecryptedString(columnIndex: Int, encryption: Encryption) = + encryption.decryptToString(getBytes(columnIndex)) + +public fun ResultSet.getDecrypted(columnIndex: Int, encryption: Encryption) = + encryption.decrypt(getBytes(columnIndex)) + +public fun ResultSet.getDate(columnIndex: Int): java.util.Date? { + val rawDate = getLong(columnIndex) + return if (wasNull()) { null } else { java.util.Date(rawDate) } +} + +public fun PreparedStatement.setEncryptedString(columnIndex: Int, value: String, encryption: Encryption) = + setBytes(columnIndex, encryption.encryptFromString(value)) + +public fun PreparedStatement.setEncrypted(columnIndex: Int, value: CharArray, encryption: Encryption) = + setBytes(columnIndex, encryption.encrypt(value)) diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/DeleteSubcommand.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/DeleteSubcommand.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,7 @@ +package name.blackcap.passman + +class DeleteSubcommand(): Subcommand() { + override fun run(args: Array) { + println("Not yet implemented") + } +} \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Encryption.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Encryption.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,96 @@ +package name.blackcap.passman + +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import javax.security.auth.Destroyable + +class Encryption(passwordIn: CharArray, saltIn: ByteArray) : Destroyable { + private companion object { + const val ITERATIONS = 390000 + const val IV_LENGTH = 16 + const val KEY_LENGTH = 256 + const val ENCRYPTION_ALGORITHM = "AES/CBC/PKCS5Padding" + const val KEY_ALGORITHM = "AES" + const val SECRET_KEY_FACTORY = "PBKDF2WithHmacSHA256" + val CHARSET : Charset = StandardCharsets.UTF_8 + val ZERO_IV = ByteArray(IV_LENGTH).apply { clear() } + } + + private val secretKey = getSecretKey(passwordIn, saltIn) + private val secureRandom = SecureRandom() + + fun encrypt(plaintext: CharArray): ByteArray { + val iv = ByteArray(IV_LENGTH).also { secureRandom.nextBytes(it) } + return encrypt(plaintext, iv) + } + + private fun encrypt(plaintext: CharArray, iv: ByteArray): ByteArray { + val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivFactory(iv)) + val inBuffer = CHARSET.encode(CharBuffer.wrap(plaintext)) + val outBuffer = ByteBuffer.allocate(cipher.getOutputSize(inBuffer.limit()) + IV_LENGTH) + outBuffer.put(iv) + cipher.doFinal(inBuffer, outBuffer) + return outBuffer.array() + } + + fun encryptFromString(plaintext: String): ByteArray = encrypt(plaintext.toCharArray()) + + fun encryptFromString0(plaintext: String): ByteArray = + encrypt(plaintext.toCharArray(), ZERO_IV) + + fun decrypt(ciphertext: ByteArray): CharArray { + val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM) + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivFactory(ciphertext)) + val bytes = cipher.doFinal(ciphertext, IV_LENGTH, ciphertext.size - IV_LENGTH) + val charBuffer = CHARSET.decode(ByteBuffer.wrap(bytes)) + bytes.clear() + val ret = CharArray(charBuffer.limit()) + charBuffer.run { + rewind() + get(ret) + zero() + } + return ret + } + + fun decryptToString(ciphertext: ByteArray): String = String(decrypt(ciphertext)) + + override fun destroy() { + secretKey.destroy() + } + + override fun isDestroyed(): Boolean { + return secretKey.isDestroyed + } + + protected fun finalize() { + destroy() + } + + private fun getSecretKey(password: CharArray, salt: ByteArray): SecretKey { + val factory = SecretKeyFactory.getInstance(SECRET_KEY_FACTORY) + val spec = PBEKeySpec(password, salt, ITERATIONS, KEY_LENGTH) + return SecretKeySpec(factory.generateSecret(spec).encoded, KEY_ALGORITHM) + } + + private fun ivFactory(ciphertext: ByteArray) = IvParameterSpec(ciphertext, 0, IV_LENGTH) +} + +fun ByteArray.clear() = indices.forEach { this[it] = 0 } + +fun CharArray.clear() = indices.forEach { this[it] = '\u0000' } + +fun CharBuffer.zero() { + clear() + array().clear() +} \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Entry.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Entry.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,68 @@ +package name.blackcap.passman + +import java.lang.StringBuilder +import java.sql.Connection +import java.sql.PreparedStatement +import java.util.Date + +class Entry(val name: String, val username: String, val password: CharArray, val notes: String, + val created: Date? = null, val modified: Date? = null, val accessed: Date? = null) { + + companion object { + fun withPromptedPassword() = Entry( + name = _getName(), + username = _getUsername(), + password = _getPassword(), + notes = _getNotes() + ) + + fun withGeneratedPassword(length: Int, allowSymbols: Boolean, verbose: Boolean): Entry { + val generated = generate(length, allowSymbols) + if (verbose) { + println("Generated password: $generated") + } + return Entry( + name = _getName(), + username = _getUsername(), + password = generated, + notes = _getNotes() + ) + } + + private fun _getName() = mustReadLine("Name of site: ") + + private fun _getUsername() = mustReadLine("Username: ") + + private fun _getPassword() = mustGetPassword("Password: ", verify = true) + + private fun _getNotes() = readLine("Notes: ") + } + + fun print(redactPassword: String? = null) { + println("Name of site: $name") + println("Username: $username") + if (redactPassword == null) { + printPassword(password) + } else { + println("Password: $redactPassword") + } + } + + fun printLong(redactPassword: String? = null) { + print(redactPassword) + println("Notes: $notes") + printDate("Created", created) + printDate("Modified", modified) + printDate("Accessed", accessed) + } + + private fun printDate(tag: String, date: Date?) { + kotlin.io.print("${tag}: ") + if (date == null) { + println("never") + } else { + println(ISO8601.format(date)) + } + } + +} \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Files.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Files.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,77 @@ +package name.blackcap.passman + +import java.io.BufferedReader +import java.io.File +import java.io.FileInputStream +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.util.* +import kotlin.system.exitProcess + +/* OS Type */ + +enum class OS { + MAC, UNIX, WINDOWS, OTHER; + companion object { + private val rawType = System.getProperty("os.name")?.lowercase() + val type = if (rawType == null) { + OTHER + } else if (rawType.contains("win")) { + WINDOWS + } else if (rawType.contains("mac")) { + MAC + } else if (rawType.contains("nix") || rawType.contains("nux") || rawType.contains("aix") || rawType.contains("sunos")) { + UNIX + } else { + OTHER + } + } +} + +/* joins path name components to java.io.File */ + +fun joinPath(base: String, vararg rest: String) = rest.fold(File(base), ::File) + +/* file names */ + +private const val SHORTNAME = "passman" +const val MAIN_PACKAGE = "name.blackcap." + SHORTNAME +private val HOME = System.getenv("HOME") +private val PF_DIR = when (OS.type) { + OS.MAC -> joinPath(HOME, "Library", "Application Support", MAIN_PACKAGE) + OS.WINDOWS -> joinPath(System.getenv("APPDATA"), MAIN_PACKAGE) + else -> joinPath(HOME, "." + SHORTNAME) +} + +val PROP_FILE = File(PF_DIR, SHORTNAME + ".properties") +val DB_FILE: String = File(PF_DIR, SHORTNAME + ".db").absolutePath + +/* make some needed directories */ + +private fun File.makeIfNeeded() = if (exists()) { true } else { mkdirs() } + +/* make some usable objects */ + +val DPROPERTIES = Properties().apply { + OS::class.java.getResourceAsStream("/default.properties").use { load(it) } +} + +val PROPERTIES = Properties(DPROPERTIES).apply { + PF_DIR.makeIfNeeded() + PROP_FILE.createNewFile() + BufferedReader(InputStreamReader(FileInputStream(PROP_FILE), StandardCharsets.UTF_8)).use { + load(it) + } +} + +/* error messages */ + +fun error(message: String) { + System.err.println("${SHORTNAME}: ${message}") +} + +fun die(message: String, exitStatus: Int = 1) { + error(message) + exitProcess(exitStatus) +} + diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Generate.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Generate.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,61 @@ +package name.blackcap.passman + +import java.lang.IllegalArgumentException +import java.security.SecureRandom +import java.util.Random + +/* ASCII, alphanumeric, no 0's, 1's, I's, O's or l's to avoid confusion. */ +private const val DIGITS = "23456789" +private const val UPPERS = "ABCDEFGHJKLMNPQRSTUVWXYZ" +private const val LOWERS = "abcdefghijkmnopqrstuvwxyz" +/* \ can confuse *NIX, |, ', and ` invite confusion */ +private const val SYMBOLS = "!\"#$%&()*+,-./:;<=>?@[]^_{}~" +const val MIN_GENERATED_LENGTH = 6 +const val DEFAULT_GENERATED_LENGTH = 12 + +fun generate(length: Int, allowSymbols: Boolean): CharArray { + /* insecure, and if REALLY short, causes bugs */ + if (length < MIN_GENERATED_LENGTH) { + throw IllegalArgumentException("length of $length is less than $MIN_GENERATED_LENGTH") + } + + /* determine allowed characters */ + val passchars = if (allowSymbols) { + DIGITS + UPPERS + LOWERS + SYMBOLS + } else { + DIGITS + UPPERS + LOWERS + } + + /* ensure we get one of each class of characters */ + val randomizer = SecureRandom() + val generated = CharArray(length) + generated[0] = randomized(DIGITS, randomizer) + generated[1] = randomized(UPPERS, randomizer) + generated[2] = randomized(LOWERS, randomizer) + var i = 3 + if (allowSymbols) { + generated[3] = randomized(SYMBOLS, randomizer) + i = 4 + } + + /* generate the rest of the characters */ + while (i < length) { + generated[i++] = randomized(passchars, randomizer) + } + + /* scramble them */ + for (i in 0 until length) { + val j = randomizer.nextInt(length) + if (i != j) { + val temp = generated[i] + generated[i] = generated[j] + generated[j] = temp + } + } + + /* AMF... */ + return generated +} + +private fun randomized(passchars: String, randomizer: Random): Char = + passchars[randomizer.nextInt(passchars.length)] diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Hashing.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Hashing.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,34 @@ +package name.blackcap.passman + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.CharBuffer +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +object Hashing { + private const val HASHING_ALGORITHM = "MD5" + private val CHARSET: Charset = StandardCharsets.UTF_8 + + fun hash(bytes: ByteBuffer): Long { + val md = MessageDigest.getInstance(HASHING_ALGORITHM) + bytes.rewind() + md.update(bytes) + return toLong(md.digest()) + } + + fun hash(bytes: ByteArray): Long = + hash(ByteBuffer.wrap(bytes)) + + fun hash(chars: CharArray): Long = + hash(CHARSET.encode(CharBuffer.wrap(chars))) + + fun hash(string: String): Long = + hash(CHARSET.encode(string)) + + private fun toLong(hash: ByteArray): Long = ByteBuffer.wrap(hash).run { + order(ByteOrder.LITTLE_ENDIAN) + getLong(hash.size - 8) + } +} \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,15 @@ +package name.blackcap.passman + +class HelpSubcommand(): Subcommand() { + override fun run(args: Array) { + println("PassMan: a password manager") + println("Available subcommands:") + println("create Create a new username/password pair.") + println("read Retrieve data from existing record.") + println("update Update existing record.") + println("delete Delete existing record.") + println("help Print this message.") + println("list List records.") + println("merge Merge passwords in from another PassMan database.") + } +} \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Main.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Main.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,45 @@ +package name.blackcap.passman + +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.* +import java.util.stream.Collectors +import kotlin.reflect.jvm.javaMethod +import kotlin.reflect.jvm.kotlinFunction +import kotlin.system.exitProcess + +fun main(args: Array) { + if (args.isEmpty()) { + error("expecting subcommand") + exitProcess(2) + } + val subcommand = args[0]; + val scArgs = args.sliceArray(1 until args.size) + runSubcommand(subcommand, scArgs) +} + +fun runSubcommand(name: String, args: Array): Unit { + val instance = getInstanceForClass(getClassForSubcommand(name)) + if (instance == null) { + die("$name - unknown subcommand", 2) + } else { + instance.run(args) + } +} + +fun getClassForSubcommand(name: String): Class? = try { + val shortName = name.replace('-', '_') + .lowercase() + .replaceFirstChar { it.titlecase(Locale.getDefault()) } + Class.forName("$MAIN_PACKAGE.${shortName}Subcommand") as? Class + /* val ret = Class.forName("$MAIN_PACKAGE.$shortName") + if (ret.isInstance(Subcommand::class.java)) { ret as Class } else { null } */ +} catch (e: ClassNotFoundException) { + null +} + +fun getInstanceForClass(klass: Class?) = try { + klass?.getDeclaredConstructor()?.newInstance() +} catch (e: ReflectiveOperationException) { + null +} diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,66 @@ +package name.blackcap.passman + +import org.apache.commons.cli.CommandLine +import org.apache.commons.cli.DefaultParser +import org.apache.commons.cli.Options +import org.apache.commons.cli.ParseException + +class ReadSubcommand(): Subcommand() { + private lateinit var commandLine: CommandLine + + override fun run(args: Array) { + val options = Options().apply { + addOption("c", "clipboard", false, "Copy username and password into clipboard.") + addOption("l", "long", false, "Long format listing.") + } + try { + commandLine = DefaultParser().parse(options, args) + } catch (e: ParseException) { + die(e.message ?: "syntax error", 2) + } + val clipboard = commandLine.hasOption("clipboard") + val long = commandLine.hasOption("long") + if (commandLine.args.size != 1) { + die("expecting site name", 2) + } + val nameIn = commandLine.args[0]; + val db = Database.open() + val id = db.makeKey(nameIn) + + db.connection.prepareStatement("select name, username, password, notes, created, modified, accessed from passwords where id = ?").use { + it.setLong(1, id) + val result = it.executeQuery() + if (!result.next()) { + die("no record matches $nameIn") + } + val entry = Entry( + name = result.getDecryptedString(1, db.encryption), + username = result.getDecryptedString(2, db.encryption), + password = result.getDecrypted(3, db.encryption), + notes = result.getDecryptedString(4, db.encryption), + created = result.getDate(5), + modified = result.getDate(6), + accessed = result.getDate(7) + ) + try { + val redaction = if (clipboard) { "(in clipboard)" } else { null } + if (long) { + entry.printLong(redaction) + } else { + entry.print(redaction) + } + if (clipboard) { + writeToClipboard(entry.password) + } + } finally { + entry.password.clear() + } + } + + db.connection.prepareStatement("update passwords set accessed = ? where id = ?").use { + it.setLong(1, System.currentTimeMillis()) + it.setLong(2, id) + it.execute() + } + } +} \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/See.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/See.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,56 @@ +package name.blackcap.passman + +import java.util.Formatter + +private val UNPRINTABLE = setOf( + Character.UnicodeBlock.COMBINING_DIACRITICAL_MARKS, + Character.UnicodeBlock.COMBINING_DIACRITICAL_MARKS_EXTENDED, + Character.UnicodeBlock.COMBINING_HALF_MARKS, + Character.UnicodeBlock.COMBINING_DIACRITICAL_MARKS_SUPPLEMENT, + Character.UnicodeBlock.COMBINING_MARKS_FOR_SYMBOLS, + Character.UnicodeBlock.HIGH_PRIVATE_USE_SURROGATES, + Character.UnicodeBlock.HIGH_SURROGATES, + Character.UnicodeBlock.LOW_SURROGATES, + Character.UnicodeBlock.PRIVATE_USE_AREA, + Character.UnicodeBlock.SPECIALS, + +) + +private val DELIM = '"' + +private val EXEMPT = setOf(' ') +private val PREFIXED = setOf(DELIM, '\\') + +fun see(input: String): String { + val accum = StringBuilder() + val formatter = Formatter(accum) + accum.append(DELIM) + for (ch in input) { + val block = Character.UnicodeBlock.of(ch) + if (ch in EXEMPT) { + accum.append(ch) + } else if (block == null || block in UNPRINTABLE || Character.isSpaceChar(ch) || Character.isWhitespace(ch)) { + formatter.format("\\u%04x", ch.code) + } else if (ch in PREFIXED) { + accum.append('\\') + accum.append(ch) + } else { + accum.append(ch) + } + } + accum.append(DELIM) + return accum.toString() +} + +fun dump(input: String): String { + val accum = StringBuilder() + var needSpace = false + for (ch in input) { + if (needSpace) { + accum.append(' ') + } + accum.append(ch.code) + needSpace = true + } + return accum.toString() +} diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Subcommand.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Subcommand.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,5 @@ +package name.blackcap.passman + +abstract class Subcommand() { + abstract fun run(args: Array): Unit +} diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/Time.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Time.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,5 @@ +package name.blackcap.passman + +import java.text.SimpleDateFormat + +val ISO8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") diff -r 000000000000 -r a6cfdffcaa94 src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,150 @@ +package name.blackcap.passman + +import org.apache.commons.cli.CommandLine +import org.apache.commons.cli.DefaultParser +import org.apache.commons.cli.Options +import org.apache.commons.cli.ParseException +import java.sql.Types +import java.util.* +import kotlin.properties.Delegates +import kotlin.system.exitProcess + +class UpdateSubcommand(): Subcommand() { + private lateinit var commandLine: CommandLine + private lateinit var db: Database + private lateinit var nameIn: String + private var id by Delegates.notNull() + private var length by Delegates.notNull() + private var generate by Delegates.notNull() + private var allowSymbols by Delegates.notNull() + private var verbose by Delegates.notNull() + private val fields = StringBuilder() + private val fieldValues = mutableListOf() + + private companion object { + const val NULL_SPECIFIED = "." + val NULLABLE_FIELDS = setOf("notes") + val SENSITIVE_FIELDS = setOf("password") + } + + override fun run(args: Array) { + parseArguments(args) + checkDatabase() + update() + } + + private fun parseArguments(args: Array) { + val options = Options().apply { + addOption("g", "generate", false, "Use password generator.") + addOption("l", "length", true, "Length of generated password.") + addOption("s", "symbols", false, "Use symbol characters in generated password.") + addOption("v", "verbose", false, "Print the generated password.") + } + try { + commandLine = DefaultParser().parse(options, args) + } catch (e: ParseException) { + die(e.message ?: "syntax error", 2) + } + checkArguments() + db = Database.open() + nameIn = commandLine.args[0] + id = db.makeKey(nameIn) + length = commandLine.getOptionValue("length").let { rawLength -> + try { + rawLength?.toInt() ?: DEFAULT_GENERATED_LENGTH + } catch (e: NumberFormatException) { + die("$rawLength - invalid length") + -1 /* will never happen */ + } + } + generate = commandLine.hasOption("generate") + allowSymbols = commandLine.hasOption("symbols") + verbose = commandLine.hasOption("verbose") + } + + private fun checkArguments(): Unit { + var bad = false + if (!commandLine.hasOption("generate")) { + for (option in listOf("length", "symbols", "verbose")) { + if (commandLine.hasOption(option)) { + error("--$option requires --generate") + bad = true + } + } + } + if (commandLine.args.isEmpty()) { + error("expecting site name") + } + if (commandLine.args.size > 1) { + error("unexpected trailing arguments") + } + if (bad) { + exitProcess(2); + } + } + + private fun checkDatabase(): Unit { + db.connection.prepareStatement("select count(*) from passwords where id = ?").use { + it.setLong(1, id) + val result = it.executeQuery() + result.next() + val count = result.getInt(1) + if (count < 1) { + die("no record matches $nameIn") + } + } + } + + private fun update(): Unit { + updateOne("username") + updateOne("password") + updateOne("notes") + if (fieldValues.isEmpty()) { + error("no values changed") + return + } + + db.connection.prepareStatement("update passwords set updated = ?, $fields where id = ?").use { stmt -> + stmt.setLong(1, System.currentTimeMillis()) + fieldValues.indices.forEach { fieldIndex -> + val fieldValue = fieldValues[fieldIndex] + val columnIndex = fieldIndex + 2 + when (fieldValue) { + is String -> stmt.setEncryptedString(columnIndex, fieldValue, db.encryption) + is CharArray -> stmt.setEncrypted(columnIndex, fieldValue, db.encryption) + null -> stmt.setNull(columnIndex, Types.BLOB) + else -> throw RuntimeException("this shouldn't happen") + } + } + stmt.setLong(fieldValues.size + 2, id) + stmt.execute() + } + } + + private fun updateOne(name: String): Unit { + val prompt = name.replaceFirstChar { it.titlecase(Locale.getDefault()) } + ": " + val value: Any? = if (name in SENSITIVE_FIELDS) { + getPassword(prompt, verify = true) + } else { + val rawValue = readLine(prompt) + if (name in NULLABLE_FIELDS && rawValue == NULL_SPECIFIED) { null } else { rawValue } + } + + val noChange = when (value) { + is String -> value.isEmpty() + is CharArray -> value.isEmpty() + else -> false + } + if (noChange) { + return + } + + if (fields.isNotEmpty()) { + fields.append(", ") + } + fields.append(name) + fields.append(" = ?") + fieldValues.add(value) + } + +} \ No newline at end of file diff -r 000000000000 -r a6cfdffcaa94 src/main/resources/META-INF/MANIFEST.MF --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/resources/META-INF/MANIFEST.MF Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Main-Class: name.blackcap.passman.MainKt + diff -r 000000000000 -r a6cfdffcaa94 src/main/resources/default.properties --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/resources/default.properties Sun Sep 11 16:11:37 2022 -0700 @@ -0,0 +1,2 @@ +# This is a blank file that is not currently used for any properties, +# but the code expects it to exist.