protect

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 22, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

README

ferry/windows/protect

Encrypt the values a struct marks as secret on their way into a plane, and decrypt them on the way back out, with Windows DPAPI-NG.

import "github.com/onhotpath/ferry/driver/windows/protect"

It is a decorator and not a plane. It wraps somebody else's ferry.Source and ferry.Sink - the registry, a key-value store, a file - and changes what is stored at the addresses a field marked, leaving every other address and everything else about that plane alone. NCryptProtectSecret maps bytes to bytes: it has no namespace, nothing to enumerate and nothing to address, so there is no plane here to be.

Read this part first

This is a large improvement over plaintext. It is not a vault.

Decrypting a value requires being the principal the descriptor names, on the machine that encrypted it. That stops another account on the machine reading the store, and it stops a copy of the store being read anywhere else.

An attacker who takes the machine's own key material recovers the value offline, at their leisure - for LOCAL= descriptors that is the DPAPI master key material under the SYSTEM registry hive and the boot key. Anyone with administrator rights on a live machine can also simply run as the principal the descriptor names and ask for the plaintext, which is what the descriptor says they may do. If your threat model includes either of those, you want a key you hold somewhere else and this package is not it.

What it retires is the common Go-on-Windows mistake: classic DPAPI at CRYPTPROTECT_LOCAL_MACHINE with no entropy, written to a file whose access control list grants read to everyone. Machine-scope DPAPI grants decryption to every principal on the machine, so the ACL is the only thing keeping the value in - and that list is usually inherited from a parent directory rather than chosen. protect.CurrentUser says what classic machine scope cannot: this value is for the account that wrote it and for nothing else.

Which descriptor works where

A descriptor that names a security principal needs a domain. SID= and SDDL= rules are resolved by the Microsoft Key Protection Provider through Active Directory's key distribution service, so they work on a domain-joined machine and, on a machine that is not joined to a domain, NCryptProtectSecret fails at the first save with NTE_ENCRYPTION_FAILURE (0x80090034). LOCAL= rules are resolved by the machine itself and need no domain at all.

This package ships three constants, and the third is not the default:

constant rule string who can decrypt needs a domain
protect.CurrentUser LOCAL=user the account the process runs as, on this machine no
protect.LocalMachine LOCAL=machine every account on this machine no
protect.LocalSystem SID=S-1-5-18 the local system account, on this machine yes

Start from protect.CurrentUser. A service running as the local system account and protecting under LOCAL=user gets exactly what SID=S-1-5-18 promises - the value is for SYSTEM on this machine and for nothing else - and it gets it on a standalone machine too. The sharp edge is that the principal is whoever runs the process: run the same program by hand as an ordinary user and the value is protected to that user, and the service will not be able to read it back.

protect.LocalMachine is not an improvement on classic machine scope in access-control terms. Windows documents LOCAL=machine as protecting content to the local computer so that all users on it can decrypt, which is the same grant CRYPTPROTECT_LOCAL_MACHINE gives. Reach for it only where more than one account genuinely has to read the value, and know that the store's ACL is then the only thing narrowing it down.

protect.LocalSystem survives as a constant because it is the right answer on a domain-joined machine, where the blob is bound to a key the domain issued and the principal is named rather than implied. It is not the default, because most unattended services this package is aimed at do not run on a domain-joined machine, and there it fails every Dump - loudly, naming the status and the reason, but every one.

There is also LOCAL=logon, which this package does not ship a constant for: it protects to the current logon session and Windows documents the value as undecryptable after logoff or reboot, which is not what a configuration store is for. Any DPAPI-NG rule string can be passed as a protect.Descriptor, including certificate and web-credential rules.

Which addresses are protected, and why the tag

Selection is a struct tag key, protect, with one word, secret, and the caller declares the key on the registry:

type Config struct {
    Auth struct {
        RefreshToken string `ferry:"refresh_token" protect:"secret"`
    } `ferry:"auth"`
}

var Registry = ferry.MustRegistry(ferry.WithTagKeys(protect.Extension()))

src := protect.Over(store, protect.CurrentUser, protect.FromTags())
cfg, err := ferry.Load[Config](ctx, src, ferry.WithRegistry(Registry))

Secrecy is a property of the field. A refresh token is a secret on every plane, in every environment, forever, which is author-side knowledge and belongs where author-side knowledge goes.

A list of addresses handed to the constructor instead would go stale twice over. A rename of the ferry tag breaks it, which can at least be caught at Bind. A secret field added to the struct and not to the list is written in plaintext, and nothing anywhere can detect that. The tag travels on the field, so both problems are structurally absent.

There is no word that takes protection away. No protect:"plaintext", and there must never be one: two sources of truth that can contradict each other about whether a value is a secret can only be reconciled safely in one direction, so this vocabulary has nothing to contradict.

The one mistake it refuses to let you make

A tag key nobody declared is another library's business and parses to nothing. So a caller who wrapped the source with protect and forgot ferry.WithTagKeys(protect.Extension()) would get an empty view, every protect:"secret" would be inert, and every marked value would be written in the clear - indistinguishable, from inside this package, from a struct that marks nothing.

FromTags refuses at Bind instead, before any read or write, with protect.ErrNotDeclared wrapping ferry.ErrPlane:

func ExampleFromTags() {
	store, keeper := newStore(), newKeeper()

	// No ferry.WithTagKeys(protect.Extension()) anywhere, so every protect tag
	// in the struct is another library's business and parses to nothing.
	dst := protect.OverSink(storeSink{s: store}, protect.CurrentUser, protect.FromTags(), protect.Using(keeper))

	err := ferry.Dump(context.Background(), Settings{Auth: Credentials{RefreshToken: "s3cr3t"}}, dst)

	fmt.Println("refused:", errors.Is(err, protect.ErrNotDeclared))
	fmt.Println("and the store is untouched:", store.empty())

	// Output:
	// refused: true
	// and the store is untouched: true
}

A registry that did declare the key over a struct that marks nothing is not a refusal. That is a schema with no secrets in it, which is a perfectly ordinary thing to run a protected source over, and telling the two apart is the whole reason AddressSet.Extension reports whether the key was declared.

Loading and saving

func Example() {
	store, keeper := newStore(), newKeeper()
	registry := ferry.MustRegistry(ferry.WithTagKeys(protect.Extension()))

	src := protect.Over(storeSource{s: store}, protect.CurrentUser, protect.FromTags(), protect.Using(keeper))
	dst := protect.OverSink(storeSink{s: store}, protect.CurrentUser, protect.FromTags(), protect.Using(keeper))

	want := Settings{Auth: Credentials{RefreshToken: "s3cr3t"}, Host: "example.internal"}

	if err := ferry.Dump(context.Background(), want, dst, ferry.WithRegistry(registry)); err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println("the store holds the token in the clear:", store.holds("s3cr3t"))
	fmt.Println("the host is stored as it always was:", rendered(store.at(ferry.At("host"))))

	got, err := ferry.Load[Settings](context.Background(), src, ferry.WithRegistry(registry))
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println("and it loads back as:", got.Auth.RefreshToken)

	// Output:
	// the store holds the token in the clear: false
	// the host is stored as it always was: example.internal
	// and it loads back as: s3cr3t
}

Both halves take the same descriptor, the same selector and the same protect.Using. A sink protecting under one descriptor and a source unprotecting through another never meet, and nothing checks that the two agree.

Over and OverSink are two constructors rather than one, for the reason a plane's own driver ships two types: a ferry.Source and a ferry.Sink are two interfaces with one method name, and one value cannot have two Bind methods. A plane with a read half and no honest write half is decorated by calling OverSink not at all.

What is stored, and what survives the round trip

A protected value is stored as a string: a marker, then the ciphertext, base64 encoded.

ferry-protect:1:<base64>

A string rather than bytes, because this decorator composes with a plane it has never heard of: every plane that carries anything carries text, and a plane may hand text back as bytes where the field is a []byte. So the marker is looked for in a String and in a Bytes, and what is written is always a String.

The kind the value had travels inside the ciphertext, as one tag byte in front of the payload. That is what makes the round trip exact rather than approximate: a ferry.Number comes back as the same number in the plane's own spelling, a bool comes back a bool, and bytes come back byte for byte. Storing only the payload would return every protected value as a string, which is a lost bool and a renumbered number.

A null is not protected. There is nothing at one to encrypt, and a plane's spelling of a null is how it says the field is nil; a ciphertext in its place would turn "nothing is here" into "something is here".

Two saves of one secret store two different ciphertexts, because DPAPI-NG is randomised. Nothing here compares two ciphertexts and nothing may be built on their being equal.

Migration, and what it costs

A value at a marked address that carries no marker is read back as it stands, and the next save writes it protected. So an existing deployment migrates itself the first time it saves, with no migration step and no flag day.

The accepted cost, stated plainly: anything that can write plaintext into the store can downgrade a field, and ferry accepts it silently. An attacker with write access to the store replaces a ciphertext with a plaintext of their choosing, the next load reads it, and nothing anywhere reports that the value used to be protected. If write access to your store is part of your threat model, this decorator does not defend against it - and neither, note, does encryption without an integrity check over the whole store.

How "not protected" is told from "could not be decrypted"

By the marker, and not by looking at the ciphertext.

DPAPI-NG's blob has a structure, and sniffing it would be a guess in both directions. The direction that matters is that a value which is protected and cannot be decrypted must be loud, never passed through as plaintext, because a silent pass-through is how a load succeeds with a secret nobody could read. A marker this package writes settles that exactly for everything this package wrote.

The one case a marker cannot settle: a value nobody ever protected that happens to begin with ferry-protect:1:. That is reported as a failure rather than passed through, which is the safe direction of the two, and it is the honest limit of the scheme.

So, at a marked address:

what the plane holds what happens
a value this package wrote decrypted, back at the kind and the exact text it was saved from
anything else returned as it stands, and the next save writes it protected
the marker, and it will not decrypt protect.ErrCiphertext, naming the address

What it keeps of the plane underneath

Everything.

A ferry.Reader and a ferry.Writer are one method each, and every other thing a driver can do - probing a container, enumerating one, releasing a resource, committing, forgetting a composite, being handed a dump's realised addresses, naming an address in the plane's own spelling, tolerating overlapping calls - is discovered by type assertion on the instance core was handed. Go has no way to implement an interface conditionally, so a wrapper is exactly the set of methods its type declares, for every plane it is ever put in front of, and both mistakes are silent:

  • declare less than the plane has and a capability disappears. A dropped Unsetter is refused at the open for every schema holding a slice or a map; a dropped Enumerator loads one as empty.
  • declare more and the wrapper answers a question the plane cannot. A shell claiming Committer over a sink that has none reports that the sink stages when it does not.

So the shells are exhaustive: one type per combination, thirty-two on the read side and sixty-four on the write side, looked up by a bitmask. Every entry is asserted to declare exactly what the plane it was handed declared, and the driver conformance suite is run over this decorator in front of a plane to prove the whole of it end to end.

The seam

Protection reaches this package through one interface:

type Protector interface {
	Protect(ctx context.Context, descriptor string, plaintext []byte) ([]byte, error)
	Unprotect(ctx context.Context, ciphertext []byte) ([]byte, error)
}

protect.Using is where one is handed over. With no Using, a source or a sink reaches DPAPI-NG, which exists on Windows and nowhere else: everywhere else it refuses at Bind with protect.ErrNoProtection.

The real implementation sits behind //go:build windows and declares the four ncrypt.dll entry points itself, because golang.org/x/sys/windows wraps the classic CryptProtectData family and nothing from DPAPI-NG. It takes no dependency beyond the one this module already has.

Options

option what it does
protect.Using(p) the protection to encrypt and decrypt through. Nil is DPAPI-NG.

The descriptor and the selector are positional arguments rather than options, because a decorator with neither is not a decorator with a default: protect.Over(store, "", nil) is refused at Bind, and there is no descriptor safe enough to be assumed.

Errors

Every one of these wraps ferry.ErrPlane and stays reachable under ferry's own wrapper, so errors.Is answers for them on what ferry.Load and ferry.Dump returned.

error when
protect.ErrNotDeclared FromTags over a schema whose registry was never given protect.Extension(). At Bind.
protect.ErrNoProtection no Using, and no DPAPI-NG on this operating system. At Bind.
protect.ErrOption no plane to decorate, no selector, or an empty descriptor. At Bind.
protect.ErrCiphertext a value that could not be encrypted, or a marked value that could not be decrypted. Named at the address.

A mark at the address of a struct, a slice or a map is refused at Bind naming the address, with no sentinel of its own: a mark says how one value is stored, and those are places rather than values.

Documentation

Overview

Package protect encrypts the values a struct marks as secret on their way into a plane, and decrypts them on the way back out, with Windows DPAPI-NG.

It is a decorator rather than a plane of its own. It wraps somebody else's ferry.Source and ferry.Sink - the registry, a key-value store, a file - and changes what is stored at the addresses a field marked, leaving every other address and everything else about that plane alone.

type Config struct {
    Auth struct {
        RefreshToken string `ferry:"refresh_token" protect:"secret"`
    } `ferry:"auth"`
}

reg := ferry.MustRegistry(ferry.WithTagKeys(protect.Extension()))

src := protect.Over(store, protect.CurrentUser, protect.FromTags())
cfg, err := ferry.Load[Config](ctx, src, ferry.WithRegistry(reg))

Which addresses are secret comes from the struct, in the protect tag key, because secrecy is a property of the field rather than of a deployment: a refresh token is a secret on every plane and in every environment, and a list of addresses kept beside the struct goes stale the day somebody adds a field to it. There is one word, secret, and there is deliberately no word that takes protection away.

The tag key has to be declared on the registry the schema is compiled against, and forgetting to is the one mistake that would be silent, since a key nobody declared parses to nothing and every marked value would be written in the clear. FromTags refuses at Bind instead, before anything is read or written.

Which descriptor, and what it protects against

CurrentUser is the one to start from. It protects to the account the process runs as, so decrypting the value requires being that account on the machine that encrypted it: another user on the machine cannot read the store, and a copy of the store cannot be read anywhere else. A service running as the local system account gets exactly that, and it needs no domain.

LocalSystem names the local system account by its security identifier, and a descriptor that names a principal is resolved by Active Directory, so it works on a domain-joined machine and fails at the first save on a standalone one. LocalMachine needs no domain but grants every account on the machine, which is what classic DPAPI at machine scope already grants.

None of them is a vault: an attacker who takes the machine's own key material recovers the value offline, at their leisure. Read the package's README for the whole of that statement.

Migration

A value that is not protected yet is read back as it stands, and the next save writes it protected, so a deployment that predates this decorator migrates itself. The cost is stated in the README and accepted: anything that can write plaintext into the store can downgrade a field.

Windows

The protection itself is a Windows API. Everywhere else a source or a sink built without Using refuses at Bind, and supplying a Protector of your own is what makes the package testable, and usable, elsewhere.

Example

Example saves a struct through a protected sink and loads it back, over a plane that is an ordinary address-keyed store rather than anything Windows.

The protector here is this package's test double, which is what protect.Using is for; leave that option off and a real deployment reaches DPAPI-NG.

store, keeper := newStore(), newKeeper()
registry := ferry.MustRegistry(ferry.WithTagKeys(protect.Extension()))

src := protect.Over(storeSource{s: store}, protect.CurrentUser, protect.FromTags(), protect.Using(keeper))
dst := protect.OverSink(storeSink{s: store}, protect.CurrentUser, protect.FromTags(), protect.Using(keeper))

want := Settings{Auth: Credentials{RefreshToken: "s3cr3t"}, Host: "example.internal"}

if err := ferry.Dump(context.Background(), want, dst, ferry.WithRegistry(registry)); err != nil {
	fmt.Println(err)

	return
}

fmt.Println("the store holds the token in the clear:", store.holds("s3cr3t"))
fmt.Println("the host is stored as it always was:", rendered(store.at(ferry.At("host"))))

got, err := ferry.Load[Settings](context.Background(), src, ferry.WithRegistry(registry))
if err != nil {
	fmt.Println(err)

	return
}

fmt.Println("and it loads back as:", got.Auth.RefreshToken)
Output:
the store holds the token in the clear: false
the host is stored as it always was: example.internal
and it loads back as: s3cr3t

Index

Examples

Constants

View Source
const ExtensionKey = "protect"

ExtensionKey is the struct tag key Extension declares.

Variables

View Source
var ErrCiphertext = errors.New("protect: this value is marked as a secret and the protection failed on it")

ErrCiphertext reports a value that could not be encrypted on its way into the plane, or a stored value that carries this package's marker and could not be turned back into the value it was written from.

It is always loud, in both directions. A value that was never protected is read back as it stands, which is how an existing deployment migrates, but a value that says it was protected and cannot be unprotected is a failure and never a plaintext quietly passed through - and a value that cannot be encrypted fails the save rather than being written in the clear.

It wraps ferry.ErrPlane, the report names the address, and whatever the Protector itself reported stays reachable underneath.

View Source
var ErrNoProtection = errors.New("protect: there is no DPAPI-NG here")

ErrNoProtection reports a machine with no DPAPI-NG on it.

It is what a source or a sink built without Using refuses with, at Bind and before any load, on every operating system but Windows. Supplying a Protector is what makes this package usable elsewhere.

It wraps ferry.ErrPlane, and it stays reachable under ferry's wrapper, so errors.Is answers for it on what ferry.Load and ferry.Dump returned.

View Source
var ErrNotDeclared = errors.New("protect: the registry was not given protect.Extension()")

ErrNotDeclared reports a schema compiled against a registry that was never given Extension, where FromTags was asked to read the tag.

It is the one refusal this package exists to make. Without it, a forgotten declaration reads exactly like a struct with no secrets in it, and every value a field marked would be written in the clear with nothing saying so.

It wraps ferry.ErrPlane and lands at Bind, before any read or write.

View Source
var ErrOption = errors.New("protect: unusable decorator option")

ErrOption reports a decorator that cannot be built: no plane to wrap, no selector, or an empty Descriptor.

Over and OverSink take options and return no error, so this lands at Bind, which is the first moment the decorator is asked for anything. It wraps ferry.ErrPlane.

Functions

func Extension

func Extension() ferry.KeyExtension

Extension declares this package's struct tag key, for a registry to read beside ferry's own.

var Registry = ferry.MustRegistry(ferry.WithTagKeys(protect.Extension()))

type Config struct {
    Token string `ferry:"token" protect:"secret"`
}

The vocabulary is one word. secret marks the value at that address as one to encrypt on the way into the plane and decrypt on the way out, and it takes no value after it.

There is no word that takes protection away, and there must never be one. Two sources of truth that can contradict each other about whether a value is a secret can only be resolved in one direction safely, so this vocabulary resolves it by having nothing to contradict: a field is marked or it is not.

It belongs on a field the plane holds one value at. A struct, a slice or a map is a place rather than a value, and marking one is refused at Bind naming the address, because what would be encrypted there is not a thing the plane holds.

Handing this to the registry is not optional when FromTags is the selector. A registry that was not given it parses the key as another library's business, every tag is inert, and FromTags refuses at Bind rather than writing the values in the clear.

func Over

func Over(src ferry.Source, d Descriptor, sel Selector, opts ...Option) ferry.Source

Over puts protection in front of a source: every address the selector picked is decrypted on its way out of the plane, and every other address is the plane's own answer, untouched.

reg := ferry.MustRegistry(ferry.WithTagKeys(protect.Extension()))
src := protect.Over(kv.NewSource(client), protect.CurrentUser, protect.FromTags())
cfg, err := ferry.Load[Config](ctx, src, ferry.WithRegistry(reg))

The result is a ferry.Source and nothing more, so it goes wherever the source it wraps went and composes with every plane rather than with one. OverSink is the other half, and both halves take the same descriptor, the same selector and the same Using: a sink protecting under one descriptor and a source unprotecting through another never meet, and nothing checks that the two agree.

It keeps everything the wrapped reader could do. Probing a container, enumerating one, releasing a resource, naming an address in the plane's own spelling and tolerating overlapping calls are all discovered by assertion on the reader core is handed, so a decorator that quietly dropped one of them would change how a schema loads without failing anything - a dropped enumeration loads a map as empty. What this hands back declares exactly what the plane underneath declared.

A plane that reports a link at a marked address is refused, naming both. The mark travels on the field, the address a link points at is a different one that carries no mark, and a value read through the link would come back as it is stored rather than as what it was protected from.

A value the plane holds that was never protected is read back as it stands. That is what lets an existing store migrate: the next save writes it protected. What it costs is in the package README, and it is accepted rather than overlooked.

It returns no error, so everything it can refuse lands at Bind, before any read: a missing tag key declaration, an address the mark cannot sit at, an empty descriptor, and, on every operating system but Windows, the absence of DPAPI-NG where no Using was given.

func OverSink

func OverSink(dst ferry.Sink, d Descriptor, sel Selector, opts ...Option) ferry.Sink

OverSink puts protection in front of a sink: every address the selector picked is encrypted on its way into the plane, and every other address is written exactly as it would have been.

reg := ferry.MustRegistry(ferry.WithTagKeys(protect.Extension()))
dst := protect.OverSink(kv.NewSink(client), protect.CurrentUser, protect.FromTags())
err := ferry.Dump(ctx, cfg, dst, ferry.WithRegistry(reg))

It is a second constructor rather than a second argument to Over, because a ferry.Source and a ferry.Sink are two interfaces with one method name and no common type: one value cannot have two Bind methods, which is the same reason a plane's own driver ships two types. A plane with a read half and no honest write half - the process environment is one - is decorated by calling this not at all.

It keeps everything the wrapped writer could do: committing, releasing, spelling a container at its own address, forgetting a composite, and being handed a dump's realised addresses before the first write. Those are discovered by assertion, so a decorator that dropped one would break a schema rather than report anything - a sink whose ferry.Unsetter went missing is refused at the open for every schema holding a slice or a map.

What is written at a marked address is a string: this package's marker and then the ciphertext, base64 encoded. The kind the value had travels inside the ciphertext, so a number comes back as the same number in the same spelling and a bool as the same bool. A null is written as a null, because there is nothing at one to encrypt.

It returns no error, and everything it can refuse lands at Bind, before anything is written, on the same terms as Over.

Types

type Descriptor

type Descriptor string

Descriptor is who may decrypt: a protection descriptor rule string, in the form DPAPI-NG spells one.

It is data rather than a function, so a source and a sink are configured with a value that can be written down, compared and put in a test.

There are two families of rule string, and which one you are holding decides whether a machine can use it at all.

A rule that names a security principal - one beginning SID= or SDDL= - is resolved by Active Directory's key distribution service, so it works on a machine joined to a domain and fails on one that is not, with NTE_ENCRYPTION_FAILURE at the first save. LocalSystem is one of these.

A rule beginning LOCAL= is resolved by the machine itself and needs no domain. CurrentUser and LocalMachine are these two, and they are what a standalone machine has.

Every other rule string DPAPI-NG accepts - a certificate, a set of web credentials - is written out here in full and is not a constant this package ships.

const CurrentUser Descriptor = "LOCAL=user"

CurrentUser protects to the account the process is running as, and it is the descriptor to reach for unless the machine is joined to a domain.

Only that account, on this machine, can decrypt the value. A copy of the store taken anywhere else is unreadable, and so is the store read here by any other account. A service running as the local system account gets from this exactly what LocalSystem promises, on a machine that needs no domain to give it.

The principal is whoever runs the process, which is the sharp edge: run the same program by hand as an ordinary user and the value is protected to that user, and the service that was going to read it back cannot.

const LocalMachine Descriptor = "LOCAL=machine"

LocalMachine protects to the machine, so every account on it can decrypt the value. It needs no domain.

It is the descriptor for a value more than one account has to read: a service that writes it and an operator's tool that reads it.

It grants what classic DPAPI at machine scope grants, which is every principal on the machine, so the store's own access control list is the only thing narrowing that down. Reach for CurrentUser wherever one account is enough.

const LocalSystem Descriptor = "SID=S-1-5-18"

LocalSystem protects to the local system account, by naming its well-known security identifier.

It says what classic DPAPI at machine scope cannot: this value is for the local system account and for nothing else, where machine scope grants decryption to every principal on the machine and leaves the store's access control list as the only thing keeping the value in.

It works on a machine joined to an Active Directory domain and nowhere else. A SID rule names a principal the domain's key distribution service resolves, so on a standalone machine the first save fails with NTE_ENCRYPTION_FAILURE and nothing is written. CurrentUser is how a service running as the local system account gets the same narrowing with no domain behind it.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option is a setting handed to Over or to OverSink.

There is one, Using, and both halves take it. They are not two types here as they are in a plane's own driver, because nothing this decorator does differs between the directions: it protects on the way in and unprotects on the way out, through the same Protector and under the same Descriptor.

func Using

func Using(p Protector) Option

Using names the protection this decorator encrypts and decrypts through.

src := protect.Over(store, protect.CurrentUser, protect.FromTags(), protect.Using(fake))

A nil argument is DPAPI-NG, which is the default and which exists on Windows and nowhere else: elsewhere, a source or a sink built without this refuses at Bind with ErrNoProtection.

It is what makes a test hermetic, and it is the seam protection this package does not know about arrives through.

Give the same one to both halves. A sink protecting through one and a source unprotecting through another never meet, and nothing checks that the two agree.

type Protector

type Protector interface {
	// Protect encrypts plaintext so that only a principal the descriptor names
	// can decrypt it.
	Protect(ctx context.Context, descriptor string, plaintext []byte) ([]byte, error)

	// Unprotect decrypts what Protect produced, or fails. The descriptor is not
	// repeated, because it travels inside the ciphertext.
	Unprotect(ctx context.Context, ciphertext []byte) ([]byte, error)
}

Protector is protection as this package needs it: bytes to bytes, and back.

It is an interface rather than a dependency, so a test double, a hardware module or somebody else's key management is a few lines and this package never learns which of them it is talking to. Using is where one is handed over, and a source or a sink built without one reaches DPAPI-NG - which exists on Windows and nowhere else, so everywhere else the decorator refuses at Bind.

Three things an implementer owns.

Protect is allowed to be randomised, and DPAPI-NG is: the same plaintext protects to different bytes on every call. Nothing here compares two ciphertexts, and nothing may be built on their being equal.

Unprotect either returns the exact plaintext Protect was given or fails. There is no third answer, and in particular there is no answer that means "this was not protected": whether the stored bytes were ever protected is settled before Unprotect is called, by a marker this package writes.

Cancellation is yours. The decorator hands its caller's context to every call and adds no deadline of its own.

Safety for use from many goroutines at once is yours. A source or a sink is constructed once and a binding is held for the life of a process, so one of these is reached from wherever a load or a save happens, and the plane underneath may declare that it tolerates overlapping calls.

type Selector

type Selector interface {
	// contains filtered or unexported methods
}

Selector says which of a schema's addresses hold secrets.

FromTags is the one this package ships and the only implementation there is: the interface is closed, so a selector is always something whose refusals this package can make at Bind.

func FromTags

func FromTags() Selector

FromTags selects the addresses whose field carried protect:"secret".

reg := ferry.MustRegistry(ferry.WithTagKeys(protect.Extension()))
src := protect.Over(store, protect.CurrentUser, protect.FromTags())
cfg, err := ferry.Load[Config](ctx, src, ferry.WithRegistry(reg))

The declaration is the struct author's, which is where it belongs: a field that holds a credential holds one on every plane and in every deployment, and an address survives a rename of the ferry tag it was minted from because the mark travels on the same field.

Three things it refuses, all of them at Bind and before any read or write:

  • a schema whose registry was never given Extension, with ErrNotDeclared. That is a forgotten line in the caller's registry, and it would otherwise be indistinguishable from a struct that marks nothing - which is to say, every marked value written in the clear.
  • the address of a struct, a slice or a map, naming the address.
  • a word the vocabulary does not have, naming the address.

A registry that was given the declaration and a struct that marks nothing is not a refusal. It is a schema with no secrets in it, which is a legitimate thing to run a protected source over, and it is exactly the case the second result of ferry.AddressSet.Extension exists to tell apart from the first.

Example

ExampleFromTags shows the one mistake this package refuses to let you make: a registry that was never given protect.Extension, which would leave every marked value written in the clear.

store, keeper := newStore(), newKeeper()

// No ferry.WithTagKeys(protect.Extension()) anywhere, so every protect tag
// in the struct is another library's business and parses to nothing.
dst := protect.OverSink(storeSink{s: store}, protect.CurrentUser, protect.FromTags(), protect.Using(keeper))

err := ferry.Dump(context.Background(), Settings{Auth: Credentials{RefreshToken: "s3cr3t"}}, dst)

fmt.Println("refused:", errors.Is(err, protect.ErrNotDeclared))
fmt.Println("and the store is untouched:", store.empty())
Output:
refused: true
and the store is untouched: true

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL