Documentation
¶
Overview ¶
Package notices is the one channel that can reach an installed Flynn after it was installed: security advisories about Flynn itself, deprecations, and release notices. Without it a shipped binary is unreachable forever, and a sandbox escape found after a release could only be announced to the people who happened to be watching the repository.
The channel is deliberately the opposite of the usual dynamic-config beacon. It is pull-only, it carries no identifier of any kind, and the document it fetches is byte-identical for every Flynn in the world. A server that cannot tell two clients apart cannot serve one of them a different answer, so a compromised or compelled origin cannot target a single user; the worst it can do is lie to everyone at once, which is what the signature stops.
Three checks make the feed unforgeable and hard to suppress, and each one exists for an attack that a client which merely fetches and checks a signature still loses to:
- Signature. The feed is a COSE_Sign1 message over deterministic CBOR, signed Ed25519 by a key in the compiled-in keyring, with the content type in the protected header so a signature over a feed can never be replayed as a signature over anything else. This is the construction chain/ already uses for a checkpoint, for the same reason.
- Anti-rollback. The feed carries a monotonically increasing version. A client records the highest version it has trusted and refuses anything lower, so a mirror cannot replay a genuinely signed older feed to bury a fresh advisory.
- Anti-freeze. The feed carries a signed expiry and is re-signed on a schedule even when nothing changed. Past that expiry a client treats the feed as stale and says so. An attacker who blocks the origin can make Flynn go blind, but cannot make it go quiet: silence is reported, never read as all-clear.
A notice can say things. It can never do things. Nothing in the feed selects behaviour, enables a feature, opens a URL, or runs a command; the feed is text, it is treated as hostile text (see Sanitize), and that line is what keeps this from becoming a remote-control channel.
Index ¶
- Constants
- func Accept(doc []byte, ring *Keyring, tr Trust, now time.Time) (Feed, Trust, error)
- func Applies(n Notice, v string) bool
- func Cached(store *Store, ring *Keyring) (Feed, Trust, bool)
- func Due(t Trust, now time.Time) bool
- func Enabled() bool
- func Render(w io.Writer, ns []Notice, stale bool) bool
- func SafeClient() *http.Client
- func Sanitize(s string, maxRunes int) string
- func SourceKeyCount() int
- type Client
- type Feed
- type Floor
- type Keyring
- type Notice
- type Severity
- type Signer
- type Source
- type Store
- type Trust
Constants ¶
const ( MaxNotices = 32 MaxFloors = 16 MaxSummary = 200 MaxDetail = 2000 MaxIDLen = 64 MaxURLLen = 200 MaxVersionLen = 32 // MaxFeedBytes caps the signed document itself. A notice feed is a few kilobytes; // anything approaching this is not a feed we published. MaxFeedBytes = 256 << 10 )
Limits on what a feed may contain. They bound the memory a hostile origin can make a client spend, and they bound the screen: a feed cannot bury a real advisory under a thousand entries, and one notice cannot scroll the terminal away.
const ( CodeDecode = "notices.decode" CodeEncode = "notices.encode" CodeOrigin = "notices.wrong_origin" CodeSignerKey = "notices.bad_key" CodeUnknownKey = "notices.unknown_key" CodeContentType = "notices.bad_content_type" CodeSignature = "notices.signature_invalid" CodeRollback = "notices.rollback" CodeMalformed = "notices.malformed" CodeTooLarge = "notices.too_large" CodeFetch = "notices.fetch_failed" CodeStateCorrupt = "notices.state_corrupt" CodeStateNotSaved = "notices.state_not_saved" )
Failure codes. They are stable dotted identifiers so a refusal names the check that failed rather than just "invalid feed", which matters when the thing being refused is a security advisory and the user needs to know whether they are being attacked or whether we shipped a bad file.
const DefaultURL = "https://flynnhq.com/.well-known/flynn/notices.cose"
DefaultURL is the one address a Flynn ever fetches notices from. It is a constant in the source, not a setting: an origin a user can point somewhere else is an origin an attacker who can write one config file can point somewhere else, and this is the channel that has to survive that. Changing it means shipping a build, which is a change anyone can see in the diff.
const FetchTimeout = 5 * time.Second
FetchTimeout bounds the request. The notice check is never allowed to make a command slower than it would have been, so this is short and the caller runs it off the hot path entirely.
const OffEnv = "FLYNN_NO_NOTICES"
OffEnv turns the notice channel off completely: no fetch, no rendering, nothing on the network. It is one switch that does exactly one thing. It is deliberately not bundled with telemetry, error reporting, or anything else, because a switch that turns off four unrelated things is a switch people flip for one reason and then wonder why a fifth thing broke.
const Origin = "flynn/notices/v1"
Origin names this feed and its format version. It is inside the signed payload, so a document signed for some other purpose by the same key can never be read as a notice feed, and a future format change is a new origin rather than a silent reinterpretation of the same bytes.
const RefreshInterval = 24 * time.Hour
RefreshInterval is how often a client looks for a new feed. It is deliberately not "every run": a fetch per invocation would make the origin a rough activity log of every user, and one check a day is already far more often than an advisory is published.
const StaleAfter = 7 * 24 * time.Hour
StaleAfter is how long a feed may go without a fresh signature before a client says it has lost sight of the channel. The publisher re-signs on a shorter cycle than this even when nothing has changed, exactly so that a client which stops seeing fresh signatures has learned something true: it is not being shown the current feed. (The Update Framework calls the attack this defends against a freeze attack, and its answer is the same: sign a short-lived statement on a schedule, so an attacker who blocks the origin cannot hold a client at an old view indefinitely without the client noticing.)
Variables ¶
This section is empty.
Functions ¶
func Accept ¶
Accept verifies a fetched feed document against the keyring and the client's trust state, and returns the feed together with the trust state to persist.
It refuses exactly two things, and both of them mean forgery or replay rather than bad luck:
- a document that does not verify against a key in the ring, or is not a feed at all (Verify's checks), and
- a feed older than the newest one this client has already trusted. That is the rollback defence: an origin or a mirror that serves a genuinely signed but stale feed is trying to bury a newer advisory, and the monotonic version is what makes that visible. Re-serving the same version is fine and normal.
It deliberately does NOT refuse an expired feed, and that is a decision worth being explicit about. Expiry tells the client the channel has gone quiet; it says nothing about whether the notices inside are true. If our re-signing job broke on the same weekend we published a critical advisory, a client that refused the expired feed would refuse the advisory, which is precisely backwards: the failure of our infrastructure would suppress the warning it exists to deliver. So staleness never suppresses content. It surfaces as a warning next to the content (see Feed.Stale), which is the honest thing to show and the thing an attacker who is blocking the origin cannot prevent.
func Applies ¶
Applies reports whether n is about the Flynn version v.
A notice applies from AffectedFrom (inclusive) up to FixedIn (exclusive). An empty AffectedFrom means every version from the beginning; an empty FixedIn means no release fixes it yet, so it applies to everything from AffectedFrom onwards. That last case is the one that matters most: an advisory published before the fix ships still has to reach people.
An unparseable, empty, or all-zero running version matches every notice. Those are the development builds: an unstamped tree, a `go install` with no version recorded, and Flynn's own "0.0.0-dev" placeholder. The alternative would be for the one build most likely to be running unreleased code to be the one build that hears nothing.
func Cached ¶
Cached loads and re-verifies the cached feed. Every run re-checks the signature, the origin, and the rollback rule against the stored trust state, so a cache file that was edited on disk since it was written is discarded here rather than believed. There is no fast path that skips this, because the fast path is exactly where a local attacker would put words in our mouth.
func Due ¶
Due reports whether it is time to look for a new feed. A client that has never checked is due immediately; otherwise it waits out RefreshInterval from the last successful check. A Checked time in the future (a clock that was wrong, or has been set back) also reads as due, so a bad clock cannot park a client in a state where it never looks again.
func Enabled ¶
func Enabled() bool
Enabled reports whether the notice channel may run in this build: the user has not set the off switch, and there is at least one key to trust a feed against. A keyless build is inert rather than credulous.
func Render ¶
Render writes the pending notices to w, security first, and reports whether it wrote anything.
The text has already been through Sanitize at decode, so what reaches the terminal here carries no escape sequence and cannot repaint what was printed above it. A URL is printed as text and is never opened. Nothing here asks a question, blocks, or changes what the command was going to do: the user asked Flynn to do something, and a notice interrupting that would be a channel that acts.
func SafeClient ¶
SafeClient is the transport the fetch uses: netguard's public-only dialer, so the notice fetch is governed by the same egress waist as everything else in the process and cannot be pointed at a private address, plus a redirect refusal. A static signed document has no reason to redirect, and following one is how a fetch ends up somewhere nobody audited.
func Sanitize ¶
Sanitize turns a string that arrived over the network into a string that is safe to hand a terminal, truncated to at most maxRunes.
Text from the feed is printed to a terminal, and a terminal is an interpreter. An escape sequence in a notice could rewrite lines that were already printed (so a notice could overwrite the governance decisions above it), set the window title, or on some terminals stuff characters into the input buffer, which is a command-execution primitive. Signing the feed does not help here: the escape would be authentically signed by us. So the text is stripped before it can ever be printed, and the stripping happens once, at decode, rather than at each print site.
The rule is a deny-by-default one: keep printable runes and nothing else. That drops ESC and the rest of the C0 controls, the C1 controls (including the 8-bit CSI and OSC introducers, which a naive "strip \x1b[" filter misses entirely), DEL, and invalid UTF-8. Newlines and tabs are the deliberate exception, allowed in Detail because a paragraph of advisory text needs them and neither carries an escape.
func SourceKeyCount ¶
func SourceKeyCount() int
SourceKeyCount reports how many keys are declared in the source. DefaultKeyring skips a malformed one rather than panicking, so comparing the two counts is how a typo in a key is caught at test time rather than on the day an advisory has to go out.
Types ¶
type Client ¶
type Client struct {
Source Source
Ring *Keyring
Store *Store
Clock clock.Clock
Version string // the running Flynn version, for deciding which notices apply
}
Client is the whole notice channel as one thing a command can call: it shows what the last accepted feed says, and it looks for a newer one when it is time to.
func (*Client) Background ¶
Background starts the refresh in a goroutine and returns immediately, so a command pays nothing for the notice channel: not the DNS lookup, not the connection, not the read. If the process exits before the fetch finishes, nothing is lost. The feed was already going to be shown on the next run, and the next run will try again.
func (*Client) Floors ¶
Floors returns the runtime version gates the last accepted feed carries, or nothing when there is no trusted feed. The caller applies them (see inference.Raise), and applying them can only ever tighten a gate: this package deliberately does not import the inference package, so there is no path here that could reach in and relax one.
func (*Client) RefreshIfDue ¶
RefreshIfDue fetches a new feed when the refresh interval has elapsed, and does nothing otherwise. It is meant to be called in the background: what it fetches is shown by the next run, not this one, which is what keeps the notice channel off the path of the command the user is waiting for.
An error is returned for a caller that wants to report it (`flynn notices --refresh` does). The background caller ignores it: a notice check that failed is not something to interrupt a user's run about, and the next run tries again.
func (*Client) Show ¶
Show prints the notices that apply to this version and have not been said, from the cached feed, and marks the one-shot ones as said. It reports whether it printed anything.
It never touches the network, so it cannot make a command slower or fail one that would otherwise have worked, and it is safe on a machine with no route out. Every failure along the way (no cache, unreadable cache, a cached document that no longer verifies) is simply no notices: this is a channel for telling the user things, and it has no business turning into an error that stops the work they actually asked for.
type Feed ¶
type Feed struct {
// Version increases with every publication and never decreases. It is the whole
// anti-rollback mechanism: a client refuses a feed older than the newest one it has
// already trusted, so an old feed cannot be replayed over a new one.
Version uint64
// Issued is when this feed was signed.
Issued time.Time
// Expires is when this feed stops being believable. We re-sign on a schedule even
// when nothing changed, so a client that sees an expired feed has learned something
// real: it is not being shown the current one.
Expires time.Time
// Notices is the full current set, not a delta. A feed is the complete state, so a
// client that missed ten publications is not missing ten notices.
Notices []Notice
// Floors are the runtime version gates in force. They only ever tighten (see Floor).
Floors []Floor
}
Feed is the signed document: every notice we currently have, plus the two fields that make replaying or suppressing it detectable.
func Refresh ¶
func Refresh(ctx context.Context, src Source, ring *Keyring, store *Store, now time.Time) (Feed, error)
Refresh fetches the feed, accepts it against the keyring and the stored trust state, and caches it for the next run. It returns the accepted feed.
Nothing is written unless the document verified, so a hostile or broken origin cannot even evict a good cached feed: the worst it can do is fail, leaving the last feed we did trust exactly where it was.
func Verify ¶
Verify checks a signed feed document against the keyring and returns the feed it attests. It fails closed on every axis: an unknown key id, a content type that is not ours, a signature that does not verify, an algorithm other than EdDSA, a payload that is not canonical CBOR, or a payload that does not pass structural validation. Nothing partial is ever returned, because a half-trusted advisory is worse than none: it would be rendered.
Verify does not check the feed's freshness. That is Accept's job, because freshness needs the client's trust state and clock, and keeping the two apart means the pure crypto here can be property-tested without either.
func (Feed) Stale ¶
Stale reports whether the feed has gone too long without a fresh signature, and so whether the client should tell the user it may not be seeing current notices. A feed whose expiry has passed is stale by definition; a feed with no expiry set is held to StaleAfter from when it was issued, so a publisher cannot make a feed immortal by leaving the field out.
type Floor ¶
type Floor struct {
// Runtime is the runtime this floor applies to ("llama.cpp", "ollama", "vllm"). A name
// Flynn does not drive is ignored, not invented.
Runtime string
// MinVersion is the oldest version considered safe, in that runtime's own version
// shape (a build number for llama.cpp, a semver for the others).
MinVersion string
// AdvisoryID names what the floor is for, so a refusal from a floor this build was not
// compiled with still tells the user what to go and read.
AdvisoryID string
}
Floor is a minimum safe version for a local inference runtime, carried in the feed so a parser flaw disclosed after a release can still raise the gate on an installed Flynn.
This is the one thing in a feed that is not inert text, and it is allowed for exactly one reason: it can only ever tighten. The client takes the higher of its compiled-in floor and this one (see inference.Raise), and no path exists to lower a floor. An attacker holding the origin and the signing key can therefore only make Flynn refuse to run a runtime, never make it run a vulnerable one. A denial of service is loud and recoverable; a relaxed gate on a parser with a live remote-code-execution bug is not.
type Keyring ¶
type Keyring struct {
// contains filtered or unexported fields
}
Keyring is the set of public keys allowed to sign a notice feed. A key that is not in the ring cannot say anything to this binary, so revoking a publisher is removing its key and shipping a build. The ring is compiled in (see keyring.go) rather than configurable: a keyring a user can add to is a keyring an attacker who can write one file can add to, and this is the one channel that must survive that.
func DefaultKeyring ¶
func DefaultKeyring() *Keyring
DefaultKeyring builds the compiled-in keyring. A malformed entry is skipped rather than panicking the binary: a bad key can only ever cost us the ability to say something, and taking the user's agent down over it would be a self-inflicted outage.
type Notice ¶
type Notice struct {
// ID is stable and unique within a feed. It is what a client remembers when it has
// already shown a notice, so reusing an ID for different content silently suppresses
// the new content.
ID string
// Severity selects how it is shown and whether it can be dismissed.
Severity Severity
// Summary is the one line the user sees. Sanitized before it reaches a terminal.
Summary string
// Detail is the optional longer text shown by `flynn notices`. Sanitized too.
Detail string
// URL points at the full writeup. It is printed as text and never opened: a feed
// that could open a browser would be a feed that could act.
URL string
// AffectedFrom is the first Flynn version the notice applies to. Empty means every
// version from the beginning.
AffectedFrom string
// FixedIn is the first Flynn version that resolves it, and the notice does not apply
// from there on. Empty means no version fixes it (an announcement, or an advisory
// with no fix released yet), so it applies to every version at or above
// AffectedFrom.
FixedIn string
}
Notice is one thing we have to tell the user.
func Pending ¶
Pending returns the notices in f that apply to the running Flynn version and have not already been said.
"Already said" is where the two severities part company. A release notice or a deprecation is shown once and then remembered, because repeating it is nagging. A security notice is shown every single time for as long as it applies, and is only ever silenced by the user moving to a version that fixes it. A user who is still exposed still needs to be told, and a channel that let a vulnerability scroll past once and then went quiet about it would be worse than no channel: it would look like diligence.
type Severity ¶
type Severity string
Severity says how loudly a notice is shown and, more importantly, what it means. Only these three exist: an unknown severity is a decoding failure, not a fourth kind rendered as a curiosity.
const ( // Security is a vulnerability in Flynn itself. It is shown on every run until the // user is on a version that fixes it; it cannot be dismissed away. Security Severity = "security" // Deprecation is behaviour that will be removed or changed. It is shown once. Deprecation Severity = "deprecation" // Info is a release notice or announcement. It is shown once, quietly. Info Severity = "info" )
type Signer ¶
type Signer struct {
// contains filtered or unexported fields
}
Signer produces a signed feed. Publishing side only: the flynn binary never signs a feed, it only verifies one, and the private key never comes near a user's machine.
func NewSigner ¶
func NewSigner(keyID string, priv ed25519.PrivateKey) (*Signer, error)
NewSigner builds a signer over an Ed25519 private key. An empty key id is refused because a feed nobody can attribute to a key is a feed nobody can revoke.
type Source ¶
type Source struct {
// URL is the https address of the signed feed.
URL string
// HTTP is the client to use. Zero value uses SafeClient.
HTTP *http.Client
}
Source is where the signed feed is fetched from.
Every property of this request is chosen so that the origin learns as close to nothing as an HTTPS request permits. It is a plain unconditional GET for one fixed path with no query string, no cookies, no authorization, no client-generated id, and a user agent that names the product and nothing else, deliberately not the version: a version in the user agent would let an origin serve a different document to the users of one release, and being unable to distinguish clients is the entire point. The response is a static document, identical for everyone.
The one thing an origin does learn is an IP address, which is unavoidable for anything that fetches over a network at all, and which is why the whole channel has an off switch.
func (Source) Fetch ¶
Fetch retrieves the signed feed document. It reads at most MaxFeedBytes, so an origin that starts streaming an endless body wastes its own time and not the client's memory. It does not verify anything: verification is Accept's job, and keeping the two apart means no path exists where bytes off a socket get used without going through it.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the client's on-disk notice state under the data directory: the last accepted feed document, and the trust state above.
The cached document is kept as the signed bytes, not as a decoded feed. It is therefore re-verified from scratch on every run, which means a local attacker who edits the cache file has not forged a notice, they have merely destroyed one: the edited document fails its signature check and is discarded. Caching decoded text would have handed them a way to put words in Flynn's mouth.
func (*Store) LoadTrust ¶
LoadTrust reads the trust state. A missing file is the first run and yields a zero Trust, which trusts nothing and has seen nothing. A corrupt file is an error rather than a silent reset: resetting would drop the highest-version-ever mark, which is exactly the state a rollback attack needs gone, so a client that quietly rebuilt it from nothing would be doing the attacker's work.
func (*Store) MarkShown ¶
MarkShown records that these notices have been displayed. Security notices are not recorded: they are meant to keep appearing while they apply.
type Trust ¶
type Trust struct {
// Version is the highest feed version ever accepted. It only ever increases.
Version uint64 `json:"version"`
// Checked is when a feed was last accepted, used to decide whether to look again.
Checked time.Time `json:"checked"`
// Shown holds the ids of notices already displayed, so a release notice appears once
// instead of on every run. Security notices ignore this: they are shown for as long
// as they apply, because a user who is still on a vulnerable version still needs to
// know, however bored they are of hearing it.
Shown []string `json:"shown"`
}
Trust is what a client remembers between runs. It is small on purpose: the highest feed version it has ever trusted (which is what makes a rollback detectable across runs, not just within one), when it last managed to check, and which notices it has already shown.