moonseal
age-encryption.org/v1 · MoonBit

The guide — one file, several readers, no key server

A sealed file is a header of recipient stanzas over a streaming payload. Every stanza wraps the same sixteen-byte file key, so a file addressed to four people is one file and any one of them opens it. This page walks through every recipient type moonseal supports, the streaming path, the armored text form, and how to add your own recipient type. Each snippet compiles, and each printed block is the output of that snippet run as-written.

Setup

Add the library and the randomness source, then declare the packages you use in moon.pkg.

# moon.mod name = "your-app" source = "." import { "moonbitstack/moonseal", "moonbitstack/moonseed", }
# moon.pkg (the package that uses moonseal) import { "moonbitstack/moonseal", "moonbitstack/moonseal/spec", "moonbitstack/moonseal/x25519", "moonbitstack/moonseal/scrypt", "moonbitstack/moonseal/ssh", "moonbitstack/moonseal/mlkem768x25519", "moonbitstack/moonseal/p256tag", "moonbitstack/moonseal/mlkem768p256tag", "moonbitstack/moonseed", }
Randomness. Every call that seals takes a random~ source. In a real program pass @system.source from moonbitstack/moonseed/system. The snippets below instead pin a deterministic source with @moonseed.Replay::new(Bytes::make(n, 0xNN)) so the printed output is reproducible and matches what you see here. Swap in @system.source and the bytes change but the shape does not. Core packages such as @utf8 and @bytes need no import.
Passphrase Key pair SSH key Post-quantum Tagged Tagged PQ Streaming Armor Custom recipient

1 · A passphrase

The simplest recipient is a passphrase. @scrypt.Recipient::new derives a key from the phrase with scrypt; log_n sets the work factor (10 is fine for a demo, 14–18 for real use). No keys to manage — the reader supplies the same phrase to open.

moonbit
fn seal_with_passphrase() -> Unit raise @spec.Refused { let random = @moonseed.Replay::new(Bytes::make(128, 0x21)) let file = @moonseal.seal( [@scrypt.Recipient::new("correct horse battery staple", log_n=10)], b"the quick brown fox\n", random=random, ) let opened = @moonseal.open( [@scrypt.Identity::new("correct horse battery staple")], file, ) println("sealed: " + file.length().to_string() + " bytes") println("opened: " + @utf8.decode_lossy(opened)) }
output
--- passphrase ---
sealed: 202 bytes
opened: the quick brown fox

2 · A key pair, then a recipient

For public-key use, generate an X25519 identity with @x25519.Identity::generate. Hand out identity.public() (an @spec.Recipient) to seal; keep identity (an @spec.Identity) to open. code() renders the bech32 age string; Identity::of / Recipient::of parse one back from text.

moonbit
fn key_pair() -> Unit raise @spec.Refused { let random = @moonseed.Replay::new(Bytes::make(64, 0x42)) let alice = @x25519.Identity::generate(random=random) catch { _ => abort("no randomness") } println(alice.code()) // AGE-SECRET-KEY-… println(alice.public().code()) // age1… } fn seal_to_recipient() -> Unit raise @spec.Refused { let kp = @moonseed.Replay::new(Bytes::make(64, 0x42)) let alice = @x25519.Identity::generate(random=kp) catch { _ => abort("no") } let seal_rand = @moonseed.Replay::new(Bytes::make(64, 0x21)) let file = @moonseal.seal([alice.public()], b"the quick brown fox\n", random=seal_rand) catch { _ => abort("cannot seal") } println("sealed: " + file.length().to_string() + " bytes") println("opened: " + @utf8.decode_lossy(@moonseal.open([alice], file))) }
output
--- keypair ---
AGE-SECRET-KEY-1GFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPQ4EGAEX
age1zvkyg2lqzraa2lnjvqej32nkuu0ues2s82hzrye869xeexvn73equnujwj
--- recipient ---
sealed: 220 bytes
opened: the quick brown fox

3 · An SSH key the reader already has

If the reader already holds an SSH key, seal to its public half with @ssh.Recipient::of and open with @ssh.Identity::of on the private half. Ed25519 and RSA are both accepted. kind() reports the algorithm; fingerprint() returns age's short SSH fingerprint.

moonbit
fn ssh_example() -> Unit raise @spec.Refused { let recipient = @ssh.Recipient::of( "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFi1otykT1YoBR3We0Xf/eNmdx4W32NHFBqQo+b4DqHM moonseal guide", ) catch { _ => abort("not an SSH key") } println("kind: " + recipient.kind()) println("fingerprint: " + recipient.fingerprint()) let seal_rand = @moonseed.Replay::new(Bytes::make(64, 0x21)) let file = @moonseal.seal([recipient], b"the quick brown fox\n", random=seal_rand) catch { _ => abort("cannot seal") } let priv_text : String = "-----BEGIN OPENSSH PRIVATE KEY-----\n" + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n" + "QyNTUxOQAAACBYtaLcpE9WKAUd1ntF3/3jZnceFt9jRxQakKPm+A6hzAAAAJhGzMSTRszE\n" + "kwAAAAtzc2gtZWQyNTUxOQAAACBYtaLcpE9WKAUd1ntF3/3jZnceFt9jRxQakKPm+A6hzA\n" + "AAAEAPBk/Wd8ZajsZ4fKz8ITJwWncXCrmdlyQVg4nGcvOHRVi1otykT1YoBR3We0Xf/eNm\n" + "dx4W32NHFBqQo+b4DqHMAAAADm1vb25zZWFsIGd1aWRlAQIDBAUGBw==\n" + "-----END OPENSSH PRIVATE KEY-----" let identity = @ssh.Identity::of(priv_text) println("opened: " + @utf8.decode_lossy(@moonseal.open([identity], file))) }
output
--- ssh ---
kind: ssh-ed25519
fingerprint: f2MncQ
opened: the quick brown fox

4 · A post-quantum hybrid key

@mlkem768x25519 pairs X25519 with ML-KEM-768. A sealed file stays openable by a classical reader, but resists a future quantum adversary. The API is identical to the plain key pair: generate, hand out .public(), open with the identity.

moonbit
fn post_quantum() -> Unit raise @spec.Refused { let kp = @moonseed.Replay::new(Bytes::make(128, 0x7a)) let after = @mlkem768x25519.Identity::generate(random=kp) catch { _ => abort("no") } println(after.code()) // AGE-SECRET-KEY-PQ-… let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21)) let file = @moonseal.seal([after.public()], b"still secret\n", random=seal_rand) catch { _ => abort("no") } println("opened: " + @utf8.decode_lossy(@moonseal.open([after], file))) }
output
--- post-quantum ---
AGE-SECRET-KEY-PQ-10FA857N60FA857N60FA857N60FA857N60FA857N60FA857N60FAQCETL9H
opened: still secret

5 · A tagged recipient (key held in hardware)

A tagged recipient names a key by a short tag instead of a full public key, so the sealed file does not reveal which key opens it. @p256tag uses a P-256 point under the tag. Generate the identity, publish .public() (the age1tag1… string), open with the identity.

moonbit
fn tagged() -> Unit raise @spec.Refused { let kp = @moonseed.Replay::new(Bytes::make(128, 0x99)) let device = @p256tag.Identity::generate(random=kp) catch { _ => abort("no") } println(device.public().code()) // age1tag1… let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21)) let file = @moonseal.seal([device.public()], b"for the key on the desk\n", random=seal_rand) catch { _ => abort("no") } println("opened: " + @utf8.decode_lossy(@moonseal.open([device], file))) }
output
--- tagged ---
age1tag1q0wuyawkyvqmwdp5lwhemmeugtz06kx0na53rswl7v64we6t5rv5vp064z0
opened: for the key on the desk

6 · A tagged post-quantum recipient

@mlkem768p256tag combines the tagged P-256 scheme with ML-KEM-768: the key stays unnamed in the header and the file is quantum-resistant. The public code is long because it embeds the ML-KEM ciphertext; the workflow is unchanged.

moonbit
fn tagged_post_quantum() -> Unit raise @spec.Refused { let kp = @moonseed.Replay::new(Bytes::make(128, 0xbb)) let device = @mlkem768p256tag.Identity::generate(random=kp) catch { _ => abort("no") } println(device.public().code()) // age1tagpq1… (long) let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21)) let file = @moonseal.seal([device.public()], b"for the key on the desk\n", random=seal_rand) catch { _ => abort("no") } println("opened: " + @utf8.decode_lossy(@moonseal.open([device], file))) }
output
--- tagged post-quantum ---
age1tagpq10yd39yeezsha20akfscgfmr52wppyscentxqppahvxp8y5wf3an22dlhqn6qx2kvcpgmtrrvnexfnuwq283yzdn3yv4jyf5hft9f9gm84xj6slus0xw76fjfg6rgle26hc0t3nahvjge46dt4cvmls6yynzush2kmd3s62pgazyr3hd3dz7uwz5ypfhtzf69j3tjdlx44999xfsmgvsqhrrvu5c3a9pmy4q74sfvnwhqga46qdac0rvqtkzkkqucmsq07lzngm6j87rvzrzm9fkssaw38ssv66nqk3nkvpsq28rwwgvznk2m84ae0plerafgzc7w7k6m5fu06n4empdjcznt8nemve74df3ygz4j4ry4g4zynqsnwjr3a63cgup9uthtvkryxqjfupnntvyh6lar788f2tdsclgrk5vrl264vcn4uvmrzjsuzwwa6jttgj5zxsurj3nekyrrtgpvctxz2jwgjfvcpxdr9rvl53ajyctcthxd4h5f6s7f0uj53fpczuctj4wf5h6hcy9n3w6qjf245f5qyengwdnc7743g0ze49aaqkpljtq8jhquqdshz8f6ghk9zerrr3ead3prftj3wv0eva69n36vys9hl3z2pf6cd3dzgm4vkqg07gpk223n4umtgzfznrydntrrmqyfuqqkdv6hvpvv0nmzct9a2smymkakgkm3dhwq5820l8yxjamqng3sfyxpeyjvklk07wrtdujjmlgryyaey4ut5gvx766slfgvq2uf82ptqvj42mksqvnmh8qqj64t62yjn6u2xv3mt34upwyy28zjc07md7ukv5mpcgufqdfdujselmm2ypeug4d7mxwakud54cdx0svge2utktelzst2dxqpmfmna90zwgkus6qkdvylt09cdxrr9n84pgue3wcs6uh3g7spg76el7ettqwa5dsqhktu4fml5anmpcg5ztlejpfmqucz6q3k99ecnnvmktrarpx4s9yuy4amfw46v9d5s5m7939e4240zgsjwsaxs6p54ytqgv88jf3r2tzhkgujezz3rs04xqs5lyuz04vk5acyk3n8gl74kvl2v0rrn56l4ac8k43sx6wyhy6y6e2lg4g2pv3qla0ng09atsd5xp5y8ygpak25c7l65ghasqvc6yhwlwpu2my9806uvk4ctxvuqjczy7v3zquxwuzpvr7rhrcxghprtkp2p6wxnk7x86zw8w4l4jc030p05myna06gt36ukpm0rv9ev63x0v5zdn8yw9plzzsqrkxl2t9xetfplsy3ek40we3czd6qc9u42cfstyrejglrtjcfwksdwkzjkx3m26xyz2cakc5mqusxg5dg4gz3xuz8clhxe9xscx8kfpev6lunqdzjdwzuvklav4h463faj62srn0r8wnzdzfcgf5e30yjfufvwdf6j389fvcalw7kzfuzundsksg32yvwc7lkpt8g9vej3s9kmewyq6mayj5mdpxdskc9m9mpedw4cm2hc5pxz2glv9trepgv3zfzp6y7ezu4as8u2xavffxy5sl689dtvkaxzufm6vmx4l3pc89u4d8xjj0qqvc3c22e25sgv9vu9judspqsws6ctzjqg0u4ghzfps6l8zlv0ff63z2mqs23mz8uw7jhv4s0kum6udr5l0gefgvh2wjk8w38v528k2j52xet7z32qkj85qtvkqjxcqpg287yf4gxhla4vjucpwlt7cred3a684yy9t3h8qvh66mnekjc2jpelkmx2frzvsukj4f8mzj66mdmwscnp2e550xe7dq8m93y8h3vk9cpsqjty6tgft9p2ux242aa4fkklmmzhnx9k440zqeu8czz8ryalkt38a4qnunre7gyna950a2cthc3p76584pwqn6e8mel3shpz8v52f2hvt6kqy0tx388ssv34n4xuhcyzw4ea0cfvjflase3gpk5k63e5p3n8pq7nza5m5gdjls6h
opened: for the key on the desk

7 · Streaming a file larger than memory

When the payload does not fit in memory, seal and open through a source and a sink. @moonseal.BytesSource and @moonseal.BytesSink are in-memory stand-ins; replace them with a file or network stream in production. The format is byte-identical to the buffered path, in 64 KiB chunks.

moonbit
fn stream_example() -> Unit raise @spec.Refused { let sink = @moonseal.BytesSink::new() let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21)) @moonseal.seal_stream( sink, [@scrypt.Recipient::new("correct horse battery staple", log_n=10)], @moonseal.BytesSource::new(b"the quick brown fox\n"), random=seal_rand, ) catch { _ => abort("no") } let file = sink.bytes() let open_sink = @moonseal.BytesSink::new() @moonseal.open_stream( open_sink, [@scrypt.Identity::new("correct horse battery staple")], @moonseal.BytesSource::new(file), ) catch { _ => abort("no") } println("opened: " + @utf8.decode_lossy(open_sink.bytes())) }
output
--- stream ---
opened: the quick brown fox

8 · Armor: the file as text

When the ciphertext must travel through a text-only channel (chat, email, a paste), seal_armored wraps the age file in a PEM-style envelope. open_armored reads it back. The body is the same age v1 stream, base64-encoded.

moonbit
fn armor_example() -> Unit raise @spec.Refused { let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21)) let text = @moonseal.seal_armored( [@scrypt.Recipient::new("correct horse battery staple", log_n=10)], b"the quick brown fox\n", random=seal_rand, ) catch { _ => abort("no") } println(text) // the armored age file }
output
--- armor ---
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNjcnlwdCBJU0VoSVNFaElTRWhJU0Vo
SVNFaElRIDEwClpWTThtc0tTekY3cnZJMklrZWRhUG43SHhFVHRiRzZoMG9FbVky
NlJ2TzgKLS0tIDlwL09yaDRmUWlaaklKcUp0MGlnMzBWNWdwZHlBSTBLdHFOWnhV
ZHE0N2MKISEhISEhISEhISEhISEhISknpBd0rIwD4A1Z7Lby9lt2n1S4ggaR1wSF
OddHwrwSvDsqhQ==
-----END AGE ENCRYPTED FILE-----

9 · Your own recipient type

A recipient is two traits: @spec.Recipient wraps the file key into a stanza, and @spec.Identity unwraps it. Implement both and the type drops into any seal / open call. The example below is a transparent stand-in that hands the file key back verbatim — a real type would wrap it under a key it owns.

moonbit
struct Example {} impl @spec.Recipient for Example with wrap(self, file_key~, random~) { ignore(self) ignore(random) @spec.Stanza::new(kind="example.com/plain", args=[], body=file_key.to_owned()) } impl @spec.Identity for Example with unwrap(self, stanza~) { ignore(self) if stanza.kind == "example.com/plain" { return Some(stanza.body) } None } fn custom_example() -> Unit raise @spec.Refused { let file = @moonseal.seal( [Example::{}], b"the quick brown fox\n", random=@moonseed.Replay::new(Bytes::make(64, 0x21)), ) catch { _ => abort("no") } println("opened: " + @utf8.decode_lossy(@moonseal.open([Example::{}], file))) }
output
--- custom recipient ---
opened: the quick brown fox
Interoperability note. Anything moonseal seals is a plain age v1 file. The armored block above, and every sealed file in this guide, opens with age, rage, and any other conforming implementation — and their files open here. The CI exchanges a sealed file with age and rage on every run.