NAME

File::SOPS - Perl implementation of Mozilla SOPS encrypted file format

VERSION

version 0.003

SYNOPSIS

use File::SOPS;

# Encrypt a data structure
my $encrypted = File::SOPS->encrypt(
    data       => {
        database => {
            password => 'secret123',
            host     => 'db.example.com',
        },
    },
    recipients => ['age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p'],
    format     => 'yaml',
);

# Decrypt
my $data = File::SOPS->decrypt(
    encrypted  => $encrypted,
    identities => ['AGE-SECRET-KEY-1...'],
);

# File operations
File::SOPS->encrypt_file(
    input      => 'secrets.yaml',
    output     => 'secrets.enc.yaml',
    recipients => ['age1...'],
);

File::SOPS->decrypt_file(
    input      => 'secrets.enc.yaml',
    output     => 'secrets.yaml',
    identities => ['AGE-SECRET-KEY-1...'],
);

# Encrypt a file over itself, atomically
File::SOPS->encrypt_in_place(
    file       => 'secrets.yaml',
    recipients => ['age1...'],
);

# Decrypt, open $EDITOR, re-encrypt
File::SOPS->edit(
    file       => 'secrets.enc.yaml',
    identities => ['AGE-SECRET-KEY-1...'],
);

# Extract single value
my $password = File::SOPS->extract(
    file       => 'secrets.enc.yaml',
    path       => '["database"]["password"]',
    identities => ['AGE-SECRET-KEY-1...'],
);

# Rotate data key
File::SOPS->rotate(
    file       => 'secrets.enc.yaml',
    identities => ['AGE-SECRET-KEY-1...'],
);

# Take the recipients and rules from the .sops.yaml governing a file
my %args = File::SOPS->creation_rules_for(file => 'secrets/prod.yaml');
File::SOPS->encrypt_in_place(file => 'secrets/prod.yaml', %args);

DESCRIPTION

File::SOPS is a pure Perl implementation of Mozilla SOPS (Secrets OPerationS), compatible with the reference Go implementation at https://github.com/getsops/sops.

SOPS encrypts values in structured files (YAML, JSON) while keeping keys readable. This enables:

  • Git-friendly diffs - see which keys changed without decrypting

  • Partial file inspection without full decryption

  • Multiple encryption backends (currently age, with PGP/KMS planned)

  • MAC verification to detect tampering

How SOPS Works

1. Generate a random 256-bit data key
2. Encrypt the data key for each recipient using age (X25519 + ChaCha20-Poly1305)
3. Store encrypted data keys in the sops metadata section
4. Encrypt each value with AES-256-GCM using the data key
5. Compute MAC over the entire structure for tamper detection

Encrypted Value Format

Each encrypted value is stored as:

ENC[AES256_GCM,data:base64==,iv:base64==,tag:base64==,type:str]

File Structure Example

database:
    password: ENC[AES256_GCM,data:xyz,iv:abc,tag:def,type:str]
    host: ENC[AES256_GCM,data:xyz,iv:abc,tag:def,type:str]
sops:
    age:
        - recipient: age1ql3z7hjy...
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            <encrypted data key>
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2025-01-10T12:00:00Z"
    mac: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
    version: 3.7.3

Special Features

  • Encryption rules - unencrypted_suffix (_unencrypted by default), encrypted_suffix, unencrypted_regex and encrypted_regex choose which values get encrypted; the rest stay readable but are still covered by the MAC. See "Choosing what gets encrypted"

  • Key rotation - Re-encrypt all values with a new data key via "rotate"

  • Multiple recipients - Encrypt once, multiple recipients can decrypt

Character encoding

The API boundary is characters. The wire is UTF-8 bytes. Encoding happens exactly once, and this module owns it.

Everything File::SOPS hands you and everything it takes from you is a Perl character string: the keys and values you pass to "encrypt", the tree "decrypt" returns, the value "extract" returns, and the path you look it up by. Encoding to UTF-8 happens at the edge where data becomes ciphertext, digest input or file content -- never in your code:

  • Values, and the key path that forms each value's AAD, are UTF-8 encoded on the way into AES-GCM, and the MAC digest is taken over those same UTF-8 bytes. This is what the Go implementation authenticates against; a document whose keys or values leave the ASCII range is not interoperable otherwise.

  • "decrypt" reverses it, so a structure survives encrypt/decrypt unchanged and is_deeply against the original holds for any input. Prior to 0.003 decrypted values came back as UTF-8 bytes, which compared unequal to the characters that went in and turned into mojibake when "decrypt_file" encoded them a second time.

  • "encrypt_file" and "decrypt_file" read and write UTF-8 encoded files. They decode on the way in and encode on the way out, so the characters rule holds across the file API too.

The one place bytes surface deliberately is type:bytes, SOPS's binary type, which is neither encoded on the way in nor decoded on the way out, because it is not text. See "value_to_bytes" in File::SOPS::Encrypted.

The boundary is characters, and it does not read Perl's UTF-8 flag

Do not hand encrypt UTF-8 bytes. Decode them first:

utf8::decode($value);              # or read the file with an :encoding layer

Everything that crosses to the wire -- the value, and the key path that forms its AAD -- is UTF-8 encoded unconditionally. Perl's UTF-8 flag is not consulted, because below U+0100 it is a storage detail and not a statement about meaning: "caf\x{e9}" may be held as one byte or as two, Perl considers both the same string, and both serializers write it to the file as caf\xc3\xa9 either way. A rule that read the flag would disagree with the bytes our own emitter wrote, and a document that disagrees with itself fails its own MAC.

Prior to 0.003 the value was encoded only when the flag was set, so an unflagged "caf\x{e9}" reached the wire as the single byte \xe9. With unencrypted_suffix -- on by default -- such a document failed its own MAC and sops -d reported MAC mismatch; when the value was encrypted the document was self-consistent but sops -d handed the value back as !!binary Y2Fm6Q== rather than as café. Passing UTF-8 bytes appeared to work in those releases, and for encrypted values it did; for unencrypted ones the emitter double-encoded them and the document already failed verification. See docs/adr/0003.

Value types

A value's type: on the wire is decided by what the value is, never by what its text looks like. A Perl string encrypts as type:str however numeric or boolean it reads:

File::SOPS->encrypt(data => { v => 'true' })  # type:str,   plaintext true
File::SOPS->encrypt(data => { v => '007'  })  # type:str,   plaintext 007
File::SOPS->encrypt(data => { v => '1.50' })  # type:str,   plaintext 1.50
File::SOPS->encrypt(data => { v => 5432   })  # type:int,   plaintext 5432
File::SOPS->encrypt(data => { v => 1.50   })  # type:float, plaintext 1.5
File::SOPS->encrypt(data => { v => JSON->true })  # type:bool, plaintext True

The JSON->true/JSON->false in the last line is a class-method call on the JSON package -- it is not imported by use File::SOPS; (nothing is; namespace::clean strips it back out), and it works under a bare use File::SOPS; only by accident, because CryptX loads JSON.pm on the way to Crypt::AuthEnc::GCM. Rely on the accident and your code breaks the moment a future CryptX stops loading JSON. The caller has to say so explicitly: use JSON::MaybeXS qw(JSON); -- JSON::MaybeXS is already a prerequisite via the Format::JSON handler, so this is a use-line, not a new dependency.

This is the rule the reference implementation follows -- it types a value by what the YAML/JSON parser returned, so a quoted scalar is a string -- and YAML::XS and Cpanel::JSON::XS preserve the same distinction, so a document loaded from a file keeps the types the file gave it. Perl has no native boolean, so type:bool requires JSON->true/JSON->false or a true/false loaded from YAML or JSON.

Prior to 0.003 the type was guessed by pattern-matching the value's text. That turned 'true' into a boolean and '007' into the integer 7 on the way back out, and -- because the reference implementation renormalises a numeric plaintext when it recomputes the MAC, 007 to 7 and 1.50 to 1.5 -- made sops -d reject any document containing such a value outright.

The full rule, the caller-visible round trips it changes, and the one case where Perl's flags can be contaminated by the caller are in "detect_type" in File::SOPS::Encrypted and "value_to_bytes" in File::SOPS::Encrypted.

Integers are Go's int64, and Perl's are wider

Perl's integers reach 2**64-1; the SOPS int type is Go's int64 and stops at 2**63-1. "encrypt" dies rather than write an integer outside that range, and "decrypt" dies rather than read one, because there is no wire form that preserves it: type:int makes sops -d stop with strconv.Atoi: value out of range, and type:float -- what sops's own JSON store falls back to -- silently drops digits. Pass such a value as a string; that is type:str, written verbatim, and it survives both implementations intact. See "assert_representable" in File::SOPS::Encrypted.

That rule is about a scalar Perl holds as an integer, which since 0.003 a bare JSON number in 2**63 .. 2**64-1 is not: the parser hands back the float64 Go reads there, so those documents are written rather than refused. A caller who does hold such an integer has the other answer available too -- unpack('d', pack('d', $v)) is what sops writes for the same digits, at the cost of the ones the double cannot hold. See "A number past Go's int64 is a float".

A number past Go's int64 is a float

There is no big integer in the SOPS data model. Past int64 a number is a float64 to Go: sops writes such a leaf as type:float, in an encrypted slot and an unencrypted one alike, and sops -e on a plaintext 99999999999999999999 writes 100000000000000000000 into the file itself. Since 0.003 this library agrees in JSON as it always did in YAML.

The boundary is Go's, not Perl's, and that is the whole rule. Perl's integers are a magnitude wider, so two windows sit above 2**63-1; they reach the tree as different scalars, and until 0.003 each of them got its own wrong answer here:

a bare JSON literal in    the decoder returns   until 0.003   since 0.003
2**63 .. 2**64-1          a Perl integer        a croak       a float
past 2**64-1              a plain string        a str         a float

The windows are positive-only. A Perl integer cannot reach below int64's floor, so -9223372036854775808 is still an int, and -9223372036854775809 is already a plain string and already the upper window's leaf.

The lower window is the one where Perl can hold the integer and Go cannot. Cpanel::JSON::XS decodes 9223372036854775808 into a Perl integer, so it was an int here and the int64 refusal above applied to it -- while sops writes that same literal as a type:float and normalises it to 9223372036854776000, which is itself inside the window. "rotate" therefore refused a JSON document sops -e had written and sops -d reads, and "encrypt" refused the plaintext it was written from. Measured against sops 3.13.3: rotating such a document now writes the unencrypted slot back with the digits it came with and sops -d reads the result, and encrypting the plaintext writes what sops -e writes for the identical input. See k101 and docs/adr/0021.

The upper window is the one where the decoder cannot hold the digits at all and hands them back as a plain string, indistinguishable from the same digits quoted, so 100000000000000000000 was typed str and "rotate" rewrote a document sops had written with a number there as a JSON string. See k63 and docs/adr/0020.

Both windows now hand back the leaf YAML::XS has always returned for those digits -- the double, carrying its source spelling -- so the value gets the float rules and neither the integer nor the string ones. A quoted "9223372036854775808" is unaffected in either window and stays a str, and so is every value in a document this library wrote before 0.003: it carries an upper-window number quoted, and it could not carry a lower-window one at all.

"decrypt" hands such a leaf back as a number. In an unencrypted slot it is a "dualvar" in Scalar::Util and prints as the digits the document holds; in an encrypted one it is the bare NV every decrypted float is, so "$value" goes through Perl's 15 significant digits and gives 1e+20 where the upper window's old string gave 100000000000000000000. "extract" is the method that prints all of them in either slot -- see the dualvar note there and "canonical_float_dualvar" in File::SOPS::Encrypted. "decrypt_file" writes the leaf into the plaintext as a bare number.

For the lower window what moved is the numeric half and not the printed one, and it is the only window where that can happen -- it is the only one Perl held exactly. Measured per slot, on a document sops -e wrote:

decrypt / extract, encrypted slot     unchanged. sops already wrote
                                      type:float there, so this library
                                      has always read a float back
decrypt / extract, unencrypted slot   prints the same digits as before,
                                      but 0 + $value is now the double
                                      those digits name where it used to
                                      be the digits themselves
decrypt_file                          unchanged, byte for byte

0 + $value for 9223372036854776000 is 9223372036854775808, 192 short of what the same scalar prints. That is not a number this library chose: it is the float64 Go has been reading out of that leaf since sops wrote it, and digesting for the MAC.

What is lost is a digit the double never held. value_to_bytes re-derives the text from the number, so a hand-written literal that is not its double's canonical decimal is written -- and read back -- rounded: 12345678901234567890 becomes 12345678901234567000 and 99999999999999999999 becomes 100000000000000000000, which is what sops -e does to the same document.

The same re-derivation settles a verification difference that used to go the other way. A document whose unencrypted number is spelled differently from its double's canonical decimal -- 9223372036854776832, whose double is the same 9223372036854775808 -- is one sops -d accepts, because Go digests the number and not the text. Until 0.003 this library digested the integer's own digits and reported MAC verification failed on such a file; it now digests what Go digests, reads it, and "decrypt_file" writes it out with the same normalisation sops -d writes. Measured against sops 3.13.3 on both sides.

Three things around these windows are still refused rather than written -- the magnitude above them where a real non-finite double reaches an unencrypted slot or states its own text, one whole format, and one kind of caller -- and a caller meeting a wide number should know where all three are.

A literal that overflows a double -- 1 followed by 400 zeros -- dies in "assert_representable" in File::SOPS::Encrypted's non-finite guard wherever it reaches the tree as a real non-finite double, on every path that writes the value: "encrypt", "encrypt_file", "encrypt_in_place", "rotate" and "edit". That is any JSON document, where Cpanel::JSON::XS returns +Inf carrying the literal's own digits as its string half -- and it is that stated text the guard refuses, in either slot, because an encrypted slot is derived from the number and would drop it without a trace. In JSON it is not an extra restriction of ours -- sops refuses the same document itself and never reaches a type: sops -e stops at Error unmarshalling file: [...] strconv.ParseFloat: value out of range, exit 2, in an encrypted and an unencrypted slot alike, and sops -d stops with the same message, exit 1, on a document hand-edited to carry one. Measured against sops 3.13.3.

A caller's own bare 9**9**9 states nothing, and it is refused in an unencrypted slot only. In an encrypted one it is now written, as type:float and the plaintext +Inf, which is what sops -e stores for the same value in both formats. See "assert_representable" in File::SOPS::Encrypted, k122 and docs/adr/0040.

In YAML that literal is not refused any more, because it is not a number there. Since 0.003 "parse" in File::SOPS::Format::YAML hands back the string go-yaml reads -- 1e400, a 401-digit integer and the bare spellings Inf, NaN and their relatives -- so the guard never sees a float, and the leaf is written as type:str, which is what sops -e writes for the identical plaintext (exit 0, measured against sops 3.13.3, encrypted and unencrypted slot alike). Until 0.003 the same document could be neither written here -- all 20 measured hit the guard -- nor read, 17 of those 20 failing with MAC verification failed.

The read paths follow from that and differ by format. In YAML "decrypt" and "extract" hand the leaf back as a plain string carrying the literal's own text, and the document verifies; "A YAML literal that overflows a double comes back as a string" under "decrypt" has what that changed for a caller. In JSON no such document verifies in either implementation: "decrypt" stops at MAC verification failed, and with ignore_mac the value is Inf with its digits gone.

The lower window in a YAML document is refused, and sops cannot write one either. YAML::XS hands those digits back as a Perl integer, so the int64 refusal above applies and "encrypt" dies with that message; sops -e on the same plaintext stops at Error walking tree: Cannot walk value, unknown type: uint64, exit 23, in both slots, measured against sops 3.13.3. The upper window in YAML has always been a float and is unaffected. Only JSON has a window Go reads as a number and Perl reads as an integer.

A lower-window value that did not come from a JSON parse -- a Perl integer literal, a computed one, or one that came out of a YAML document -- is still refused by "assert_representable" in File::SOPS::Encrypted. The scalar the caller handed over is an exact integer and no reader has spoken for it, so turning it into a lossy double would be this library guessing. Say which one you mean: unpack('d', pack('d', $v)) makes it the float sops writes, and "$v" makes it a type:str that stores the digits exactly. That refusal is wider than it strictly has to be -- of 35 literals measured across the window, 14 would have produced a document sops -d accepts in an unencrypted JSON slot -- and narrowing it is open as k104.

Saying what a value is

There is no per-leaf type argument to "encrypt", and none is needed: the scalar is the type, so you say what a value is by handing over the scalar that says it.

use JSON::MaybeXS qw(JSON);          # JSON->true is not exported by File::SOPS
$data->{port}  = "$data->{port}";   # type:str
$data->{port}  = 0 + $data->{port}; # type:int
$data->{ratio} = unpack('d', pack('d', $data->{ratio}));  # type:float
$data->{flag}  = JSON->true;        # type:bool
$data->{admin} = ($level > 3);      # type:bool too, on perl 5.36+

The last line works because Perl marks its own booleans on the scalar since 5.36 -- !!1, !!0 and every comparison's result -- and both emitters write such an SV as a bare true/false. Until 0.003 it was type:int, and the resulting file failed its own MAC. See "detect_type" in File::SOPS::Encrypted and k90.

The ratio line goes through pack/unpack rather than the more obvious + 0.0 because addition sets Perl's public SVf_IOK on an integral result, and that flag is what decides the type: measured, 0.0 + 2 is type:int, and so is 0.0 + '2'. pack('d', ...) lays the value out as a native double and unpack builds a fresh scalar from those bytes, which is a float and nothing else -- the same conversion "decrypt_value" in File::SOPS::Encrypted makes for the same reason. A literal 2.0 written in your own source is already a float and needs none of this.

The case that makes this worth spelling out is the one "detect_type" in File::SOPS::Encrypted warns about. Perl marks a string as numeric in place the first time it is read in numeric context, so

if ($cfg->{port} > 1024) { ... }    # $cfg->{port} is now an int

turns a later encrypt(data => $cfg) into type:int. Reading a scalar numerically sets the numeric flag but leaves the string alone, so $cfg->{port} = "$cfg->{port}" puts it back exactly -- type str and the original text, padding and trailing zeros included.

What that idiom cannot undo is a numeric assignment:

$cfg->{ratio} += 0;                 # '1.50' is now the number 1.5
$cfg->{ratio} = "$cfg->{ratio}";    # type:str, but the text is '1.5'

Here the scalar's string really was replaced, by your code, before this module saw it. No argument to encrypt could recover 1.50 either -- a type override would write the same 1.5 under a different label -- so the value has to be re-read from wherever it came from.

Multi-document YAML

Not supported, and refused rather than truncated. A YAML file holding more than one document (separated by ---) makes "encrypt_file", "decrypt", "extract" and "rotate" die.

Until 0.003 such a file was accepted and silently reduced to its last document, so encrypting a two-document file wrote one document back and discarded the other without an error. sops does support multi-document YAML; what its model is, and why matching it is more than a parser change, is in "Multi-document YAML" in File::SOPS::Format::YAML.

How a file is written

Every method here that writes a file writes it atomically. The document goes to a temporary file in the same directory, which is then renamed over the target. Nothing ever observes a half-written document, and a failure anywhere before the rename -- a full disk, a signal, an error from the cipher -- leaves the file that was there exactly as it was. This holds for "encrypt_file", "decrypt_file", "encrypt_in_place", "edit" and "rotate" alike, whether the target already exists or not.

Until 0.003 only encrypt_in_place and edit did that. The other three opened the target with '>', which truncates it before the first byte is written, and then checked neither the print nor the close -- so a write that ran out of disk left an empty file and reported success. encrypt_file defaults output to input and rotate always writes back over the file it read, so in both cases the file that was destroyed was the only copy: for rotate, one whose data key had already been replaced.

What rename costs

sops truncates the file and rewrites it in place. Keeping the inode is the one thing that buys, and it costs the file if the write stops half way; on a secrets file being replaced by a re-encryption of itself, that trade goes the other way round. The differences that follow from it are all visible from outside:

  • The file gets a new inode. Hard links to it keep the old content -- where sops, which rewrites the same inode, updates every link at once. A file with n links comes back with one link and the other n-1 still pointing at the previous content.

  • Replacing a file needs write permission on its directory, not on the file. A read-only file in a writable directory is refused with Could not open in-place file for writing: ...: permission denied, the same wording sops -e -i uses (measured, 3.13.3). chmod 0444 is a guard against these methods. The directory, by contrast, must be writable -- a read-only secrets/ is the precondition that lets chmod 0444 on the file mean anything, and these methods cannot write into a directory that is not.

  • A symlink is resolved: the link is left alone and the file it points at is replaced. sops does the same (measured, 3.13.3) -- what differs is only that the target picks up a new inode.

  • An existing file keeps its mode; a file that has to be created gets the mode open '>' would have given it, 0666 against the process umask. That is what sops's --output does as well, so a decrypted file this writes is no more and no less protected than before -- if that is too open for plaintext, set the umask or the mode yourself.

    The match-sops decision is deliberate (k45): the alternative, a hard 0600 on every new output, would break a caller whose next step is another process reading the file -- loudly, not silently -- and would diverge from the reference implementation without a measurable security gain over a umask set to 077 by the caller. "edit" uses 0600 for its own temporary copy, which is a different question -- that file is known to be removed at the end of the call rather than passed on.

A target that exists and is not a regular file -- /dev/stdout, /dev/null, a fifo -- is written through directly instead. There is nothing there to protect, and renaming over it would replace the device itself with an ordinary file. sops --output /dev/stdout works, and so does passing that as output here.

Every YAML file starts with ---, where sops writes none

YAML::XS's emitter always writes the document-start marker, so line 1 of every YAML file this distribution produces is ---: the encrypted document from "encrypt", "encrypt_file", "encrypt_in_place", "rotate" and "edit", and the plaintext from "decrypt_file" and the copy "edit" hands the editor. sops writes neither (measured, 3.13.3):

our encrypt_file    ->  "---\nhalf: ENC[...]"
sops -e             ->  "whole: ENC[...]"
our decrypt_file    ->  "---\nhalf: 1.5"
sops -d             ->  "whole: 2"

It is cosmetic and not a compatibility difference. YAML resolves a document with the marker and one without it identically, sops -d accepts our files (exit 0, pinned by t/04-interop.t), and the MAC covers the values, never the serialized text -- so nothing about the digest, the ciphertext or the metadata depends on that line. JSON has no such marker and is unaffected.

Where it is visible: a caller who diffs this output against sops -d's meets one extra line at the top, and so does anything reading the file with something stricter than a YAML parser.

It is documented rather than removed. Dropping it means changing what the emitter emits, and the MAC's encrypt side rides on that same emitter (see docs/adr/0001), so it is a wire-format change for a cosmetic gain. k83.

encrypt

my $encrypted = File::SOPS->encrypt(
    data               => \%data,
    recipients         => \@age_public_keys,
    format             => 'yaml',  # json / env / ini, defaults to 'yaml'
    mac_only_encrypted => 0,       # optional

    # optional, at most ONE of these four
    unencrypted_suffix => '_unencrypted',
    encrypted_suffix   => '_enc',
    unencrypted_regex  => '^public_',
    encrypted_regex    => '^secret_',

    # optional, carry another document's rules forward
    metadata           => $metadata,
);

Encrypts a data structure for specified recipients.

Takes a HashRef in data, encrypts all values (not keys) using AES-256-GCM, and encrypts the data key for each age recipient. Returns serialized encrypted content as a string.

data may also be an ArrayRef of HashRefs, one per document, to write a multi-document YAML stream -- the inverse of what "decrypt" returns for one (see decrypt). This is purely additive: until 0.003 an ArrayRef raised data must be a hash ref. Every document is encrypted under the one data key and carries the same sops metadata block, byte-identical, and the documents are joined with ---. A one-element ArrayRef writes a one-document file, byte-identical to the same bare HashRef.

Only YAML has a document stream. An ArrayRef of more than one document with a format of json, env or ini is refused, naming the document count and the target -- that format cannot hold a stream, and writing one would drop all but the first document, which is the sops behaviour this library declines to copy (docs/adr/0033 Decision 3).

Keys and values are character strings and are UTF-8 encoded on their way to the cipher and the digest; the returned document is UTF-8 encoded bytes, ready to write to a :raw handle. See "Character encoding".

The recipients parameter must be an ArrayRef of age public keys (starting with age1...).

Dies if data has a top-level sops key, in YAML or JSON. That name is where those formats put the metadata section, so a caller-supplied value at that key would be overwritten -- after the digest had already covered it, which leaves a document that fails its own MAC on the next read. Until 0.003 the user's value was silently replaced by the metadata, for every format, and the resulting document failed its own MAC; for ENV and INI the overwrite did not happen (the metadata lives in flat sops_ keys and in a [sops] section respectively), so sops is a legitimate data key there. sops refuses such a file too, with exit code 203, and its advice applies here: rename the entry.

Supported formats: yaml, yml, json, env, ini. dotenv is accepted as an alias for env, which is the name sops itself uses for the format.

The env and ini formats are untyped, and that is visible to a caller: an unencrypted leaf comes back from the round trip as the text it was written as -- 42 as "42", a boolean as "True", an undef as ''. An encrypted leaf keeps its type, because the type: label carries it.

env is flat: a nested value is refused, as sops refuses it, and a comment lives under the document's empty key as a list. See File::SOPS::Format::ENV.

ini is exactly two levels deep -- section, then key. A value written outside any section is in the section DEFAULT, as it is for sops; a deeper tree is refused (sops writes a dump of the Go value instead); and a section's comments live under that section's empty key, as a list, because that is the path sops authenticates them under. See File::SOPS::Format::INI.

A File::SOPS::Comment in data is written as a sops comment (type:comment) and is left out of the MAC, which is what sops does with one. It has to sit in a list, and in a slot the encryption rules encrypt: those are the only places a SOPS document holds a comment as a leaf, and anywhere else it is refused, naming the path. See "A comment in a list comes back as a File::SOPS::Comment".

mac_only_encrypted is the equivalent of the reference implementation's --mac-only-encrypted: it restricts the MAC to the values that are actually encrypted, and records that choice in the sops section so a reader knows which rule to verify under. See "mac_only_encrypted" in File::SOPS::Metadata. Off by default, which is what sops defaults to as well.

Turning it on has a consequence in YAML that is easy to miss, so it is warned about rather than left to be discovered: an unencrypted leaf is then covered by no MAC at all, and this distribution and sops do not read every YAML spelling the same way. mode_unencrypted: 0755 is the integer 493 to sops and 755 here, and with mac_only_encrypted set nothing fails -- sops -d exits 0 and hands back a different number. Encrypting such a document carps once per such leaf, naming its key path and never its value; without the option the same leaf is refused outright, since the document would fail its own MAC. See "serialize" in File::SOPS::Format::YAML.

One YAML divergence is warned about whatever mac_only_encrypted is set to, because the MAC cannot catch it either way: a True or False string in an unencrypted slot is written as a bare True, which Go's yaml.v3 resolves as a boolean while this module keeps a string. Both sides digest the same bytes, so the MAC holds and sops -d exits 0 -- but sops hands the value on as a boolean, and any sops write-back (rotate, set, edit) rewrites the leaf to a bare true, after which this module reads a boolean too. Encrypting the leaf or using the JSON format avoids it; both are measured. See "serialize" in File::SOPS::Format::YAML.

Choosing what gets encrypted

unencrypted_suffix, encrypted_suffix, unencrypted_regex and encrypted_regex are the equivalents of the sops command line options of the same names, and at most one of them may be given -- passing two dies, as does a document carrying two, because sops refuses such a file outright. Each is matched against every component of a value's key path, so encrypted_suffix => '_enc' encrypts everything under a database_enc: block as well as a password_enc: anywhere in the document; the exact rule is in "should_encrypt_path" in File::SOPS::Metadata.

With none of them given, unencrypted_suffix defaults to _unencrypted, which is what sops does when it creates a document. Pass unencrypted_suffix => undef for a document with no rule, where every value is encrypted whatever its key.

Values excluded from encryption are still covered by the MAC, so they are authenticated even though they are readable -- unless mac_only_encrypted is on.

The same rule is applied on the way out, and it is what decides what a leaf is: see "The rule decides what a value is, in both directions".

Reusing another document's rules

metadata takes a File::SOPS::Metadata object -- typically one just parsed out of an existing file -- and starts from its encryption policy instead of from the defaults. Only the policy is taken: the rules and mac_only_encrypted. The key material, the MAC and lastmodified are always regenerated, because a new data key is generated here and none of them would survive it. See "policy_args" in File::SOPS::Metadata.

Any rule passed explicitly alongside metadata replaces the template's rule rather than adding to it. This is how "rotate" keeps a file's rules across a key rotation.

Dies if metadata carries a rule this distribution cannot apply (unencrypted_comment_regex or encrypted_comment_regex, which select values by their comment -- neither parser here keeps comments, so every value would be classified wrongly).

A structure that contains itself is refused

New in 0.003. data must be a finite tree. A value that contains itself -- a hash or array reachable from inside its own value -- is refused, naming the path at which the cycle closes:

my $h = { a => 1 };
$h->{self} = $h;
File::SOPS->encrypt(data => $h, recipients => \@r);
# dies: self: this value contains itself, so the document has no finite
#       set of values to encrypt or to hash. ...

This used to hang. Not die, not return a truncated document: the process did not come back, and no eval could catch it. There are two roads to it and both are now refused at the same guard. The one above is a caller's own structure. The other is a document: YAML::XS resolves a recursive anchor into a real Perl cycle and hands it over, so

root: &a
  b: *a

hung "encrypt_file" and "encrypt_in_place" as well, and its encrypted counterpart hung "decrypt", "decrypt_file", "extract", "rotate" and "edit". All eight now die instead.

Refusing is what the reference implementation does. Measured against sops 3.13.3, the document above is rejected in both directions -- Error unmarshalling file: yaml: anchor 'a' value contains itself (exit 2) from sops -e, and yaml: anchor 'a' value contains itself (exit 1) from sops -d. There is also nothing else this could honestly do: such a document has no finite set of values, so it has no digest and no serialization, and terminating the walk early would have written a file whose contents and whose MAC describe a tree that is not the one it was given.

An anchor that is merely reused is not affected and never was. Sharing a subtree is ordinary YAML, sops accepts it and expands it -- base: &b with other: *b encrypts to two independent ENC[...] values -- and so does this. Only a container that is its own ancestor is refused.

A document that is acyclic but shares its aliases exponentially is a separate exposure with a separate guard, described under "A document that expands far beyond what it contains is refused".

See k110 and docs/adr/0025.

A document that expands far beyond what it contains is refused

New in 0.003. Sharing an anchor is ordinary YAML and is expanded, here and by sops alike. Sharing it exponentially is not: aliases that are perfectly acyclic still double the document at every level, so

l0: &l0
  v: 1
l1: &l1
  a: *l0
  b: *l0
... 25 levels

is 727 bytes that expand to a tree with 2**25 leaves. This used to hang, in the same eight entry points and for the same practical reason as "A structure that contains itself is refused" -- and it is not caught by that guard, because the document really is acyclic. Measured before the guard: 9 levels encrypted in 0.06s, 12 in 0.5s, 15 in 4.3s, and 25 never came back.

Such a document is now refused, naming how far out of proportion it is:

# dies: this document expands to 8146 values from the 60 it holds, which
#       is the alias bomb sops refuses: "yaml: document contains
#       excessive aliasing". ...

The threshold is not this library's. It is go-yaml's, reproduced on go-yaml's own counters, because an independently chosen one would refuse documents sops accepts. What it budgets is a ratio -- roughly how far the expanded document exceeds the document as written, up to about 100 times, tightening to about 1.1 times as the expansion approaches four million values -- and not a size. sops accepts a 206,104-value expansion and refuses an 8,146-value one, and so does this. Bisected against sops 3.13.3 in four differently shaped families, the accept/refuse boundary is the same document on both sides.

There is no size at which a document is refused for being large: one that shares nothing amplifies nothing and is accepted however big it gets.

A caller's own structure reaches the same guard by the other road. One hash reference held in many places is the same blowup with no YAML involved, and format => 'json' is exposed to it exactly as YAML is.

Refusing is what the reference implementation does, in both directions -- Error unmarshalling file: yaml: document contains excessive aliasing (exit 2) from sops -e, and yaml: document contains excessive aliasing (exit 1) from sops -d.

See k112 and docs/adr/0027.

A document nested deeper than sops can carry is refused

New in 0.003. A document is walked at most $File::SOPS::MAX_DEPTH containers deep, 10000 by default, and one nested deeper is refused rather than walked:

# dies: this document nests containers more than 10000 deep, which is as
#       deep as this library walks. sops stops as well: ...

The number is sops's, not this library's. Measured against sops 3.13.3, counting containers from the document's own root mapping: go-yaml accepts 10001 and refuses 10002 with yaml: exceeded max depth of 10000, in both directions and ahead of the data key; Go's JSON encoder accepts 10000 and refuses 10001 with exceeded max depth. 10000 is therefore the deepest a document can be and still be readable in both formats. It is one level tighter than go-yaml alone would be, deliberately: the alternative is writing a JSON file sops cannot read back.

Nothing that came from a sops-written document can reach this. It exists for what a caller can build in Perl and for what a hand-written file can carry, and because a walk that cannot say no has only a hang to offer -- before this, 4000 levels took 101s and 410 MB in the MAC's walk alone.

$File::SOPS::MAX_DEPTH is writable and is what a caller processing untrusted documents can lower to refuse deep documents earlier. Raising it above 10000 produces documents sops will refuse to read.

See k117.

A plain YAML infinity is the float go-yaml reads

New in 0.003, and a change for existing callers of every method that reads a YAML file. A leaf a YAML document wrote as a plain scalar whose token go-yaml resolves to a non-finite float -- .inf, .Inf, .INF, the same three with a leading + or -, .nan, .NaN, .NAN -- is that float here too, carrying the document's own token as its text. A quoted ".inf" is the string it has always been; the difference is decided by asking YAML::PP how the document wrote the scalar, never by matching the leaf's text. See "A plain infinity comes back as the float go-yaml reads" in File::SOPS::Format::YAML.

This applies to a plaintext file as well as to an encrypted one, which is what changed last: until then the repair ran only for a document that carried a sops: section, so this library's own "decrypt_file" wrote v_unencrypted: .inf and its own "encrypt_file" refused to read that file back, and "edit" could not save a document it had just opened. sops makes one parse and this now makes one too.

What it means per slot, measured against sops 3.13.3 for all twelve spellings:

  • In an unencrypted slot the leaf is written as the token the document had, and the MAC digest covers +Inf / -Inf / NaN. sops -d reads it, and for the three spellings sops -e itself writes the wire bytes are identical to sops's.

  • In an encrypted slot the leaf is refused, naming the key path: the wire form there is type:float with the plaintext +Inf, and "encrypt_value" in File::SOPS::Encrypted cannot see which format it is writing for -- YAML would carry it, JSON cannot (sops -e exit 4, Error marshaling to json). Before this such a leaf was written as a type:str holding .inf, where sops -e on the same plaintext writes a type:float: a working file that had silently stopped being a number. This is a refusal where sops succeeds, and it is deliberate -- see k122, which is where the encrypted slot gets the format it needs.

  • In JSON nothing changes at either end. .inf is not JSON, so the token cannot reach a JSON document to begin with.

See k123 and docs/adr/0034.

decrypt

my $data = File::SOPS->decrypt(
    encrypted  => $encrypted_content,
    identities => \@age_secret_keys,
    format     => 'yaml',  # optional, auto-detected
    ignore_mac => 0,       # optional, see below
);

Decrypts SOPS-encrypted content.

Takes encrypted content as a string, decrypts the data key using provided age identities, verifies the MAC, and returns the decrypted data structure -- a HashRef for a single-document file, or an ArrayRef of HashRefs for a multi-document YAML stream (see "A multi-document YAML stream is an ArrayRef").

The returned structure holds character strings, so it compares equal to the structure "encrypt" was given. Do not decode it again. See "Character encoding".

A multi-document YAML stream is an ArrayRef

New in 0.003. A YAML file holding more than one document -- documents joined by ---, as sops writes them -- is one tree with N branches carrying one sops metadata section and one MAC spanning every document in order. This method reads such a stream and returns an ArrayRef whose elements are the decrypted documents in document order; a file holding a single document still returns a bare HashRef. Which shape you get mirrors the document, not the call, so code that wants to be shape-agnostic writes

my @docs = ref $result eq 'ARRAY' ? @$result : ($result);

The metadata is taken from the first document, exactly as sops does. A stream carrying a sops section only in a later document surfaces no metadata and is refused with No SOPS metadata found -- the same sops metadata not found sops reports for it.

The MAC authenticates the concatenated leaf sequence across all documents, not where the document boundaries fall. Because the AAD carries no document index, a value can be moved from one document to another undetected as long as the concatenated leaf order is preserved; what the MAC does catch is a deleted, duplicated, reordered or altered value. This is a property of the sops format, not of this implementation. See File::SOPS::Format::YAML.

Until 0.003 a multi-document stream was refused outright -- it was "parse" in File::SOPS::Format::YAML that stopped it, before decrypt saw it -- so no caller has ever received a value from this path, and widening the return type over it breaks nothing.

New in 0.003, and a change for existing callers: a JSON number past Go's int64 comes back as a float. Past 2**64-1 it used to come back as a string, so "$value" can print 1e+20 where it printed 100000000000000000000; between 2**63 and 2**64-1 it used to come back as an exact Perl integer, and there the printed digits are unchanged while 0 + $value is now the double they name. Only a bare literal is affected -- a quoted "100000000000000000000" is a string as it always was, and so is every value in a document this library wrote before 0.003. Which slot moves which half, and what is refused instead, are in "A number past Go's int64 is a float". Unlike "extract", this method does not wrap a float leaf in a dualvar: a dualvar inside a tree changes the bytes the emitters write. See "canonical_float_dualvar" in File::SOPS::Encrypted.

The identities parameter must be an ArrayRef of age secret keys (starting with AGE-SECRET-KEY-1...).

If format is not specified, it will be auto-detected from the content.

Dies if none of the provided identities can decrypt the data key, or if MAC verification does not succeed. Verification failing to run counts as not succeeding: a document whose sops section has no mac, or a mac that is not a well-formed ENC[...] value, or one that will not decrypt under the data key and this document's lastmodified, is refused rather than returned unverified. This mirrors the Go implementation, which reports File has no MAC / Cannot decrypt MAC and stops.

ignore_mac is the equivalent of the reference implementation's --ignore-mac, and the only way to read such a document. It skips verification entirely, so what it returns is decrypted but not authenticated -- the AAD binding on each individual value still holds, but nothing detects a value that was deleted, duplicated, moved to another key, or replaced with one taken from elsewhere in the same document. Use it to recover data, not to consume it.

The rule decides what a value is, in both directions

New in 0.003, and it changes what this method returns and what it refuses. The document's encryption rule -- "should_encrypt_path" in File::SOPS::Metadata, built from unencrypted_suffix, encrypted_suffix, unencrypted_regex or encrypted_regex -- is asked about every leaf on the way out, exactly as it is on the way in, and its answer is what decides whether a leaf is ciphertext at all. Until 0.003 this method asked the leaf instead: anything that looked like ENC[...] was decrypted, whatever the rule said.

That is how sops reads a document, and two things follow from it.

A leaf the rule excludes is a literal value, whatever its text spells. An ENC[...] string at such a path is not decrypted; it comes back as that string, and the MAC covers that string rather than the value behind it. A document whose rule excludes a leaf that really is encrypted therefore fails its MAC, which is what sops does with it too (measured on 3.13.3 over four formats and all four rule fields: exit 51, MAC mismatch, with the digest taken over the ENC[...] text byte for byte). It also means a plain string of your own that happens to spell ENC[...] survives a round trip intact, as long as the rule leaves its path alone.

A leaf the rule selects must be encrypted, and one that is not is refused at its path -- because reading it as a literal meant encrypting it on the next write, turning a value that was readable into ciphertext under a data key the caller may not keep. sops refuses the same document at exit 25. Four shapes are left alone rather than refused, because sops leaves them alone as well: an undef, an empty string, a comment, and an empty list or mapping. The first two are also the only shapes "encrypt" writes bare into a slot the rule selects.

The error message names the path and the rule and never the value: a leaf that is bare where the rule says it is encrypted is a secret in the clear, and an error message goes into bug reports.

ignore_mac => 1 does not change any of this. It skips the MAC and nothing else, so a document whose rule excludes an encrypted leaf comes back holding that leaf's ENC[...] text -- decrypted values everywhere the rule selects, ciphertext everywhere it does not.

A regex rule is read here in RE2's dialect too, which is the one place this path is more permissive than the write path. sops does not report a pattern RE2 cannot compile: it discards the compile error, so the rule matches nothing and every value is classified as though the rule were not there. That is reproducible and it is reproduced, so unencrypted_regex: "(?=foo)" -- which sops -e --unencrypted-regex will write for you without a word -- is a document this method reads at exit 0, exactly as sops -d reads it. "encrypt" and "rotate" still refuse to write under such a rule. What stays refused on this path is a pattern the two dialects compile and read differently (\v, \Q, \E) or one Perl cannot compile at all ((?U)): there is no sops answer to reproduce for those, so guessing at one would classify leaves wrongly and silently. See docs/adr/0051.

A comment in a list comes back as a File::SOPS::Comment

New in 0.003. sops attaches a comment to the node that follows it, and where that node is a sequence entry it writes the comment as a sequence entry of its own (- ENC[...,type:comment]) -- in YAML and, measured, in JSON written with --output-type json as well. Such an entry comes back as a File::SOPS::Comment object holding the comment's text, sitting at the index the file puts it at. Hand the same tree back to "encrypt" or use "rotate" and it is written out again as a type:comment entry; sops -d then restores it as a real comment, on the node it was attached to.

It is deliberately an object and not the text: a comment is not a value, and a string here would be an extra element the file does not contain. That is what this used to return, and a decrypt plus encrypt cycle made it a permanent value with sops -d reporting success at every step (k108). A comment leaf is not covered by the MAC, which is what sops does with one.

Two shapes are still refused, both naming the path: a comment in a mapping value slot, which no SOPS store writes and which sops reads back as a dump of Go's comment struct; and writing a comment into plaintext, so "decrypt_file" and "edit" refuse a document that has one -- YAML::XS cannot emit a comment, and a comment line handed to an editor would be dropped on the way back in. A comment above a mapping key never reaches this library at all: it stays a comment line, which YAML::XS discards on the way in and cannot write on the way out. See docs/adr/0041.

New in 0.003: a plaintext comment in an encrypted slot is warned about. Distinct from the encrypted type:comment entry above: where a File::SOPS::Comment stands bare in a slot the document's encryption rule selects, this method carps. That is the third of the four bare shapes "The rule decides what a value is, in both directions" tolerates in a selected slot -- the document is still read, the comment comes back unchanged and stays out of the MAC, exactly as sops -d reads the same document at exit 0 and warns the same way (its message quotes sops's own Found possibly unencrypted comment in file). The warning is advisory -- such a comment is neither encrypted nor authenticated and may hold a secret in the clear -- and its text is not put in the warning, for that same reason: a value that lands in a log was not encrypted for any practical purpose. Any re-encryption of the document ("rotate", "edit", "encrypt_in_place", or "decrypt_file" then "encrypt_file") turns the comment into an encrypted type:comment leaf and silences it.

This reaches dotenv and INI only. A plaintext comment has to survive parsing into a File::SOPS::Comment before there is anything to warn about, and YAML::XS discards one before this library sees the tree -- so a YAML document never gets here, though sops itself warns for YAML as well. See docs/adr/0067.

A YAML literal that overflows a double comes back as a string

New in 0.003, and a change for existing callers. From an unencrypted YAML slot, a literal libyaml resolves to a non-finite double -- 1e400, a 401-digit integer, and the bare spellings Inf, inf, INF, Infinity, NaN, nan, NAN, -Inf and +Inf -- comes back as a plain string holding the literal's own text. It used to carry Inf or NaN as its numeric half as well, because YAML::XS returns those literals with both halves set.

This is a correction, and it does not extend to the encrypted slot. go-yaml -- the parser sops reads a document with -- resolves none of those spellings to a number either: strconv.ParseFloat answers ErrRange, so sops keeps a string, writes type:str and digests the literal's own text. The old return value also belonged to a document this library mostly could not read at all: of 20 such documents sops -e wrote and sops -d read, 17 failed here with MAC verification failed and 3 verified by coincidence. An encrypted type:float whose plaintext is +Inf, -Inf or NaN is unchanged and still decrypts to a real Perl non-finite float, bit for bit -- measured on a document sops wrote, 000000000000f07f, 000000000000f0ff and 000000000000f8ff -- because such a leaf is still an ENC[...] string when the document is parsed and cannot reach the retyping walk at all.

Arithmetic is not what changes. Perl numifies every one of those spellings to the same double and does not warn, so 0 + $value answers exactly as before. What moves is the scalar's type, and with it what re-encrypting the returned tree writes: type:str, which is what sops writes for the same leaf, where the numeric half used to hit the non-finite refusal in "assert_representable" in File::SOPS::Encrypted. Untouched: the twelve spellings go-yaml really does resolve to a non-finite float (.inf, .nan and their case variants -- an encrypted one of those decrypts to a real Inf here as it always did, and an unencrypted one is the separate repair described under "A plain YAML infinity is the float go-yaml reads"), and JSON, where sops refuses the document itself. See "parse" in File::SOPS::Format::YAML, "A number past Go's int64 is a float", k102 and docs/adr/0023.

A document that contains itself is refused

New in 0.003. A document carrying a recursive YAML anchor -- one whose value is nested inside itself -- is refused here, before the data key is unwrapped, rather than hanging the process as it did until now. The check and the reasoning are the same in both directions and are described under "A structure that contains itself is refused".

The refusal deliberately comes ahead of the key: a cyclic document reports the cycle even when none of the identities could have opened it. That is the order sops answers in, measured -- sops -d on such a file with no age identity available reports yaml: anchor 'a' value contains itself, not a failure to get the data key.

ignore_mac does not get past this. It suppresses verification, not the document's shape.

The same holds, at the same place and in the same order, for a document whose aliases are acyclic but exponentially shared -- see "A document that expands far beyond what it contains is refused". Measured, sops -d on such a file with no age identity available reports yaml: document contains excessive aliasing rather than a failure to get the data key, so this reports it there too. Before the guard, decrypt at 15 levels walked 65,535 leaves before it could report anything at all, and with ignore_mac => 1 it walked the same tree with nothing to report.

And for a document nested deeper than this library walks -- see "A document nested deeper than sops can carry is refused". Measured the same way: sops -d on an over-deep encrypted file reports yaml: exceeded max depth of 10000 whether or not an age identity is available, so the depth is reported here ahead of the key as well.

encrypt_file

File::SOPS->encrypt_file(
    input      => 'secrets.yaml',
    output     => 'secrets.enc.yaml',  # optional, defaults to input (in-place)
    recipients => \@age_public_keys,
    format     => 'yaml',              # optional, auto-detected from filename
);

Encrypts a file.

Reads the input file, encrypts it for the specified recipients, and writes the encrypted content to the output file. If output is not specified, encrypts in-place (overwrites the input file) -- which is what "encrypt_in_place" spells out.

The output is written atomically, whether it is the input file, another file that already exists, or a new one: the plaintext input, or whatever the output file held before, survives a write that cannot finish. Until 0.003 it did not, and the consequences of the rename that fixed it -- a new inode, so hard links keep the old content -- are in "How a file is written".

The input is read as UTF-8; see "Character encoding".

Dies if the input already has a top-level sops entry -- which is what an already-encrypted file looks like. Until 0.003 there was no such check, and the result destroyed data: parsing split the sops section off before "encrypt" ever saw it, so the ENC[...] strings were encrypted a second time under a new data key while the old section -- holding the key they were encrypted with -- was discarded rather than written back. The doubly-wrapped file was written out successfully and silently, over the original if output was omitted, and decrypting it returns the inner ENC[...] strings that nothing can now decrypt. To re-key an encrypted file use "rotate"; to change its contents, decrypt it first. sops refuses the same input with exit code 203.

It dies whatever that entry holds. A plaintext file using the name for its own value -- sops: mine, a list, an explicit null -- is refused for the same reason and with the same exit code by sops, and until 0.003 this method took it, dropped the key and wrote the rest back: parsing removed the entry before deciding there was no metadata section, so the guard above never saw it and the key was simply missing from the output. See "from_hash" in File::SOPS::Metadata.

Format is auto-detected from the filename extension (.yaml, .yml, .json, .env) unless explicitly specified.

mac_only_encrypted, the four encryption rules and metadata are all passed through to "encrypt"; see "Choosing what gets encrypted".

Returns true on success.

encrypt_in_place

File::SOPS->encrypt_in_place(
    file       => 'secrets.yaml',
    recipients => \@age_public_keys,
    format     => 'yaml',   # optional, auto-detected from filename
);

Encrypts a plaintext file over itself.

This is "encrypt_file" with output omitted, said in one argument instead of two. There is nothing encrypt_file will not do for you here -- since 0.003 both write atomically, so neither can leave the plaintext truncated or half-encrypted ("How a file is written") -- but file cannot be got wrong the way an input/output pair can, and it is the same shape "edit" and "rotate" take for the same job.

The file's permissions are preserved, it comes back with a new inode so hard links keep the old plaintext, and a symlink is resolved rather than replaced. All three, and where they differ from sops, are in "How a file is written".

mac_only_encrypted, the four encryption rules and metadata are passed through to "encrypt"; see "Choosing what gets encrypted".

Dies if the file is already encrypted, i.e. has a top-level sops entry, for the reasons in "encrypt_file". There is deliberately no "encrypt it again" mode: to re-key an encrypted file use "rotate", to change its contents use "edit". sops answers the same call the same way, with exit code 203 and the same advice.

Returns true on success.

decrypt_file

File::SOPS->decrypt_file(
    input      => 'secrets.enc.yaml',
    output     => 'secrets.yaml',
    identities => \@age_secret_keys,
    format     => 'yaml',  # optional, auto-detected from filename
);

Decrypts a SOPS-encrypted file.

Reads the encrypted input file, decrypts it using the provided identities, and writes the decrypted content to the output file.

The output is UTF-8 encoded, so a file round-tripped through "encrypt_file" and back is byte-identical in its non-ASCII content rather than double-encoded. See "Character encoding".

It is written by the format handler's emit ("emit" in File::SOPS::Format::YAML, "emit" in File::SOPS::Format::JSON) -- the same emitter the encrypted document goes through, minus the sops section -- so the plaintext and the encrypted file cannot disagree about quoting, booleans, key order or float precision. In YAML that also means it starts with the document-start marker ---, which sops -d does not write; the line is cosmetic and "Every YAML file starts with ---, where sops writes none" has the measurement.

One rendering deliberately differs from sops -d: in JSON, an integral type:float is written 2.0 where sops writes 2. The YAML output is 2, exactly as sops -d writes it. 2.0 parses back as a float, so decrypt_file -> hand-edit -> "encrypt_file" keeps such a leaf at type:float, where 2 silently relabels it type:int -- which is what sops -d followed by sops -e does to its own document, and what the YAML side therefore still does here. Measured against sops 3.13.3 on a document sops itself wrote; see "decrypt_value" in File::SOPS::Encrypted, k73 and docs/adr/0009.

The plaintext of a JSON number past 2**64-1 moved in 0.003, because such a leaf is now a float rather than a string: an unencrypted 100000000000000000000 is written back bare where it used to be written as the JSON string "100000000000000000000", and an encrypted one as 1e+20. Both parse back to the same double and both are what the input document meant; what changes is that the plaintext no longer turns the reference's own number into a string. The window below it, 2**63 to 2**64-1, is written back byte for byte as before -- what moved there is that a document whose number is not its double's canonical decimal is now read at all, where it used to be refused as MAC verification failed, and that decrypt_file then writes the normalisation sops -d writes. See "A number past Go's int64 is a float".

Unlike "encrypt_file", output is required to prevent accidental data loss. It is nonetheless written atomically, since nothing stops it naming a file that matters -- the encrypted input itself, or a working copy being refreshed. Until 0.003 an output that already existed was truncated before the plaintext was written and a failing write was not reported, so a full disk replaced that file with an empty one and decrypt_file still returned true. See "How a file is written", which also covers what mode the output file gets.

ignore_mac is passed through to "decrypt"; read the warning there before using it.

A dotenv or INI document whose encrypted slot holds a plaintext comment carps on the read, as "decrypt" describes under "A comment in a list comes back as a File::SOPS::Comment"; the decrypted output still carries it.

A multi-document YAML stream (see decrypt) is decrypted, MAC-verified and written back out as a plaintext multi-document YAML stream, its documents joined by ---.

Returns true on success.

extract

my $value = File::SOPS->extract(
    file       => 'secrets.enc.yaml',
    path       => '["database"]["password"]',
    identities => \@age_secret_keys,
    format     => 'yaml',  # optional, auto-detected from filename
    document   => 0,       # optional, which document of a stream (default 0)
);

Extracts and decrypts a single value from an encrypted file.

Path can be specified in two formats:

  • Bracket notation: ["database"]["password"], ['database']['password']

  • Dot notation: database.password

For array indices, use a bare number: ["items"][0] or items.0. Before 0.003 the bracket parser only recognised double-quoted components, so ["items"][0] matched items alone and returned the whole ArrayRef.

The whole file is decrypted and MAC-verified either way. extract saves you the navigation, not the work -- it is not a cheaper "decrypt". ignore_mac is passed through to "decrypt".

Reaching a document of a multi-document stream

For a multi-document YAML stream (see decrypt), path addresses a single document and document names which one, defaulting to 0. The path language stays sops's, applied to the document you name -- extract does not grow a document axis into the path, because sops cannot: there a leading integer already means "the Nth key of document 0".

Two guards, both loud:

  • document beyond the last document dies naming the file's document count, rather than returning undef.

  • A path not found dies naming which document was searched. extract never falls through to a later document looking for a key -- that would turn a typo in document into a plausible-looking answer from the wrong one.

On a single-document file, document => 0 is a no-op and the not-found messages are unchanged; document => 1 there dies, because there is no document 1. Whatever sops --extract '[1]["key"]' does is not reproduced: it panics the Go binary (docs/adr/0033 N2), and a panic is not a specification.

path is a character string and is matched against the document's keys as characters, so a non-ASCII key is written in path exactly as you would write it in data. See "Character encoding".

Returns whatever the path names: a decrypted scalar for a leaf, or a HashRef or ArrayRef for a branch -- extract(path => '["database"]') returns the whole subtree, as sops --extract does.

A float leaf comes back as a "dualvar" in Scalar::Util: numerically the value itself, as a string the canonical decimal the document holds. A decrypted float is a bare NV with no string form of its own, so printing one went through Perl's 15 significant digits -- an encrypted 0.30000000000000004 arrived as 0.3, where sops -d --extract prints all 17. Arithmetic, ==, sprintf '%f' and is_deeply are unchanged; what changes is "$value", and that change is the point. New in 0.003; see k61 and docs/adr/0010.

Two things that dualvar does not do. The spelling is the wire's -- the canonical decimal "value_to_bytes" in File::SOPS::Encrypted derives, which is what the ciphertext holds and what the MAC covers, and which is positional at every magnitude. sops -d --extract prints the value's YAML or JSON serialization instead, and that switches to an exponent at the ends of the range. Measured, sops 3.13.3, one document per row:

format   sops prints an exponent when      example
yaml     decimal exponent >= 6 or < -4     1000000 -> 1e+06, 1e-5 -> 1e-05
json     decimal exponent >= 21 or < -6    1e21    -> 1e+21, 1e-7 -> 1e-7

So 1e20 stringifies as 100000000000000000000 here and prints as 1e+20 from a YAML document -- but as 100000000000000000000 from a JSON one, where the two agree up to 1e21. The exponent's own spelling differs between the formats as well (1e-05 in YAML, 1e-7 in JSON). No digits are lost in any of it: measured from the smallest subnormal to DBL_MAX, in both formats, every spelling either side prints parses back to the identical double. What the positional form costs is length -- DBL_MAX stringifies as 309 digits and 5e-324 as 0. followed by 323 zeros and a 5. Matching sops would mean a second float formatter, Go's %g rules beside the %f ones this distribution has, and then a third for JSON's; the decision to keep the wire's spelling is recorded in ADR 0010 (k79).

The wire's spelling is not the document's either, where the two differ: an unencrypted 1.50 or 42.0 comes back as 1.5 and 42, because the string half is the canonical decimal and not the source text.

Since 0.003 that paragraph covers a JSON number past Go's int64 as well: such a leaf is a float now, where past 2**64-1 it used to come back as a plain string and between 2**63 and 2**64-1 as an exact Perl integer, so extract wraps it and prints all its digits in an encrypted slot as well as an unencrypted one. A hand-written 99999999999999999999 comes back as 100000000000000000000, which is also what the document itself now holds. For the lower window the digits printed are the ones the document already carried, and it is 0 + $value that moved instead. See "A number past Go's int64 is a float".

The second thing the dualvar does not do is travel: it applies to the leaf this method returns and to nothing else. Floats inside a returned branch are the plain scalars they have always been, because a dualvar in a structure changes the bytes the emitters write. Not the value: putting one into an unencrypted slot stores the canonical decimal as a number, in JSON as in YAML (k78, docs/adr/0011). What changes is the spelling at the extremes -- 1e300 written as 301 positional digits rather than 1e+300 -- and an encrypted slot is unaffected either way.

Since 0.003 a non-finite YAML literal comes back from an unencrypted slot as a string, and is not wrapped either. 1e400, a 401-digit integer and the bare spellings Inf, inf, INF, Infinity, NaN, nan, NAN, -Inf and +Inf are the string go-yaml reads there, and a string is not a float. This is a correction -- such a leaf used to carry Inf or NaN as its numeric half, on the few of those documents that could be read here at all -- and it stops at the unencrypted slot: an encrypted type:float whose plaintext is +Inf, -Inf or NaN still comes back as the real Perl non-finite float it always did. That one is the single float this method does not wrap, because +Inf and NaN are wire spellings rather than a number's decimal, so "$value" is Perl's own Inf / NaN and not a canonical form. See "A YAML literal that overflows a double comes back as a string" under "decrypt" and "canonical_float_dualvar" in File::SOPS::Encrypted.

Dies if the path does not exist, at any depth, naming the component that was not found. Before 0.003 a missing top-level key returned undef while a missing nested one died, so the same mistake was silent or loud depending on where it was made -- and undef was indistinguishable from a key whose value really is null. sops reports component ['nope'] not found at every level.

rotate

File::SOPS->rotate(
    file       => 'secrets.enc.yaml',
    identities => \@age_secret_keys,
    recipients => \@new_recipients,  # optional, keeps current recipients
    format     => 'yaml',            # optional, auto-detected from filename
);

Rotates the data key (re-encrypts all values with a new key).

This operation:

1. Decrypts the file using identities
2. Generates a new random data key
3. Re-encrypts all values with the new data key
4. Encrypts the new data key for recipients (or existing recipients if not specified)
5. Writes back to the same file

Step 5 is atomic: the rotated document is written next to the file and renamed over it, so an interrupted rotation leaves the file readable under its old data key rather than truncated. Until 0.003 the file was opened with '>' first, which is the worst moment to lose it -- the new data key exists only in the buffer being written, so a file truncated there is not recoverable with any identity. "How a file is written" has the consequences of the rename.

Key rotation is recommended periodically for security, or when removing a recipient's access.

The rotated file keeps the sops section it had, apart from what a new data key necessarily replaces. Its encryption rules, mac_only_encrypted and any field this distribution does not model -- shamir_threshold and whatever a later sops adds -- are carried over; the wrapped data keys, the MAC and lastmodified are regenerated. Until 0.003 none of that was carried: rotate called "encrypt", which built fresh metadata with the defaults, so a file that customised any of it was rewritten under the default rules.

The values keep their type: labels too, and type:float on a whole number is the one that did not. Rotation re-encrypts every leaf, so each label is re-derived from the scalar the decryption produced, and until 0.003 that scalar came back carrying Perl's public SVf_IOK whenever the plaintext was integral: a whole: 2.0 that sops itself had written was rotated back out as type:int, at exit 0 and with the MAC holding either way, because the plaintext is 2 under both labels. What moved was the document's own type field, silently. "decrypt_value" in File::SOPS::Encrypted has the conversion this now uses instead. See k73 and docs/adr/0009.

Files rotate refuses

Rotation makes a new data key, and this distribution can only wrap one for age recipients. A file whose sops section holds key material for another backend -- pgp, kms, gcp_kms, azure_kv, hc_vault or key_groups -- is therefore refused rather than rotated:

Refusing to rotate 'shared.yaml': its sops section holds key material
this distribution cannot re-encrypt (pgp). ...

Both alternatives are wrong and the quiet one is worse. Dropping those entries -- which is what happened before 0.003 -- revokes access for everyone behind them while reporting success, and the file still decrypts perfectly for whoever runs the command, so nothing looks amiss until someone else needs it. Keeping them would leave a wrapped copy of a key that no longer decrypts anything. Rotate such a file with the sops CLI, which can re-encrypt for every backend; or, if losing those recipients is the intention, say so by calling "decrypt" and "encrypt" yourself.

The second refusal is the document's own encryption rule, where that rule is a regex Go RE2 and Perl do not read the same way. Rotation writes, and this distribution does not write a document under a rule it cannot apply as sops applies it -- see "The regex rules are matched in RE2's dialect" in File::SOPS::Metadata. Note the asymmetry, which is deliberate and is docs/adr/0051: "decrypt" and "extract" do read such a document where sops's own answer is reproducible, so a file this method refuses to rotate is still one you can read, and "decrypt" followed by "encrypt" under a rewritten pattern is the way through.

A rule that does not cover what is encrypted stops on the MAC

Changed in 0.003, and it changed twice. A rotation re-encrypts the document under the rule the document itself carries, and that rule has to be the one the document is already encrypted under. Where a leaf is encrypted in the file and the rule says it is not, this method used to write that value back into the file in plaintext, at exit 0 -- "decrypt" was driven by which values looked encrypted and returned the plaintext of all of them, "encrypt" was driven by the rule and encrypted only what the rule selected, and everything in between went to disk bare.

That cannot happen any more, and not because a guard stands in the way. The decryption "rotate" does first is rule-first now, so a leaf the rule excludes is never decrypted at all: what comes back is its own ENC[...] text, and what gets written is that same text. There is no plaintext for this method to leak. What stops such a document is the MAC, in "decrypt", which is exactly where sops stops it -- and it is stopped because the digest covers the ENC[...] text rather than the value behind it. See "The rule decides what a value is, in both directions".

The refusal an intermediate release raised here, naming the leaf and the rule, is gone with the guard that raised it. It refused two documents the MAC does not, and sops reads both: one whose stored MAC really is over the literal, and -- under ignore_mac => 1 -- any of them.

ignore_mac is passed through to "decrypt"; rotating a file you could not verify re-signs whatever it contained, so prefer to fail.

A dotenv or INI document whose encrypted slot holds a plaintext comment carps on the "decrypt" this does first (see "A comment in a list comes back as a File::SOPS::Comment"); the rotation then re-encrypts that comment into a type:comment leaf, so a second rotation is silent.

Returns true on success.

edit

File::SOPS->edit(
    file       => 'secrets.enc.yaml',
    identities => \@age_secret_keys,
    editor     => 'vim',   # optional, defaults to $ENV{EDITOR}
    format     => 'yaml',  # optional, auto-detected from filename
);

Decrypts a file, opens it in an editor, and re-encrypts what comes back.

The decrypted document is written to a fresh temporary directory (mode 0700) as a file (mode 0600) with the same basename as the original, the editor is run on it, and the result is parsed and encrypted back over the original -- atomically, as "encrypt_in_place" does, and for the same reason.

That is the same shape sops uses, and it has the same caveat: the plaintext touches the filesystem. Point TMPDIR at a tmpfs if that matters to you.

The decrypted copy is removed on every way out of this method, which is three of them:

  • Returning, or dying anywhere -- including from the editor failing, the result not parsing, or the re-encryption refusing. The directory belongs to a File::Temp object scoped to the call, so unwinding removes it.

  • SIGTERM and SIGHUP, which are caught for the duration of the call, remove the directory and are then re-raised with the default disposition, so the process still dies of the signal it was sent. Perl defers a signal that arrives while the editor is running until the editor exits, so this happens on the way back rather than immediately.

  • Ctrl-C, which does not reach here at all: Perl's system ignores SIGINT for the duration of the child, so the interrupt goes to the editor. The editor dying of it is an editor that exited non-zero, which is the first case again -- reported as was killed by signal 2 with the file unchanged.

Returns 1 if the file was rewritten, and 0 if the editor left the content byte-identical -- in which case the file is not touched at all, so its lastmodified, MAC and wrapped data keys stay as they were. sops reports the same situation as File has not changed, exiting. with exit code 200.

The editor

editor may be a string, which is split into words the way a shell would (editor => 'code --wait'), or an ArrayRef, which is used as it stands. It defaults to $ENV{EDITOR}, and the temporary file's path is appended as the final argument. The editor is run without a shell.

Dies if neither is set. sops falls back to vim, nano or vi here; this method does not, because a library that opens an interactive editor nobody asked for hangs an unattended script rather than failing it.

Dies if the editor exits non-zero, naming the status or the signal, and leaves the file unchanged -- an editor that refused to start or was killed has not produced an edit worth encrypting. sops behaves the same way (Could not run editor: exit status 3, exit code 201).

What comes back is checked before anything is written

The edited text must parse, as one document, to a mapping, and must not carry a top-level sops entry of its own. Any of those dies with the original file untouched.

Those are two refusals, not one, and they say so: an entry of your own under that name is refused as a reserved key -- whatever shape it has, a mapping, a scalar, a list or an explicit null -- and never as a parse failure, because such a document parses perfectly well. sops separates the same two cases in its editor mode, as Tree not valid for encryption against Could not load tree, probably due to invalid syntax.

A document that does not parse is lost. The temporary file is removed on the way out, so the only copy of what was typed is whatever the editor still has in its buffer. This is the one place where sops does better: it reopens the editor on the same file until the document parses, which needs a terminal to return to and an interactive user in front of it -- neither of which a library method can assume.

Editing re-keys the file

Unlike sops edit, which keeps the document's data key and only rewrites the values, this method decrypts and encrypts, so the file comes back with a new data key -- the wrapped copies in the sops section change on every edit, and so does the diff. Nothing is lost by it: the age recipients and the encryption policy (the rules, mac_only_encrypted, and any sops field this distribution does not model) are carried over exactly as "rotate" carries them.

What it does mean is that edit refuses the same files "rotate" refuses: a document whose sops section also holds pgp, kms, gcp_kms, azure_kv, hc_vault or key_groups material cannot be re-encrypted for those recipients here, and dropping them silently would revoke their access while reporting success. See "Files rotate refuses".

It stops on the other of rotate's files too, and before the editor is opened: a document holding an encrypted value at a path its own encryption rule says is not encrypted fails its MAC in the "decrypt" this method does first. See "A rule that does not cover what is encrypted stops on the MAC". Stopping after the editor had run would throw away what was just typed, for a defect that was in the file before it started.

Key order is not preserved either: the plaintext handed to the editor is emitted from a Perl hash, so it comes out sorted whatever order the encrypted file had. That is the same emitter "decrypt_file" uses -- the format handler's emit, which is also what the encrypted document is written with -- and it is the order this distribution writes documents in anyway, so it does not affect the MAC.

ignore_mac is passed through to "decrypt"; editing a file you could not verify re-signs whatever it contained, so prefer to fail.

A dotenv or INI document whose encrypted slot holds a plaintext comment carps on the "decrypt" this does first (see "A comment in a list comes back as a File::SOPS::Comment"); re-keying the file then re-encrypts that comment into a type:comment leaf.

What the round trip through the editor keeps, and what it does not

The document the editor sees is plaintext, so anything a value knew that its plaintext spelling does not say is gone by the time it comes back. What comes back is parsed exactly as any other YAML file would be -- there is no second, gentler parse for text this method wrote itself.

That is enough for a plain YAML infinity, because the plaintext really does say it: .inf written plain is a float to go-yaml and to "decrypt_file"'s output alike, so a document sops wrote with a bare .inf in an unencrypted slot survives an edit and comes back with the wire byte-identical. Before 0.003 it did not -- editing any other key in such a file died with the leaf refused, and the edit was destroyed with it, because the temporary file is already gone by then. See "A plain YAML infinity is the float go-yaml reads" and k123.

It is not enough for a non-finite float in an encrypted slot, and that one is still wrong: such a leaf decrypts to a real Perl infinity, whose only plaintext spelling from this emitter is a bare Inf / -Inf / NaN -- tokens go-yaml reads as strings. The editor is shown Inf, the string Inf comes back, and the leaf is re-encrypted as a type:str. sops edit keeps it a type:float, measured. The file is written and nothing is said, so this is the one place edit can still lose a value quietly. Open as k134.

A data_key => $bytes argument would close the gap -- pass the existing data key through and this method stops re-keying -- but it puts raw key material on the public API, which is a real decision (and probably an ADR) rather than a refactor. It will be worth doing once a backend other than age exists (k39): today the refusal only fires on documents this distribution could not have produced in the first place.

creation_rules_for

my %args = File::SOPS->creation_rules_for(file => 'secrets/prod.yaml');

File::SOPS->encrypt_in_place(
    file => 'secrets/prod.yaml',
    %args,
);

Reads the .sops.yaml that governs a file and returns the "encrypt" arguments its first matching creation rule asks for: recipients, plus whichever of the four encryption rules and mac_only_encrypted that rule carries. The returned list is meant to be splatted straight into "encrypt", "encrypt_file" or "encrypt_in_place", which is where the actual encrypting still happens -- this method decides for whom and under which rules, and nothing else.

Nothing here reads .sops.yaml implicitly. encrypt and friends still want their recipients spelled out; this is the one method that will go and look.

Finding the config file

.sops.yaml is looked for in the directory holding file and then in every directory above it, up to the filesystem root, and the first one found is used. Nothing stops the walk earlier -- not a .git directory, not $HOME -- which is what sops does as well (measured on 3.13.3). The name must be exactly .sops.yaml: a .sops.yml is ignored there and here.

This is a deliberate deviation, and it is the one thing here that differs from the reference implementation. sops walks up from the current working directory, not from the file: measured on 3.13.3, sops -e a/b/c/secrets.yaml run from the top of that tree does not see an a/b/.sops.yaml at all, and sops -e /abs/path/secrets.yaml run from an unrelated directory reports config file not found however many config files sit above the file. That is a sensible rule for a command a person types in the directory they are working in, and a useless one for a library: the caller's working directory has nothing to do with the file it was handed, and a daemon whose cwd is / would find nothing at all. The two agree in the ordinary case -- one .sops.yaml at the top of a repository, the file somewhere underneath it -- and differ only when config files are nested, where walking up from the file picks the nearer and more specific one. The measurements and the two rejected alternatives are in docs/adr/0007.

config names a config file explicitly, and skips the search entirely. It is the equivalent of sops's --config. Its value is not taken from $SOPS_CONFIG, which sops does honour: an environment variable that redirects which public keys a secret gets encrypted to is a reasonable thing for a user to set for a command they are running, and not a reasonable thing for a library to obey on behalf of a caller who never asked. Pass config => $ENV{SOPS_CONFIG} if you want it.

Which rule matches

The rules are tried in order and the first match wins; a rule with no path_regex matches everything, which is how a catch-all is written at the end of the list. Both are sops's behaviour.

path_regex is not matched against the path you passed. It is matched against that path made absolute and normalised, and then taken relative to the directory holding the config file -- so with a config at the top of a repository, a rule matches secrets/prod.yaml whether the caller said secrets/prod.yaml, ./secrets//prod.yaml, ../repo/secrets/prod.yaml or the full absolute path, and whether it is called from the top of the repository or from inside secrets/. Symlinks are not resolved, so a rule sees the link's path and not the target's. All of that is measured against sops 3.13.3, including the fallback: a file that is not under the config file's directory at all -- only reachable by passing config here -- is matched as an absolute path instead of as a ../..-prefixed relative one.

The regex is a Perl regex, and nothing translates it. sops compiles the same string with Go's RE2, which is not the same dialect: (?i) works in both, but a lookbehind compiles here and makes sops stop with error parsing regexp (measured on 3.13.3). A path_regex written in either dialect alone will therefore pick different rules -- or no rule -- depending on which of the two tools reads the config. The constructs that RE2 does not have -- lookarounds ((?=, (?!, (?<=, (?<!) and backreferences (\1..\9) -- are refused here at match time, naming the offending construct and the config file; everything that compiles in both ((?i), ., standard quantifiers, character classes, anchors) passes. One that will not compile at all is reported here naming the config file and the rule; sops reports it too. Keep a path_regex to what both accept, and you can rely on the refusal rather than on memorising the dialect differences.

Rules this refuses rather than half-applies

Each of these dies, naming the config file and which rule in it:

  • A rule naming key material for a backend other than age -- pgp, kms, gcp_kms, azure_keyvault, hc_vault_transit_uri -- or key_groups or shamir_threshold. sops wraps the data key for every backend the rule names; age is the only one implemented here, so honouring such a rule would write a document the config says several parties can read and only the age recipients actually can, and report success. This is the refusal "rotate" makes for the same reason. Those are the config file's field names and not the sops section's, which differ.

  • A rule carrying more than one encryption rule. sops refuses the same config with cannot use more than one of encrypted_suffix, unencrypted_suffix, ... for the same rule, and File::SOPS::Metadata refuses the resulting document; catching it here names the config file instead of the document that could not be built.

  • A rule carrying unencrypted_comment_regex or encrypted_comment_regex. Neither parser here keeps comments, so every value would be classified wrongly -- the refusal "encrypt" makes for a document carrying one.

  • A matching rule with no age recipient, which leaves nothing to encrypt for. sops stops on the same rule with Could not generate data key: [empty key group provided].

Not finding a config file at all, and finding one where no rule matches, die too rather than returning an empty list: both are what sops exits non-zero on, and a recipients that quietly came back empty would be a document nobody can decrypt -- or, since "encrypt" refuses an empty recipient list, an error naming neither the file nor the config that failed to produce one.

The rule's own fields

age is a comma-separated list of recipients, or a YAML list of them, or a list whose entries are themselves comma-separated. Whitespace around each recipient is ignored, which is what makes the folded

age: >-
  age1...,
  age1...

form work. Newlines are not separators on their own: measured on 3.13.3, a literal block of recipients without commas is handed to age as one string and fails.

unencrypted_suffix, encrypted_suffix, unencrypted_regex, encrypted_regex and mac_only_encrypted are returned as the "encrypt" arguments of the same names -- see "Choosing what gets encrypted". Fields this does not know are ignored, as sops ignores them.

Note what the returned rules do not do: nothing is applied here. A caller that drops the encryption rule out of %args encrypts under the default instead, and a caller that adds one of its own alongside gets the refusal "Encryption rules are mutually exclusive" in File::SOPS::Metadata gives any document carrying two.

What this does not read or return

A few things a .sops.yaml can carry are deliberately outside this method's scope (k54):

  • destination_rules is not read. sops's full file lifecycle has two rule lists, creation_rules (which decides how a file is encrypted) and destination_rules (which decides how it is rewritten when it is moved to a different path or backend). File::SOPS does not move files, so destination_rules has no consumer here; a config carrying only destination_rules is reported as having no creation rules.

  • A rule naming aws_kms, azure_kv or hc_vault is ignored. Those are sops-section field names that sops also ignores in a creation rule (measured, 3.13.3). The names THIS method refuses are the config file's: pgp, kms, gcp_kms, azure_keyvault, hc_vault_transit_uri.

  • $SOPS_CONFIG is not consulted (covered above under config).

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-file-sops/issues.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.