Skip to content

Commit

Permalink
Add armor.encode and armor.decode
Browse files Browse the repository at this point in the history
Fixes #17
  • Loading branch information
FiloSottile committed Jan 20, 2025
1 parent fb1efb8 commit 2dd8802
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 17 deletions.
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
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)
28 changes: 19 additions & 9 deletions tests/testkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,35 +50,45 @@ 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) {
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 +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()
})
}
}
Expand Down

0 comments on commit 2dd8802

Please sign in to comment.