FiloSottile / typage

A TypeScript implementation of the age file encryption format, based on libsodium.
BSD 3-Clause "New" or "Revised" License
64 stars 9 forks source link

Version issue with age-encrypted sops files #17

Open humphd opened 3 months ago

humphd commented 3 months ago

Thank you for making this. I couldn't believe it when I went looking for a TS age implementation, and lo and behold, you had made an official one. Amazing!

My current use case is being able to decrypt pieces of an age-encrypted sops file in JS. We

Here's an example of the kind of thing I want to parse, where I need to decrypt the value key, and my AGE public key is listed as a recipient:

value: ENC[AES256_GCM,data:asgm,iv:535n5Dj8DJ+XY5KuAYK2nGPKpA2H5Er7eLNPChQxEWg=,tag:TEohl6v4sHcrQK8IAx8p8w==,type:str]
sops:
    kms: []
    gcp_kms: []
    azure_kv: []
    hc_vault: []
    age:
        - recipient: age19j4d6v9j7rx5fs629fu387qz4zmlpsqjexa4s08tkfrrmfdl5cwqjlaupd
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqTXJvbVVsSzVMM1RIcUpY
            RTBPZTdwN3RZUGJDV0p0eDFCaTJzZm1YU0RFCk1YbFg3djFBR3RjQmduaTBBUlFy
            WFR2S2JacC8xUnh4Y29GMk8wK3NGREUKLS0tIFFvRGlHNmt1RGtVVEZ3eUpWbk96
            a1lpeVFqVDlZaHRFV1c5V0pMbXI4RkkKrLaOy3LVv9Uap07S8xQi+CJr9i2tcbZR
            VAgOMocpDRQU6AsiU+suZQ0X+Zz9Obb1oRTez84FSBwoOojYBbjLxA==
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2024-03-15T13:31:49Z"
    mac: ENC[AES256_GCM,data:/qN53oo7iWrZRLm8OopnkK/4IpOYTb+wo2PA3EHQIzuWxnpigxdbnCA8bWdB4v79Xaeu8AJUZOa5kzWh8+we9mPI8M2zj6rwYnbuqrYnF/wzGmEW6PQylw2LN8WNKnlNRWf/a3XP1/v2LXyQCkdtmadejEgG3BjH5gmKLWskl2E=,iv:g+Ppyo9LxFgoCOZTCnesdwwE91d2j8isg91KfXPFntM=,tag:I4b0IyqjxbTJ6+yrCHvtjw==,type:str]
    pgp: []
    unencrypted_suffix: _unencrypted
    version: 3.8.1

Here's my first attempt to get that decryption key:

import age from "age-encryption";
import { readFile } from "node:fs/promises";
import yaml from "js-yaml";

async function getPublicAgeKey(privateAgeKey: string) {
    const { identityToRecipient } = await age();
    return identityToRecipient(privateAgeKey);
}

async function decryptSopsFile(sopsFile: string, privateAgeKey: string) {
    const { Decrypter } = await age();
    const doc: any = yaml.load(await readFile(sopsFile, "utf8"));
    const ageConfig = doc.sops.age;

    const pubKey = await getPublicAgeKey(privateAgeKey);
    const config = ageConfig.find((config: any) => config.recipient === pubKey);

    const decrypter = new Decrypter();
    decrypter.addIdentity(privateAgeKey);
    const decryptionKey = decrypter.decrypt(config.enc.trim(), "text");

    // TODO ...
}

async function main() {
    await decryptSopsFile(process.env.SOPS_FILE, process.env.AGE_KEY);
}

main();

When I run this, I get the following error:

/workspaces/DeepStructure/node_modules/.pnpm/age-encryption@0.1.5/node_modules/age-encryption/dist/format.js:91
        throw Error("invalid version " + versionLine);
              ^

Error: invalid version null
    at parseHeader (/workspaces/DeepStructure/node_modules/.pnpm/age-encryption@0.1.5/node_modules/age-encryption/dist/format.js:91:15)
    at Decrypter.decrypt (/workspaces/DeepStructure/node_modules/.pnpm/age-encryption@0.1.5/node_modules/age-encryption/dist/index.js:115:19)
    at decryptSopsFile (/workspaces/DeepStructure/misc/sops/sops-js/src/sops.ts:20:37)
    at main (/workspaces/DeepStructure/misc/sops/sops-js/src/index.ts:32:5)

Which seems to be https://github.com/FiloSottile/typage/blob/d0744544906d115825c358698a58ba259bc83f23/lib/format.ts#L109

On my system I'm using:

$ age --version
v1.1.1

Do I need to pass more info in order to be able to do this? Use a different version somehow? Or maybe it's not possible?

Thanks for helping me understand what is and isn't possible.

humphd commented 3 months ago

I played with this some more and was able to get it. I needed to extract the base64 encoded key:

function getEncryptionKeyForRecipient(
    sopsFile: string,
    privateAgeKey: string
) {
    const { Decrypter } = await age();
    const doc = await loadSopsFile(sopsFile);
    if (!Array.isArray(doc?.sops?.age)) {
        throw new Error("missing sops age metadata");
    }

    const sopsAgeConfig = doc.sops.age;
    const pubKey = await getPublicAgeKey(privateAgeKey);
    const { enc } = sopsAgeConfig.find(
        (config: SopsAgeConfig) => config.recipient === pubKey
    );
    if (!enc) {
        throw new Error("no matching recipient found in age config");
    }

    const decrypter = new Decrypter();
    decrypter.addIdentity(privateAgeKey);

    const regex =
        /-----BEGIN AGE ENCRYPTED FILE-----\s*([\s\S]*?)\s*-----END AGE ENCRYPTED FILE-----/;
    const matches = enc.match(regex);

    if (!(matches && matches[1])) {
        throw new Error("unable to extract age encryption key");
    }

    const base64String = matches[1].trim();
    const encrypted = Buffer.from(base64String, "base64");
    const decryptionKey = decrypter.decrypt(encrypted, "uint8array");

    return decryptionKey;
}

I'm surprised that I couldn't use the whole -----BEGIN AGE ENCRYPTED FILE-----... block, but perhaps that's just my own ignorance showing.

humphd commented 3 months ago

I ended up making an npm package to work with sops and age in TS/JS: https://github.com/humphd/sops-age

Thanks for making this!