diff --git a/README.md b/README.md index 31200ad..3711294 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,41 @@ const out = await d.decrypt(ciphertext, "text") console.log(out) ``` +### ASCII armoring + +age encrypted files (the inputs of `Decrypter.decrypt` and outputs of +`Encrypter.encrypt`) are binary files, of type `Uint8Array`. There is an official ASCII +"armor" format, based on PEM, which provides a way to encode an encrypted file as text. + +```ts +import * as age from "age-encryption" + +const identity = await age.generateIdentity() +const recipient = await age.identityToRecipient(identity) +console.log(identity) +console.log(recipient) + +const e = new age.Encrypter() +e.addRecipient(recipient) +const ciphertext = await e.encrypt("Hello, age!") +const armored = age.armor.encode(ciphertext) + +console.log(armored) +// -----BEGIN AGE ENCRYPTED FILE----- +// YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0QXVkQmNwZ3ZzYnNRZDJP +// WlFId3hyeFNmRS9SdUVUTkFhY1FXSno5VUFBClNOSWhEbnhoK21TaEs3SWRGdklw +// OW9pdlBZbDg3SEVSQ1FZZHBvUS90YjgKLS0tIGRCVXNNWmdJS0ZkNlNZbStPZWh4 +// N2FBNUJZdTFxMmYwVTEzUWwvTFVNeUkKrNZnrZjMlXvoCHz0FUS/bp9129XtSV1Q +// 2twDjjAOwgBtBYoji9gKWgOG4w== +// -----END AGE ENCRYPTED FILE----- + +const d = new age.Decrypter() +d.addIdentity(identity) +const decoded = age.armor.decode(armored) +const out = await d.decrypt(decoded, "text") +console.log(out) +``` + #### Encrypt and decrypt a file with a passphrase ```ts @@ -108,8 +143,6 @@ and support the `deriveBits` key usage. It doesn't need to be extractable. ```ts const keyPair = await crypto.subtle.generateKey({ name: "X25519" }, false, ["deriveBits"]) const identity = (keyPair as CryptoKeyPair).privateKey -const recipient = await age.identityToRecipient(identity) - const recipient = await age.identityToRecipient(identity) console.log(recipient) diff --git a/lib/armor.ts b/lib/armor.ts new file mode 100644 index 0000000..bf1fe7b --- /dev/null +++ b/lib/armor.ts @@ -0,0 +1,50 @@ +import { base64 } from "@scure/base" +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { type Encrypter, type Decrypter } from "./index.js" + +/** + * Encode an age encrypted file using the ASCII armor format, a strict subset of + * PEM that starts with `-----BEGIN AGE ENCRYPTED FILE-----`. + * + * @param file - The raw encrypted file (returned by {@link Encrypter.encrypt}). + * + * @returns The ASCII armored file, with a final newline. + */ +export function encode(file: Uint8Array): string { + const lines: string[] = [] + lines.push("-----BEGIN AGE ENCRYPTED FILE-----\n") + for (let i = 0; i < file.length; i += 48) { + let end = i + 48 + if (end > file.length) end = file.length + lines.push(base64.encode(file.subarray(i, end)) + "\n") + } + lines.push("-----END AGE ENCRYPTED FILE-----\n") + return lines.join("") +} + +/** + * Decode an age encrypted file from the ASCII armor format, a strict subset of + * PEM that starts with `-----BEGIN AGE ENCRYPTED FILE-----`. + * + * Extra whitespace before and after the file is ignored, and newlines can be + * CRLF or LF, but otherwise the format is parsed strictly. + * + * @param file - The ASCII armored file. + * + * @returns The raw encrypted file (to be passed to {@link Decrypter.decrypt}). + */ +export function decode(file: string): Uint8Array { + const lines = file.trim().replaceAll("\r\n", "\n").split("\n") + if (lines[0] !== "-----BEGIN AGE ENCRYPTED FILE-----") { + throw Error("invalid header") + } + if (lines[lines.length - 1] !== "-----END AGE ENCRYPTED FILE-----") { + throw Error("invalid footer") + } + lines.shift() + lines.pop() + if (lines.some((l, i) => i === lines.length - 1 ? l.length > 64 : l.length !== 64)) { + throw Error("line too long") + } + return base64.decode(lines.join("")) +} diff --git a/lib/index.ts b/lib/index.ts index 0831392..ddc6046 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,6 +6,8 @@ import { ScryptIdentity, ScryptRecipient, X25519Identity, X25519Recipient } from import { encodeHeader, encodeHeaderNoMAC, parseHeader, Stanza } from "./format.js" import { decryptSTREAM, encryptSTREAM } from "./stream.js" +export * as armor from "./armor.js" + export { Stanza } /** diff --git a/tests/examples/identity.js b/tests/examples/identity.js index 644db2a..785bd49 100644 --- a/tests/examples/identity.js +++ b/tests/examples/identity.js @@ -1,15 +1,18 @@ -import { Encrypter, Decrypter, generateIdentity, identityToRecipient } from "age-encryption" +import * as age from "age-encryption" -const identity = await generateIdentity() -const recipient = await identityToRecipient(identity) +const identity = await age.generateIdentity() +const recipient = await age.identityToRecipient(identity) console.log(identity) console.log(recipient) -const e = new Encrypter() +const e = new age.Encrypter() e.addRecipient(recipient) const ciphertext = await e.encrypt("Hello, age!") +const armored = age.armor.encode(ciphertext) +console.log(armored) -const d = new Decrypter() +const d = new age.Decrypter() d.addIdentity(identity) -const out = await d.decrypt(ciphertext, "text") +const decoded = age.armor.decode(armored) +const out = await d.decrypt(decoded, "text") console.log(out) diff --git a/tests/testkit.test.ts b/tests/testkit.test.ts index bf69cbe..0449ab3 100644 --- a/tests/testkit.test.ts +++ b/tests/testkit.test.ts @@ -5,7 +5,7 @@ import { forceWebCryptoOff } from "../lib/x25519.js" import { hkdf } from "@noble/hashes/hkdf" import { sha256 } from "@noble/hashes/sha256" import { hex, base64 } from "@scure/base" -import { Decrypter } from "../lib/index.js" +import { Decrypter, armor } from "../lib/index.js" declare module "@vitest/browser/context" { interface BrowserCommands { @@ -50,13 +50,16 @@ describe("CCTV testkit", async function () { } for (const vec of vectors) { - if (vec.meta.armored) continue + let body = () => vec.body + if (vec.meta.armored) { + body = () => armor.decode(new TextDecoder().decode(vec.body)) + } if (vec.meta.expect === "success") { it(vec.name + " should succeed", async function () { const d = new Decrypter() if (vec.meta.passphrase) d.addPassphrase(vec.meta.passphrase) if (vec.meta.identity) d.addIdentity(vec.meta.identity) - const plaintext = await d.decrypt(vec.body) + const plaintext = await d.decrypt(body()) assert.equal(hex.encode(sha256(plaintext)), vec.meta.payload) }) if (vec.meta.identity) { @@ -64,21 +67,28 @@ describe("CCTV testkit", async function () { withoutWebCrypto() const d = new Decrypter() d.addIdentity(vec.meta.identity) - const plaintext = await d.decrypt(vec.body) + const plaintext = await d.decrypt(body()) assert.equal(hex.encode(sha256(plaintext)), vec.meta.payload) }) } + if (vec.meta.armored) { + it(vec.name + " should round-trip armor", function () { + const normalize = (s: string) => s.replaceAll("\r\n", "\n").trim() + assert.deepEqual(normalize(armor.encode(body())), + normalize(new TextDecoder().decode(vec.body))) + }) + } it(vec.name + " should round-trip header encoding", function () { - const h = parseHeader(vec.body) + const h = parseHeader(body()) assert.deepEqual(encodeHeaderNoMAC(h.stanzas), h.headerNoMAC) const hh = encodeHeader(h.stanzas, h.MAC) const got = new Uint8Array(hh.length + h.rest.length) got.set(hh) got.set(h.rest, hh.length) - assert.deepEqual(got, vec.body) + assert.deepEqual(got, body()) }) it(vec.name + " should round-trip STREAM encryption", function () { - const h = parseHeader(vec.body) + const h = parseHeader(body()) const nonce = h.rest.subarray(0, 16) const streamKey = hkdf(sha256, hex.decode(vec.meta["file key"]), nonce, "payload", 32) const payload = h.rest.subarray(16) @@ -90,14 +100,14 @@ describe("CCTV testkit", async function () { const d = new Decrypter() if (vec.meta.passphrase) d.addPassphrase(vec.meta.passphrase) if (vec.meta.identity) d.addIdentity(vec.meta.identity) - await expect(d.decrypt(vec.body)).rejects.toThrow() + await expect(async () => await d.decrypt(body())).rejects.toThrow() }) if (vec.meta.identity) { it(vec.name + " should fail without Web Crypto", async function () { withoutWebCrypto() const d = new Decrypter() d.addIdentity(vec.meta.identity) - await expect(d.decrypt(vec.body)).rejects.toThrow() + await expect(async () => await d.decrypt(body())).rejects.toThrow() }) } }