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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
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
Loading