Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add armor.encode and armor.decode #31

Merged
merged 2 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
50 changes: 50 additions & 0 deletions lib/armor.ts
Original file line number Diff line number Diff line change
@@ -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(""))
}
2 changes: 2 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

/**
Expand Down
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@stylistic/eslint-plugin": "2.13.0",
"@types/node": "22.10.5",
"@vitest/browser": "2.1.8",
"cctv-age": "0.1.1",
"esbuild": "0.24.2",
"eslint": "9.18.0",
"eslint-plugin-tsdoc": "0.4.0",
Expand Down
15 changes: 9 additions & 6 deletions tests/examples/identity.js
Original file line number Diff line number Diff line change
@@ -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)
62 changes: 30 additions & 32 deletions tests/testkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,9 @@ import { decryptSTREAM, encryptSTREAM } from "../lib/stream.js"
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"

declare module "@vitest/browser/context" {
interface BrowserCommands {
listTestkitFiles: () => Promise<string[]>
readTestkitFile: (name: string) => Promise<string>
}
}

let listTestkitFiles: () => Promise<string[]>
let readTestkitFile: (name: string) => Promise<Uint8Array>
if (expect.getState().environment === "node") {
const { readdir, readFile } = await import("fs/promises")
listTestkitFiles = () => readdir("./tests/testkit")
readTestkitFile = (name) => readFile("./tests/testkit/" + name)
} else {
const { commands } = await import("@vitest/browser/context")
listTestkitFiles = commands.listTestkitFiles
readTestkitFile = async (name) => base64.decode(await commands.readTestkitFile(name))
}
import { hex } from "@scure/base"
import { Decrypter, armor } from "../lib/index.js"
import * as testkit from "cctv-age"

describe("CCTV testkit", async function () {
interface Vector {
Expand All @@ -33,8 +15,7 @@ describe("CCTV testkit", async function () {
body: Uint8Array,
}
const vectors: Vector[] = []
for (const name of await listTestkitFiles()) {
const contents = await readTestkitFile(name)
for (const [name, contents] of Object.entries(testkit)) {
const sepIdx = findSeparator(contents)
const header = new TextDecoder().decode(contents.subarray(0, sepIdx))
const body = contents.subarray(sepIdx + 2)
Expand All @@ -46,39 +27,56 @@ describe("CCTV testkit", async function () {
if (!vector.meta.expect) {
throw Error("no metadata found in " + name)
}
if (vector.meta.compressed === "zlib") {
vector.body = new Uint8Array(await new Response(
new Blob([vector.body]).stream().pipeThrough(new DecompressionStream("deflate"))
).arrayBuffer())
} else if (vector.meta.compressed) {
throw Error("unknown compression: " + vector.meta.compressed)
}
vectors.push(vector)
}

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) {
it(vec.name + " should succeed without Web Crypto", 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)
Expand All @@ -90,14 +88,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()
})
}
}
Expand All @@ -115,5 +113,5 @@ function findSeparator(data: Uint8Array): number {
return i
}
}
return -1
throw Error("no separator found")
}
13 changes: 0 additions & 13 deletions tests/testkit/armor

This file was deleted.

14 changes: 0 additions & 14 deletions tests/testkit/armor_crlf

This file was deleted.

13 changes: 0 additions & 13 deletions tests/testkit/armor_empty_line_begin

This file was deleted.

13 changes: 0 additions & 13 deletions tests/testkit/armor_empty_line_end

This file was deleted.

13 changes: 0 additions & 13 deletions tests/testkit/armor_eol_between_padding

This file was deleted.

13 changes: 0 additions & 13 deletions tests/testkit/armor_full_last_line

This file was deleted.

Loading
Loading