-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC: Create SecretBuffer and use it to help keep LibGit2's secrets #27565
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fantastic work! You found some good solutions to some issues I was struggling with. The IO based SecureBuffer
design worked out really well. I'll do some further testing yet.
stdlib/LibGit2/src/types.jl
Outdated
@@ -1180,7 +1180,7 @@ function objtype(obj_type::Consts.OBJECT) | |||
end | |||
end | |||
|
|||
import Base.securezero! | |||
import Base.Base.shred! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Find replace issue?
stdlib/LibGit2/src/gitcredential.jl
Outdated
while !eof(io) && (c = read(io, UInt8)) != UInt8('\n') | ||
write(value, c) | ||
end | ||
seek(value, 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personal preference but I prefer seekstart
result = let | ||
$code | ||
result = open($input_code) do fp | ||
eval(deserialize(fp)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this change now necessary?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if it's still necessary, but it definitely was at one point and it seems like a good improvement. Before we were trusting that the string representation of a expression was round-trippable. This isn't just Expr
s, it also includes embedded credential objects spliced directly into the AST. If their stringification isn't round-trippable, this fails.
stdlib/LibGit2/test/libgit2.jl
Outdated
@@ -318,27 +323,26 @@ end | |||
url = LibGit2.git_url( | |||
scheme="https", | |||
username="user", | |||
password="pass", | |||
# password="pass", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should just remove these lines
test/strings/secure.jl
Outdated
@@ -0,0 +1,45 @@ | |||
## Secure strings ## |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test file is out of date now. I appear to have forgotten to add this into the test suite...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh man, I totally missed this — thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's a modernized version of this test suite:
using Base: SecretBuffer, SecretBuffer!, shred!, isshredded
using Base.Test
@testset "SecretBuffer" begin
@testset "original unmodified" begin
str = "foobar"
secret = SecretBuffer(str)
@test read(secret, String) == str
seekstart(secret)
@test shred!(secret) === secret
@test read(secret, String) != ""
@test str != "foobar"
end
@testset "finalizer" begin
v = UInt8[1, 2]
secret_a = SecretBuffer!(v)
secret_b = secret_a
secret_a = nothing
GC.gc()
@test all(iszero, v)
@test !isshredded(secret_b)
end
end
stdlib/LibGit2/test/libgit2.jl
Outdated
@@ -2424,7 +2525,10 @@ mktempdir() do dir | |||
@test err == exhausted_error | |||
@test auth_attempts == 3 | |||
@test p.explicit == invalid_cred | |||
@test p.credential != invalid_cred | |||
# @test p.credential != invalid_cred # TODO!!! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll try to look into this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests can be re-enabled if you set the non-secret fields of UserPasswordCredential
and SSHCredential
to ""
in their respective shred!
methods.
GitCredential(cred::UserPasswordCredential, url::AbstractString) = parse(GitCredential, url) | ||
|
||
Base.:(==)(c1::GitCredential, c2::GitCredential) = (c1.protocol, c1.host, c1.path, c1.username, c1.password, c1.use_http_path) == | ||
(c2.protocol, c2.host, c2.path, c2.username, c2.password, c2.use_http_path) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My personal preference is to use ==
and &&
over multiple lines
base/secretbuffer.jl
Outdated
|
||
Initialize a new `SecretBuffer` with `data` and securely zero the original source argument. | ||
""" | ||
SecretBuffer!(s::Cstring) = SecretBuffer!(convert(Ptr{UInt8}, s)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great use of !
. Highly approve!
I totally agree. I have seen these dungeons you speak of and dare not speak of them myself. |
CI failing due to:
|
base/secretbuffer.jl
Outdated
|
||
An IOBuffer-like object where the contents will be securely wiped when garbage collected. However, it is | ||
considered best practice to wipe the buffer using `Base.shred!(::SecretBuffer)` as soon as the | ||
secure data are no longer required. Avoid initializing with converting to strings as they are |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Avoid initializing with converting..." reads poorly. Perhaps first mention that they can be initialized, then add the note to avoid String second?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missed an "and" there — I was intending: "Avoid initializing with and converting to". Good point about swapping ordering here.
base/secretbuffer.jl
Outdated
convert(::Type{SecretBuffer}, s::AbstractString) = SecretBuffer(String(s)) | ||
SecretBuffer(str::AbstractString) = SecretBuffer(String(str)) | ||
function SecretBuffer(str::String) | ||
buf = unsafe_wrap(Vector{UInt8}, str) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use codepoints
base/secretbuffer.jl
Outdated
Initialize a new `SecretBuffer` with `data` and securely zero the original source argument. | ||
""" | ||
SecretBuffer!(s::Cstring) = SecretBuffer!(convert(Ptr{UInt8}, s)) | ||
function SecretBuffer!(p::Ptr{UInt8}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be called unsafe_SecretBuffer!
, calling our normal naming convention, and should take a length argument (with a default value of calling :strlen
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ugh, that's ugly. But I see your point.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely ugly
base/secretbuffer.jl
Outdated
function SecretBuffer!(d::Vector{UInt8}) | ||
s = SecretBuffer(sizehint=length(d)) | ||
for i in 1:len | ||
write(s, unsafe_load(p, i)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p
isn't defined. missing test coverage?
base/secretbuffer.jl
Outdated
end | ||
|
||
function unsafe_convert(::Type{Ptr{UInt8}}, s::SecretBuffer) | ||
# Add a hidden nul byte just past the end of the valid region |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's usually only applicable for conversion to ::Type{Cstring}
(and also should check that there are no embedded \nul
values)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes, that'd be much better — I'll change this to a conversion to Cstring
and fixup the accompanying ccalls.
stdlib/LibGit2/src/gitcredential.jl
Outdated
cred.username !== nothing && securezero!(cred.username) | ||
cred.password !== nothing && securezero!(cred.password) | ||
function Base.shred!(cred::GitCredential) | ||
cred.protocol !== nothing && (cred.protocol = nothing) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the check for nothing
is unnecessary
stdlib/LibGit2/src/gitcredential.jl
Outdated
a.host = b.host | ||
a.path = b.path | ||
a.username = b.username | ||
a.password = b.password |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like this still should be copy(b.password)
stdlib/LibGit2/src/gitcredential.jl
Outdated
if key == "url" | ||
# Any components which are missing from the URL will be set to empty | ||
# https://git-scm.com/docs/git-credential#git-credential-codeurlcode | ||
Base.shred!(cred) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like this can be part of the copy!
definition
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case yes but the copy!
method could be used for duplication where you do not want to destroy the source.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't cred
the destination?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes it is. I had some wires crossed. Your suggestion is good 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had to go check myself too. That's perhaps a good point too though, that we should destroy the source
argument? Maybe take!(dest, src)
for that, or just do it manually.
stdlib/LibGit2/src/types.jl
Outdated
c = new(user, pass) | ||
finalizer(securezero!, c) | ||
return c | ||
user::AbstractString |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
keep String
stdlib/LibGit2/src/types.jl
Outdated
c = new(user, pass, prvkey, pubkey) | ||
finalizer(securezero!, c) | ||
return c | ||
user::AbstractString |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
keep as String
base/secretbuffer.jl
Outdated
end | ||
|
||
function final_shred!(s::SecretBuffer) | ||
if !isshredded(s) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it would likely be cheaper and faster to call shred!
unconditionally
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. Originally this code issued a warning to alert developers that they weren't explicitly calling shred!
base/secretbuffer.jl
Outdated
|
||
An IOBuffer-like object where the contents will be securely wiped when garbage collected. However, it is | ||
considered best practice to wipe the buffer using `Base.shred!(::SecretBuffer)` as soon as the | ||
secure data are no longer required. Avoid initializing with converting to strings as they are |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"secure data is no longer required"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a typo
base/secretbuffer.jl
Outdated
end | ||
function SecretBuffer!(d::Vector{UInt8}) | ||
s = SecretBuffer(sizehint=length(d)) | ||
for i in 1:len |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
len
not defined
key, value = split(readline(io), '=') | ||
|
||
key = readuntil(io, '=') | ||
if key == "password" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We may also want to do this if key == "url"
since that URL could contain a password
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We still parse that URL with a regex which stashes things into Strings. I decided to just consider URLs insecure, but perhaps we should re-write that, too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That approach makes sense. I think we should add a comment which calls the potential security issue and leave it at that. The "url" key doesn't seem commonly used so it isn't a big concern for me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any ideas as to where this would most make sense to document? Another alternative would just be to completely not support passwords in URLs. We'd just throw an error if m[:password]
exists. I kinda like that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it makes sense to open an issue about this and reference the issue below if key == "url"
. I believe we can address the issue later by rewriting the URL parsing and reading in the password with a SecretBuffer
. We can try to tackle this now but I don't want to add extra work for you.
Raising an error will still result in the password information being stored in a string which is no better than what we currently have.
Thanks for the very thorough reviews Jameson and Curtis! |
I somewhat cannot believe that this passes tests on Windows as I've done all the win-specific changes blindly. Is there reasonable test coverage that I can trust that? Or should I hunt down a windows VM to do some extra interactive testing myself? |
I usually call things good enough if tests pass, but ping me on slack for access to a Windows test VM if you want. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're a beast. Looks great to me.
base/secretbuffer.jl
Outdated
|
||
convert(::Type{SecretBuffer}, s::AbstractString) = SecretBuffer(String(s)) | ||
SecretBuffer(str::AbstractString) = SecretBuffer(String(str)) | ||
function SecretBuffer(str::String) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it bears creating a docstring for the ::String
constructor that mentions that the string will not be zeroed out.
base/secretbuffer.jl
Outdated
""" | ||
SecretBuffer!(data::Vector{UInt8}) | ||
|
||
Initialize a new `SecretBuffer` with `data` and securely zero the original source argument. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The way this is phrased makes it sound like data
and "the original source argument" are two different things? Perhaps rephrase to something like "Initialize a new SecretBuffer
from data
, securely zeroing it afterwards."
a.host = b.host | ||
a.path = b.path | ||
a.username = b.username | ||
a.password = b.password == nothing ? nothing : copy(b.password) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like copy(nothing)
should just return nothing
. :P
base/secretbuffer.jl
Outdated
show(io::IO, s::SecretBuffer) = print(io, "SecretBuffer(\"*******\")") | ||
|
||
hash(s::SecretBuffer, h::UInt) = hash(SecretBuffer, hash((s.data, s.size, s.ptr), h)) | ||
==(s1::SecretBuffer, s2::SecretBuffer) = (view(s1.data, 1:s1.size), s1.ptr) == (view(s2.data, 1:s2.size), s2.ptr) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know that secretbuffer is meant for uninitialized memory disclosure scenarios and the following does not matter for use in libgit2. Nevertheless, I think it would be good practice to make comparison constant time for something called secretbuffer. Somebody will eventually misuse it to compare a secret to an attacker-supplied string and leak the secret via timing side-channel.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A simple sample implementation could be (no early return, no branching, no conditional moves, @noinline
to prevent the compiler from being clever):
@noinline function _bufcmp(data1::Vector{UInt8}, data2::Vector{UInt8}, sz::Int)
res = UInt8(0)
for i = 1:sz
res |= xor(data1[i], data2[i])
end
return res
end
==(s1::SecretBuffer, s2::SecretBuffer) = (s1.ptr == s2.ptr) && (s1.size == s2.size) && (UInt8(0) == _bufcmp(s1.data, s2.data, s1.size))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good point. These methods are also just strange in the first place. They break with the convention for other IO subtypes — those don't do equality by value. Plus I just now noticed there's a bug in the agreement between hash and ==.
I'd love to figure out how to remove them, but they are used both in LibGit2's source and its tests and I don't have a good replacement.
What's missing to merge this? |
Maybe |
base/secretbuffer.jl
Outdated
return byte | ||
end | ||
|
||
final_shred!(s::SecretBuffer) = shred!(s) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't it log a warning or something if !isshredded(s)
? Waiting until gc runs seems like a bad idea.
FreeBSD CI got a segfault. seems related. |
Ref. #27702 regarding likely cause of the segfault. |
Originally used `isequal` to deal with `Nullable`
Move lower in the bootstrap order to allow `@warn`.
bba1513
to
3e8960f
Compare
Ok, once CI gives this the green-light I think it's good to go. I believe I've addressed all review comments now. |
base/util.jl
Outdated
object containing the secret. | ||
|
||
Note that on Windows, the secret might be displayed as it is typed; see | ||
`Base.winprompt` for securely retrieving username/password pairs from a secure |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
winprompt
seems misnamed now… should it be wingetpass
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(also, the word "secure" appears in this sentence twice, which seems a bit redundant)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It returns a username/password pair. It's more like wingetcredentials
or some such. But these are all un-exported functions and can be changed down the line. I'm inclined to leave it be.
base/util.jl
Outdated
@@ -467,36 +479,30 @@ function getpass(prompt::AbstractString) | |||
end | |||
else | |||
function getpass(prompt::AbstractString) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should prompt
have a default value, e.g. "Password"
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It can always be added later if someone else feels strongly about it.
base/util.jl
Outdated
if Sys.iswindows() | ||
function getpass(prompt::AbstractString) | ||
print(prompt) | ||
print(prompt, ':') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
": "
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The extra space there doesn't matter all that much because (ideally) the typed input doesn't get echo'ed back. In any case, this was required to match Base.prompt
's behavior, which doesn't use a space either.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be nice to have something analogous to the Python Keyring library, but I guess that could be an external package. |
These julia/stdlib/Pkg/src/Operations.jl Line 433 in c1eb3e8
Line 420 in c1eb3e8
Line 488 in c1eb3e8
Line 793 in c1eb3e8
|
Oh interesting; perhaps I should add a warning if those credential wrapper objects are shredded by the finalizer, too? That's why Edit: no, that's wrong — none of the LibGit2 credential objects have finalizers. Perhaps our test suite just doesn't exercise those methods with repos that require passwords? |
base/util.jl
Outdated
@@ -459,7 +459,7 @@ function getpass end | |||
|
|||
if Sys.iswindows() | |||
function getpass(prompt::AbstractString) | |||
print(prompt, ':') | |||
print(prompt, ": ") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Honestly, it seems a bit weird to me that we add a colon at all. Can't we let the caller decide this? For example, what if they want to display a prompt "True (y/n)? "
or "Enter password here => "
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It all stems from the behavior of Base.prompt
, where you can also specify a default, which will appear as message [default]:
.
0645771
to
c9b9cb8
Compare
I would guess that the Pkg tests do not target repos requiring passwords. In that case the |
2358901
to
9ab9f63
Compare
9ab9f63
to
5a50d9b
Compare
There we go, finally some green checks are starting to roll in from CI. I propose we merge this as it stands so Keno can finally tag a beta. |
This appears to have caused an issue with using credential helpers. I think @omus is investigating. |
@@ -107,6 +111,11 @@ provided the URL produced will use the alternative [scp-like syntax](https://git | |||
provided. Cannot be specified when using the scp-like syntax. | |||
* `path::AbstractString=""`: the path to use in the output if provided. | |||
|
|||
!!!warning |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs a space between !
and warning
for it to be recognized correctly. Currently it shows up literally as !!!warning
in the docstring rather than being rendered nicely.
This PR is building upon @omus' #24738, transitioned to using an
IO
-like API instead of anAbstractString
one. It definitely makes some things more awkward, but on the plus side, it also has made me vet and correct many places where we were still transitioning the secrets throughString
s in LibGit2.It has the major downside that the returned object fromEdit:Base.prompt
changes drastically depending upon thepassword
keyword.Base.prompt
andBase.getpass
are now detangled.I initially tried following the approach in #24738, wherein all the credential information (including usernames and key paths) is considered secret. This proved to be horribly cumbersome and a huge dungeon of kludges and work-arounds. This PR now only considers the passwords themselves to be secret — and that works quite well.