Keyless agents create release tags: receive and fetch authorize certified tag keys 1ec6fdbc6bcb
Parts 2 and 3 of #129. A receiver authorizes a tag signed by a short-lived key by the policy's subject grant at its own clock, as it does a bookmark move, and records its acceptance (the hub signs it with its key). A fetch checks such a tag at that acceptance, or at its integrated time when the host's log gives one, never at the time the tag claims: without either the tag is left out with a warning and the rest is fetched. A client keeps the issuer's acceptance, serves it and pushes it with the tag, and a receiver takes a tag at a carried acceptance when its own clock is past the certificate; gc keeps it. Tags on the wire gain keys 4 to 6 (acceptance, log place, cosigned checkpoints), only when present, which older readers ignore; the fetch checks a tag's log entry as a bookmark's. Spec §10.1, §14.5, §14.7, §14.8, §16.3 and docs/protocol.md say so. Closes #129
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 984468c55197
- When
- · 2026-10-20 04:54 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
1ec6fdbc6bcba5867766727220e1b13e9460a72ad5291f54d77df766d2ac21f8- Depends on
- 028545dfa7a4 096f447ccda0 0ed150d62994 132b884b9a8d 3526d339519f 4b9d06778581 5b9c6ad45f6e 6f06d9f912b4 750251b21a96 75167672ebcd 767ac6cd0545 83e0677b07fe 8fd78eb52ad2 982864068a59 9f9e72b93e4e a2387d547373 b2ef93bb2366 ba160f60219e d8524a9f0117 e4595038502a e5d9b0513f3e ee06a6c557b7 f0a517d28237 f53cbe7a204e ff0af1ee299d
- Depended on by
- 570e35310f31 b63fc7a77fc6 ee130dd9eef2
- In bookmarks
- identical-sides main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 70 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Keyless agents can create release tags (#129).** `oxy tag create` and `oxy push` under a keyless home was refused "unknown key": the push did not carry the tag signer's identity and certificate, and tags were authorized only by keys the policy names, never by a certificate under a subject grant. The push now carries them, and a receiver authorizes a short-lived key's tag by a subject grant at its own clock, as it does a bookmark move, and records when it accepted it (OxygenHub signs that with its key). A fetch checks such a tag at that acceptance, or at its integrated time when the hub's log gives one, so a release tag made with a ten-minute key is still fetched months later. A fetch never checks it at the time the tag claims: without an acceptance the tag is left out with a warning (`tags/NAME not updated`), and the rest is fetched. A client keeps the acceptance and serves and pushes it with the tag, so a tag passed on to another remote, or cloned from a clone, keeps its time. The wire change is additive: tags in `refs` and `push` gain keys 4 to 6 (acceptance, log place, cosigned checkpoints), which older readers ignore. |
|
| 6 | + | ||
| 5 | 7 | ## 0.5.0 (2026-10-20) |
|
| 6 | 8 | ||
| 7 | 9 | - **A repository kept in a bucket finds signatures and seals without a LIST each (#116).** Every lookup of an object's signatures, seals, receipts, approvals or certificate listed a prefix in the bucket, and most of these lookups found nothing. Writers now also add each such entry to a journal (`pack-entries`, updated by compare-and-swap), and `oxy gc` folds them all into one list. Once a store has been folded, a reader reads the lists and the journal when it opens it, and again after 30 seconds (as long as the pack listing is kept), and looks everything up from them. Another writer's entry is seen within 30 seconds, as its pack would be, even by a handle a server keeps open. The first `oxy gc` also copies the one-list keys of much older stores (`sigs/`, `seals/`, `certs/`, …) into the current layout and marks them folded, so readers stop requesting them; until now nearly every lookup made one of these requests and found nothing. On the test store, 40 lookups went from 85 requests (40 LISTs) to 7 (one journal read and no LIST). Older readers are unaffected, because every entry still has its own key. Entries written by oxy 0.4.6 or older after the fold are missed until the next `oxy gc`, so upgrade every client that writes to the bucket first, then run `oxy gc` once. A server's `/health` counts journal reads as `read_journal`. A repository's own directory works as before. |
|
| … | 248 unchanged lines |
modified crates/oxy-core/src/protocol.rs +174 −58
| … | 91 unchanged lines |
||
| 92 | 92 | pub state: ObjectRef, |
|
| 93 | 93 | pub heads: Vec<ChangeHash>, |
|
| 94 | 94 | pub attestation: Option<SignedAttest>, |
|
| 95 | + | /// The acceptance of `attestation` by its signer's certificate issuer |
|
| 96 | + | /// when a short-lived key signed it (spec §14.7; key 4, only when there |
|
| 97 | + | /// is one): the time a fetch checks the certificate at. |
|
| 98 | + | pub accepted: Option<SignedAttest>, |
|
| 99 | + | /// The attestation's place in the host's transparency log (key 5, |
|
| 100 | + | /// only when it has one), as a bookmark's. |
|
| 101 | + | pub logged: Option<Logged>, |
|
| 102 | + | /// The cosigned checkpoints that first included `logged`'s entry (key |
|
| 103 | + | /// 6, only when there are any). |
|
| 104 | + | pub integration: Vec<Integration>, |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | impl TagRef { |
|
| 108 | + | /// A tag with no acceptance or log place (yet). |
|
| 109 | + | pub fn new( |
|
| 110 | + | name: String, |
|
| 111 | + | state: ObjectRef, |
|
| 112 | + | heads: Vec<ChangeHash>, |
|
| 113 | + | attestation: Option<SignedAttest>, |
|
| 114 | + | ) -> TagRef { |
|
| 115 | + | TagRef { |
|
| 116 | + | name, |
|
| 117 | + | state, |
|
| 118 | + | heads, |
|
| 119 | + | attestation, |
|
| 120 | + | accepted: None, |
|
| 121 | + | logged: None, |
|
| 122 | + | integration: vec![], |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 95 | 125 | } |
|
| 96 | 126 | ||
| 97 | 127 | /// One reference as a remote reports it. |
|
| … | 823 unchanged lines |
||
| 921 | 951 | Value::Array( |
|
| 922 | 952 | tags.iter() |
|
| 923 | 953 | .map(|t| { |
|
| 924 | - | Value::map([ |
|
| 954 | + | let mut v = Value::map([ |
|
| 925 | 955 | (0, Value::Text(t.name.clone())), |
|
| 926 | 956 | (1, Value::Bytes(t.state.0.to_vec())), |
|
| 927 | 957 | (2, hashes_value(&t.heads)), |
|
| 928 | 958 | (3, opt_attest_value(&t.attestation)), |
|
| 929 | - | ]) |
|
| 959 | + | ]); |
|
| 960 | + | // Only when there is one; older peers ignore them |
|
| 961 | + | // (oxygen#129). |
|
| 962 | + | if let Value::Map(m) = &mut v { |
|
| 963 | + | if t.accepted.is_some() { |
|
| 964 | + | m.insert(4, opt_attest_value(&t.accepted)); |
|
| 965 | + | } |
|
| 966 | + | if let Some(l) = &t.logged { |
|
| 967 | + | m.insert(5, logged_value(l)); |
|
| 968 | + | } |
|
| 969 | + | if !t.integration.is_empty() { |
|
| 970 | + | m.insert(6, integration_value(&t.integration)); |
|
| 971 | + | } |
|
| 972 | + | } |
|
| 973 | + | v |
|
| 930 | 974 | }) |
|
| 931 | 975 | .collect(), |
|
| 932 | 976 | ) |
|
| … | 7 unchanged lines |
||
| 940 | 984 | state: o::oref(field(tm, 1, "state")?, "state")?, |
|
| 941 | 985 | heads: hashes_from(field(tm, 2, "heads")?)?, |
|
| 942 | 986 | attestation: opt_attest_from(field(tm, 3, "attestation")?)?, |
|
| 987 | + | accepted: match tm.get(&4) { |
|
| 988 | + | Some(a) => opt_attest_from(a)?, |
|
| 989 | + | None => None, |
|
| 990 | + | }, |
|
| 991 | + | logged: tm.get(&5).map(logged_from).transpose()?, |
|
| 992 | + | integration: match tm.get(&6) { |
|
| 993 | + | Some(l) => integration_from(l)?, |
|
| 994 | + | None => Vec::new(), |
|
| 995 | + | }, |
|
| 943 | 996 | }); |
|
| 997 | + | } |
|
| 998 | + | Ok(out) |
|
| 999 | + | } |
|
| 1000 | + | ||
| 1001 | + | /// The tags of a refs answer as oxy 0.5.0 read them (and the runners |
|
| 1002 | + | /// built with it), keys 0 to 3 only: what an older reader makes of an |
|
| 1003 | + | /// answer with keys 4 to 6. |
|
| 1004 | + | #[cfg(test)] |
|
| 1005 | + | pub(super) fn tags_as_0_5_read_them(bytes: &[u8]) -> Result<Vec<TagRef>> { |
|
| 1006 | + | let v = cbor::decode(bytes).map_err(ObjectError::from)?; |
|
| 1007 | + | let m = map(&v, "refs")?; |
|
| 1008 | + | let mut out = Vec::new(); |
|
| 1009 | + | for t in o::array(field(m, 3, "tags")?, "tags")? { |
|
| 1010 | + | let tm = map(t, "tag")?; |
|
| 1011 | + | out.push(TagRef::new( |
|
| 1012 | + | o::text(field(tm, 0, "name")?, "name")?.to_owned(), |
|
| 1013 | + | o::oref(field(tm, 1, "state")?, "state")?, |
|
| 1014 | + | hashes_from(field(tm, 2, "heads")?)?, |
|
| 1015 | + | opt_attest_from(field(tm, 3, "attestation")?)?, |
|
| 1016 | + | )); |
|
| 944 | 1017 | } |
|
| 945 | 1018 | Ok(out) |
|
| 1019 | + | } |
|
| 1020 | + | ||
| 1021 | + | /// A log place, `[entry number, entry bytes, [proof hash]]` (spec §14.8). |
|
| 1022 | + | fn logged_value(l: &Logged) -> Value { |
|
| 1023 | + | Value::Array(vec![ |
|
| 1024 | + | Value::Uint(l.index), |
|
| 1025 | + | Value::Bytes(l.entry.clone()), |
|
| 1026 | + | proof_value(&l.proof), |
|
| 1027 | + | ]) |
|
| 946 | 1028 | } |
|
| 1029 | + | fn logged_from(v: &Value) -> Result<Logged> { |
|
| 1030 | + | let a = o::array(v, "logged")?; |
|
| 1031 | + | if a.len() != 3 { |
|
| 1032 | + | return Err(schema("logged: expected [index, entry, proof]")); |
|
| 1033 | + | } |
|
| 1034 | + | Ok(Logged { |
|
| 1035 | + | index: o::uint(&a[0], "index")?, |
|
| 1036 | + | entry: o::bytes(&a[1], "entry")?.to_vec(), |
|
| 1037 | + | proof: proof_from(&a[2])?, |
|
| 1038 | + | }) |
|
| 1039 | + | } |
|
| 947 | 1040 | ||
| 1041 | + | /// Cosigned checkpoints holding an entry, `[[note, [proof hash]]]`. |
|
| 1042 | + | fn integration_value(integration: &[Integration]) -> Value { |
|
| 1043 | + | Value::Array( |
|
| 1044 | + | integration |
|
| 1045 | + | .iter() |
|
| 1046 | + | .map(|i| Value::Array(vec![Value::Text(i.note.clone()), proof_value(&i.proof)])) |
|
| 1047 | + | .collect(), |
|
| 1048 | + | ) |
|
| 1049 | + | } |
|
| 1050 | + | fn integration_from(v: &Value) -> Result<Vec<Integration>> { |
|
| 1051 | + | let mut out = Vec::new(); |
|
| 1052 | + | for i in o::array(v, "integration")? { |
|
| 1053 | + | let a = o::array(i, "integration")?; |
|
| 1054 | + | if a.len() != 2 { |
|
| 1055 | + | return Err(schema("integration: expected [note, proof]")); |
|
| 1056 | + | } |
|
| 1057 | + | out.push(Integration { |
|
| 1058 | + | note: o::text(&a[0], "note")?.to_owned(), |
|
| 1059 | + | proof: proof_from(&a[1])?, |
|
| 1060 | + | }); |
|
| 1061 | + | } |
|
| 1062 | + | Ok(out) |
|
| 1063 | + | } |
|
| 1064 | + | ||
| 948 | 1065 | pub fn encode_refs(id: [u8; 16], resp: &RefsResponse) -> Vec<u8> { |
|
| 949 | 1066 | let mut v = Value::map([ |
|
| 950 | 1067 | ( |
|
| … | 15 unchanged lines |
||
| 966 | 1083 | m.insert(5, opt_attest_value(&r.accepted)); |
|
| 967 | 1084 | } |
|
| 968 | 1085 | if let Some(l) = &r.logged { |
|
| 969 | - | m.insert( |
|
| 970 | - | 6, |
|
| 971 | - | Value::Array(vec![ |
|
| 972 | - | Value::Uint(l.index), |
|
| 973 | - | Value::Bytes(l.entry.clone()), |
|
| 974 | - | proof_value(&l.proof), |
|
| 975 | - | ]), |
|
| 976 | - | ); |
|
| 1086 | + | m.insert(6, logged_value(l)); |
|
| 977 | 1087 | } |
|
| 978 | 1088 | if !r.integration.is_empty() { |
|
| 979 | - | m.insert( |
|
| 980 | - | 7, |
|
| 981 | - | Value::Array( |
|
| 982 | - | r.integration |
|
| 983 | - | .iter() |
|
| 984 | - | .map(|i| { |
|
| 985 | - | Value::Array(vec![ |
|
| 986 | - | Value::Text(i.note.clone()), |
|
| 987 | - | proof_value(&i.proof), |
|
| 988 | - | ]) |
|
| 989 | - | }) |
|
| 990 | - | .collect(), |
|
| 991 | - | ), |
|
| 992 | - | ); |
|
| 1089 | + | m.insert(7, integration_value(&r.integration)); |
|
| 993 | 1090 | } |
|
| 994 | 1091 | } |
|
| 995 | 1092 | v |
|
| … | 61 unchanged lines |
||
| 1057 | 1154 | }, |
|
| 1058 | 1155 | accepted: match rm.get(&5) { |
|
| 1059 | 1156 | Some(a) => opt_attest_from(a)?, |
|
| 1060 | - | None => None, |
|
| 1061 | - | }, |
|
| 1062 | - | logged: match rm.get(&6) { |
|
| 1063 | - | Some(l) => { |
|
| 1064 | - | let a = o::array(l, "logged")?; |
|
| 1065 | - | if a.len() != 3 { |
|
| 1066 | - | return Err(schema("logged: expected [index, entry, proof]")); |
|
| 1067 | - | } |
|
| 1068 | - | Some(Logged { |
|
| 1069 | - | index: o::uint(&a[0], "index")?, |
|
| 1070 | - | entry: o::bytes(&a[1], "entry")?.to_vec(), |
|
| 1071 | - | proof: proof_from(&a[2])?, |
|
| 1072 | - | }) |
|
| 1073 | - | } |
|
| 1074 | 1157 | None => None, |
|
| 1075 | 1158 | }, |
|
| 1159 | + | logged: rm.get(&6).map(logged_from).transpose()?, |
|
| 1076 | 1160 | integration: match rm.get(&7) { |
|
| 1077 | - | Some(l) => { |
|
| 1078 | - | let mut out = Vec::new(); |
|
| 1079 | - | for i in o::array(l, "integration")? { |
|
| 1080 | - | let a = o::array(i, "integration")?; |
|
| 1081 | - | if a.len() != 2 { |
|
| 1082 | - | return Err(schema("integration: expected [note, proof]")); |
|
| 1083 | - | } |
|
| 1084 | - | out.push(Integration { |
|
| 1085 | - | note: o::text(&a[0], "note")?.to_owned(), |
|
| 1086 | - | proof: proof_from(&a[1])?, |
|
| 1087 | - | }); |
|
| 1088 | - | } |
|
| 1089 | - | out |
|
| 1090 | - | } |
|
| 1161 | + | Some(l) => integration_from(l)?, |
|
| 1091 | 1162 | None => Vec::new(), |
|
| 1092 | 1163 | }, |
|
| 1093 | 1164 | }); |
|
| … | 328 unchanged lines |
||
| 1422 | 1493 | integration: vec![], |
|
| 1423 | 1494 | }, |
|
| 1424 | 1495 | ]; |
|
| 1425 | - | let tag = TagRef { |
|
| 1426 | - | name: "v1".into(), |
|
| 1427 | - | state: ObjectRef([2; 32]), |
|
| 1428 | - | heads: vec![c.hash()], |
|
| 1429 | - | attestation: None, |
|
| 1430 | - | }; |
|
| 1496 | + | let tag = TagRef::new("v1".into(), ObjectRef([2; 32]), vec![c.hash()], None); |
|
| 1431 | 1497 | let mut resp = RefsResponse { |
|
| 1432 | 1498 | refs, |
|
| 1433 | 1499 | policy: Some(SignedPolicy { |
|
| … | 43 unchanged lines |
||
| 1477 | 1543 | assert_eq!(back.policy.as_ref().unwrap().policy, policy); |
|
| 1478 | 1544 | assert_eq!(back.history, resp.history); |
|
| 1479 | 1545 | assert_eq!(back.tags, vec![tag.clone()]); |
|
| 1546 | + | ||
| 1547 | + | // A tag's acceptance and log place (oxygen#129): keys 4 to 6, only |
|
| 1548 | + | // when there are any, so a tag without them encodes as before. |
|
| 1549 | + | let crate::cbor::Value::Map(m) = crate::cbor::decode(&new).unwrap() else { |
|
| 1550 | + | panic!() |
|
| 1551 | + | }; |
|
| 1552 | + | let crate::cbor::Value::Array(tags) = &m[&3] else { |
|
| 1553 | + | panic!() |
|
| 1554 | + | }; |
|
| 1555 | + | let crate::cbor::Value::Map(plain) = &tags[0] else { |
|
| 1556 | + | panic!() |
|
| 1557 | + | }; |
|
| 1558 | + | assert_eq!(plain.keys().copied().collect::<Vec<_>>(), vec![0, 1, 2, 3]); |
|
| 1559 | + | let mut full = tag.clone(); |
|
| 1560 | + | full.name = "v2".into(); |
|
| 1561 | + | full.attestation = Some(SignedAttest { |
|
| 1562 | + | attest: att.clone(), |
|
| 1563 | + | sigs: vec![sig.clone()], |
|
| 1564 | + | }); |
|
| 1565 | + | full.accepted = full.attestation.clone(); |
|
| 1566 | + | full.logged = Some(Logged { |
|
| 1567 | + | index: 7, |
|
| 1568 | + | entry: vec![4], |
|
| 1569 | + | proof: vec![[3; 32]], |
|
| 1570 | + | }); |
|
| 1571 | + | full.integration = vec![Integration { |
|
| 1572 | + | note: "hub.example/log\n8\nroot\n\n- sig\n".into(), |
|
| 1573 | + | proof: vec![], |
|
| 1574 | + | }]; |
|
| 1575 | + | resp.tags = vec![full.clone(), tag.clone()]; |
|
| 1576 | + | let bytes = wire::encode_refs([3; 16], &resp); |
|
| 1577 | + | assert_eq!(wire::decode_refs(&bytes).unwrap().1.tags, resp.tags); |
|
| 1578 | + | // In a push too. |
|
| 1579 | + | let pushed = wire::encode_push(&Bundle::default(), &[], None, &resp.tags); |
|
| 1580 | + | assert_eq!(wire::decode_push(&pushed).unwrap().3, resp.tags); |
|
| 1581 | + | // A reader that knows only keys 0 to 3 (oxy 0.5.0 and older, and |
|
| 1582 | + | // the runners built with it) reads the same tags from the new answer. |
|
| 1583 | + | let stripped: Vec<TagRef> = resp |
|
| 1584 | + | .tags |
|
| 1585 | + | .iter() |
|
| 1586 | + | .map(|t| { |
|
| 1587 | + | TagRef::new( |
|
| 1588 | + | t.name.clone(), |
|
| 1589 | + | t.state, |
|
| 1590 | + | t.heads.clone(), |
|
| 1591 | + | t.attestation.clone(), |
|
| 1592 | + | ) |
|
| 1593 | + | }) |
|
| 1594 | + | .collect(); |
|
| 1595 | + | assert_eq!(wire::tags_as_0_5_read_them(&bytes).unwrap(), stripped); |
|
| 1480 | 1596 | ||
| 1481 | 1597 | let (h, w) = wire::decode_negotiate(&wire::encode_negotiate( |
|
| 1482 | 1598 | &[ObjectRef([1; 32])], |
|
| … | 68 unchanged lines |
modified crates/oxy-core/src/repo/gc.rs +4 −0
| … | 143 unchanged lines |
||
| 144 | 144 | states.extend(view_now.tags.values().copied()); |
|
| 145 | 145 | for name in view_now.tags.keys() { |
|
| 146 | 146 | if let Some((r, _)) = self.tag_attest(name)? { |
|
| 147 | + | // With its issuer's acceptance (spec §14.7). |
|
| 148 | + | if let Some(acc) = self.acceptance_of(&r)? { |
|
| 149 | + | attests.push(acc.attest.hash()); |
|
| 150 | + | } |
|
| 147 | 151 | attests.push(r); |
|
| 148 | 152 | } |
|
| 149 | 153 | } |
|
| … | 98 unchanged lines |
modified crates/oxy-core/src/repo/strata.rs +7 −2
| … | 346 unchanged lines |
||
| 347 | 347 | for t in &resp.tags { |
|
| 348 | 348 | mirror.store.put_state(&StateObject::new(t.heads.clone()))?; |
|
| 349 | 349 | mirror |
|
| 350 | - | .adopt_tag(&mut view, t, policy.as_ref()) |
|
| 351 | - | .map_err(|e| RepoError::Msg(format!("{}: {e}", self.what())))?; |
|
| 350 | + | .adopt_tag( |
|
| 351 | + | &mut view, |
|
| 352 | + | t, |
|
| 353 | + | policy.as_ref(), |
|
| 354 | + | &mut super::tags::TagCheck::Mirrored, |
|
| 355 | + | ) |
|
| 356 | + | .map_err(|e| RepoError::Msg(format!("{}: {}", self.what(), e.reason)))?; |
|
| 352 | 357 | } |
|
| 353 | 358 | if view != op.view { |
|
| 354 | 359 | mirror.record_op(&[op_ref], "strata sync", view)?; |
|
| … | 1007 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +49 −28
| 1 | 1 | //! Serving and talking to remotes (spec §10.2, §16). |
|
| 2 | 2 | ||
| 3 | + | use super::tags::TagCheck; |
|
| 3 | 4 | use super::*; |
|
| 4 | 5 | ||
| 5 | 6 | /// What a push did, for hosts (hub spec §6.12). |
|
| … | 938 unchanged lines |
||
| 944 | 945 | for t in &tags { |
|
| 945 | 946 | let name = format!("tags/{}", t.name); |
|
| 946 | 947 | let new = !view.tags.contains_key(&t.name); |
|
| 947 | - | match self.adopt_tag(&mut view, t, policy.as_ref()) { |
|
| 948 | + | match self.adopt_tag(&mut view, t, policy.as_ref(), &mut TagCheck::Received) { |
|
| 948 | 949 | Ok(()) => { |
|
| 949 | 950 | // A tag new here is an update too, so a hub can start |
|
| 950 | 951 | // its pipeline's tag jobs (oxygenflow#24); one it |
|
| … | 16 unchanged lines |
||
| 967 | 968 | Err(e) => results.push(RefResult { |
|
| 968 | 969 | name, |
|
| 969 | 970 | ok: false, |
|
| 970 | - | reason: e, |
|
| 971 | + | reason: e.reason, |
|
| 971 | 972 | }), |
|
| 972 | 973 | } |
|
| 973 | 974 | } |
|
| … | 83 unchanged lines |
||
| 1057 | 1058 | sa: &SignedAttest, |
|
| 1058 | 1059 | accepted: Option<&SignedAttest>, |
|
| 1059 | 1060 | ) -> u64 { |
|
| 1060 | - | let own = sa.attest.hlc.wall_ms; |
|
| 1061 | - | let Some(acc) = accepted else { |
|
| 1062 | - | return own; |
|
| 1063 | - | }; |
|
| 1064 | - | let Ok(Some(c)) = self.store.certificate_of(signer) else { |
|
| 1065 | - | return own; |
|
| 1066 | - | }; |
|
| 1067 | - | let Ok(cert) = self.store.get_certificate(&c) else { |
|
| 1068 | - | return own; |
|
| 1069 | - | }; |
|
| 1061 | + | self.issuer_acceptance(signer, sa, accepted) |
|
| 1062 | + | .unwrap_or(sa.attest.hlc.wall_ms) |
|
| 1063 | + | } |
|
| 1064 | + | ||
| 1065 | + | /// The time of `accepted` when it accepts exactly `sa` and is signed by |
|
| 1066 | + | /// the issuer of `signer`'s kept certificate (spec §14.7); else none. |
|
| 1067 | + | pub(crate) fn issuer_acceptance( |
|
| 1068 | + | &self, |
|
| 1069 | + | signer: &KeyId, |
|
| 1070 | + | sa: &SignedAttest, |
|
| 1071 | + | accepted: Option<&SignedAttest>, |
|
| 1072 | + | ) -> Option<u64> { |
|
| 1073 | + | let acc = accepted?; |
|
| 1074 | + | let c = self.store.certificate_of(signer).ok().flatten()?; |
|
| 1075 | + | let cert = self.store.get_certificate(&c).ok()?; |
|
| 1070 | 1076 | let AttestBody::Accepted { attest } = &acc.attest.body else { |
|
| 1071 | - | return own; |
|
| 1072 | - | }; |
|
| 1073 | - | let Some(sig) = acc.sigs.iter().find(|s| s.key == cert.cert.issuer) else { |
|
| 1074 | - | return own; |
|
| 1077 | + | return None; |
|
| 1075 | 1078 | }; |
|
| 1076 | - | let Ok(issuer) = self.store.get_identity(&cert.cert.issuer) else { |
|
| 1077 | - | return own; |
|
| 1078 | - | }; |
|
| 1079 | - | if *attest == sa.attest.hash() |
|
| 1080 | - | && verify_signature(&issuer, sig, Domain::Attest, &acc.attest.hash()).is_ok() |
|
| 1081 | - | { |
|
| 1082 | - | acc.attest.hlc.wall_ms |
|
| 1083 | - | } else { |
|
| 1084 | - | own |
|
| 1079 | + | let sig = acc.sigs.iter().find(|s| s.key == cert.cert.issuer)?; |
|
| 1080 | + | let issuer = self.store.get_identity(&cert.cert.issuer).ok()?; |
|
| 1081 | + | (*attest == sa.attest.hash() |
|
| 1082 | + | && verify_signature(&issuer, sig, Domain::Attest, &acc.attest.hash()).is_ok()) |
|
| 1083 | + | .then_some(acc.attest.hlc.wall_ms) |
|
| 1084 | + | } |
|
| 1085 | + | ||
| 1086 | + | /// Keep an acceptance this repository was given for attestation `r` |
|
| 1087 | + | /// (one [`Repo::issuer_acceptance`] took), so it is served again with |
|
| 1088 | + | /// what it accepts. |
|
| 1089 | + | pub(crate) fn keep_acceptance(&self, r: &ObjectRef, acc: &SignedAttest) -> Result<()> { |
|
| 1090 | + | let a = self.store.put_attest(&acc.attest)?; |
|
| 1091 | + | for sig in &acc.sigs { |
|
| 1092 | + | self.store.put_signature(&a, sig)?; |
|
| 1085 | 1093 | } |
|
| 1094 | + | self.store.index_add_pub("accepted", r, &a)?; |
|
| 1095 | + | Ok(()) |
|
| 1086 | 1096 | } |
|
| 1087 | 1097 | ||
| 1088 | 1098 | /// A fetched bookmark's attestation by a signer the policy does not |
|
| … | 689 unchanged lines |
||
| 1778 | 1788 | self.import_bundle(&bundle, false)?; |
|
| 1779 | 1789 | } |
|
| 1780 | 1790 | self.store.put_state(&StateObject::new(t.heads.clone()))?; |
|
| 1781 | - | self.adopt_tag(&mut view, t, policy.as_ref()) |
|
| 1782 | - | .map_err(|e| RepoError::Msg(format!("{remote_name} {}: {e}", t.name)))?; |
|
| 1783 | - | new_tags += 1; |
|
| 1791 | + | let mut check = TagCheck::Fetched { |
|
| 1792 | + | remote: remote_name, |
|
| 1793 | + | log: &log, |
|
| 1794 | + | notes: &mut log_notes, |
|
| 1795 | + | }; |
|
| 1796 | + | match self.adopt_tag(&mut view, t, policy.as_ref(), &mut check) { |
|
| 1797 | + | Ok(()) => new_tags += 1, |
|
| 1798 | + | // Nothing says when a short-lived key signed it: the tag is |
|
| 1799 | + | // left out, said so, and the rest is fetched (oxygen#129). |
|
| 1800 | + | Err(e) if e.untimed => { |
|
| 1801 | + | skipped.push((format!("tags/{}", t.name), e.reason)); |
|
| 1802 | + | } |
|
| 1803 | + | Err(e) => return fail(format!("{remote_name} {}: {}", t.name, e.reason)), |
|
| 1804 | + | } |
|
| 1784 | 1805 | } |
|
| 1785 | 1806 | if view != op.view { |
|
| 1786 | 1807 | self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?; |
|
| … | 839 unchanged lines |
modified crates/oxy-core/src/repo/tags.rs +163 −14
| … | 4 unchanged lines |
||
| 5 | 5 | ||
| 6 | 6 | use super::*; |
|
| 7 | 7 | ||
| 8 | + | /// How a tag's signer is judged when it is adopted (spec §10.1, §14.7). |
|
| 9 | + | pub(crate) enum TagCheck<'a> { |
|
| 10 | + | /// Received in a push: a short-lived key's certificate is checked at |
|
| 11 | + | /// the receiver's own clock, or at its issuer's acceptance when the |
|
| 12 | + | /// push carries one (a tag passed on from another host). |
|
| 13 | + | Received, |
|
| 14 | + | /// Fetched from `remote`: at the tag attestation's integrated time when |
|
| 15 | + | /// the host's log gives one, else at its certificate issuer's |
|
| 16 | + | /// acceptance; never at the time the tag claims, which its signer chose. |
|
| 17 | + | Fetched { |
|
| 18 | + | remote: &'a str, |
|
| 19 | + | log: &'a super::transparency::LogCheck, |
|
| 20 | + | notes: &'a mut Vec<String>, |
|
| 21 | + | }, |
|
| 22 | + | /// Replayed into a Strata mirror from its store's record: at the |
|
| 23 | + | /// acceptance when there is one, else at the tag's own time. The fetch |
|
| 24 | + | /// from the mirror then judges it as every fetch does. |
|
| 25 | + | Mirrored, |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | /// Why a tag was not adopted. |
|
| 29 | + | #[derive(Debug)] |
|
| 30 | + | pub(crate) struct TagRefusal { |
|
| 31 | + | pub reason: String, |
|
| 32 | + | /// Signed by a short-lived key, with nothing that says when to check |
|
| 33 | + | /// its certificate: a fetch leaves the tag out and goes on. |
|
| 34 | + | pub untimed: bool, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | impl From<String> for TagRefusal { |
|
| 38 | + | fn from(reason: String) -> TagRefusal { |
|
| 39 | + | TagRefusal { |
|
| 40 | + | reason, |
|
| 41 | + | untimed: false, |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | impl From<&str> for TagRefusal { |
|
| 47 | + | fn from(reason: &str) -> TagRefusal { |
|
| 48 | + | reason.to_string().into() |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | ||
| 8 | 52 | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| 9 | 53 | pub struct TagInfo { |
|
| 10 | 54 | pub name: String, |
|
| … | 133 unchanged lines |
||
| 144 | 188 | .tag_attest(&t.name)? |
|
| 145 | 189 | .map(|(_, a)| a) |
|
| 146 | 190 | .filter(|a| !a.sigs.is_empty()); |
|
| 147 | - | out.push(crate::protocol::TagRef { |
|
| 148 | - | name: t.name.clone(), |
|
| 149 | - | state: t.state, |
|
| 150 | - | heads: t.heads, |
|
| 151 | - | attestation, |
|
| 152 | - | }); |
|
| 191 | + | // A short-lived key's tag goes with its issuer's acceptance |
|
| 192 | + | // (spec §14.7), for whoever fetches it later. |
|
| 193 | + | let accepted = match &attestation { |
|
| 194 | + | Some(a) => self.tag_acceptance(a)?, |
|
| 195 | + | None => None, |
|
| 196 | + | }; |
|
| 197 | + | let mut tag = crate::protocol::TagRef::new(t.name, t.state, t.heads, attestation); |
|
| 198 | + | tag.accepted = accepted; |
|
| 199 | + | out.push(tag); |
|
| 153 | 200 | } |
|
| 154 | 201 | Ok(out) |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | /// The acceptance kept for tag attestation `sa`: its signer's |
|
| 205 | + | /// certificate issuer's when one of those kept is, else the first. |
|
| 206 | + | fn tag_acceptance(&self, sa: &SignedAttest) -> Result<Option<SignedAttest>> { |
|
| 207 | + | let r = sa.attest.hash(); |
|
| 208 | + | let mut first = None; |
|
| 209 | + | for a in self.store.index_get_pub("accepted", &r)? { |
|
| 210 | + | let acc = SignedAttest { |
|
| 211 | + | attest: self.store.get_attest(&a)?, |
|
| 212 | + | sigs: self.store.signatures(&a)?, |
|
| 213 | + | }; |
|
| 214 | + | let signer = sa.sigs.first().map(|s| s.key); |
|
| 215 | + | if signer.is_some_and(|k| self.issuer_acceptance(&k, sa, Some(&acc)).is_some()) { |
|
| 216 | + | return Ok(Some(acc)); |
|
| 217 | + | } |
|
| 218 | + | first.get_or_insert(acc); |
|
| 219 | + | } |
|
| 220 | + | Ok(first) |
|
| 155 | 221 | } |
|
| 156 | 222 | ||
| 157 | 223 | /// Adopt a tag received from a remote or a push. Verifies the |
|
| 158 | 224 | /// attestation when present (and requires one, by an authorized key, |
|
| 159 | 225 | /// under a policy). An existing tag with a different state is refused. |
|
| 226 | + | /// A short-lived key's tag is authorized by a subject grant, its |
|
| 227 | + | /// certificate checked at the time `check` says (spec §14.7), and its |
|
| 228 | + | /// issuer's acceptance is kept with it, or recorded when this host |
|
| 229 | + | /// receives it. |
|
| 160 | 230 | pub(crate) fn adopt_tag( |
|
| 161 | 231 | &self, |
|
| 162 | 232 | view: &mut View, |
|
| 163 | 233 | t: &crate::protocol::TagRef, |
|
| 164 | 234 | policy: Option<&crate::policy::Policy>, |
|
| 165 | - | ) -> std::result::Result<(), String> { |
|
| 235 | + | check: &mut TagCheck, |
|
| 236 | + | ) -> std::result::Result<(), TagRefusal> { |
|
| 166 | 237 | if let Some(existing) = view.tags.get(&t.name) { |
|
| 167 | 238 | return if *existing == t.state { |
|
| 168 | 239 | Ok(()) |
|
| … | 1 unchanged lines |
||
| 170 | 241 | Err(format!( |
|
| 171 | 242 | "tag {:?} already exists with a different state; tags are immutable", |
|
| 172 | 243 | t.name |
|
| 173 | - | )) |
|
| 244 | + | ) |
|
| 245 | + | .into()) |
|
| 174 | 246 | }; |
|
| 175 | 247 | } |
|
| 176 | 248 | let state = self |
|
| … | 6 unchanged lines |
||
| 183 | 255 | "tag {:?} names change {}, which is missing", |
|
| 184 | 256 | t.name, |
|
| 185 | 257 | h.short(12) |
|
| 186 | - | )); |
|
| 258 | + | ) |
|
| 259 | + | .into()); |
|
| 187 | 260 | } |
|
| 188 | 261 | } |
|
| 189 | 262 | if let Some(sa) = &t.attestation { |
|
| … | 19 unchanged lines |
||
| 209 | 282 | .get_identity(&sig.key) |
|
| 210 | 283 | .map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
| 211 | 284 | verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?; |
|
| 285 | + | let certified = self.store.certificate_of(&sig.key).ok().flatten().is_some(); |
|
| 286 | + | // The issuer's acceptance this tag came with, when it is one. |
|
| 287 | + | let accepted = self.issuer_acceptance(&sig.key, sa, t.accepted.as_ref()); |
|
| 212 | 288 | if let Some(p) = policy { |
|
| 213 | 289 | let reference = format!("tags/{}", t.name); |
|
| 214 | 290 | let at = sa.attest.hlc.wall_ms; |
|
| 215 | - | if !p.authorizes(&sig.key, &reference) |
|
| 216 | - | && !self.delegate_authorized(p, &sig.key, &reference, at) |
|
| 217 | - | && !self.predecessor_authorized(p, &sig.key, &reference, at) |
|
| 291 | + | let listed = p.authorizes(&sig.key, &reference) |
|
| 292 | + | || self.delegate_authorized(p, &sig.key, &reference, at) |
|
| 293 | + | || self.predecessor_authorized(p, &sig.key, &reference, at); |
|
| 294 | + | if !listed |
|
| 295 | + | && !(certified |
|
| 296 | + | && self.tag_certified_in_time(p, t, sa, &sig.key, accepted, check)?) |
|
| 218 | 297 | { |
|
| 219 | 298 | return Err(format!( |
|
| 220 | 299 | "key {} may not create tags/{} under this repository's policy", |
|
| 221 | 300 | sig.key.short(12), |
|
| 222 | 301 | t.name |
|
| 223 | - | )); |
|
| 302 | + | ) |
|
| 303 | + | .into()); |
|
| 224 | 304 | } |
|
| 225 | 305 | } |
|
| 226 | 306 | let stored = self |
|
| … | 5 unchanged lines |
||
| 232 | 312 | .put_signature(&stored, s) |
|
| 233 | 313 | .map_err(|e| e.to_string())?; |
|
| 234 | 314 | } |
|
| 315 | + | if certified { |
|
| 316 | + | match (&t.accepted, accepted) { |
|
| 317 | + | (Some(acc), Some(_)) => self.keep_acceptance(&stored, acc), |
|
| 318 | + | // Received here: this host says when, as it does for a |
|
| 319 | + | // bookmark's attestation. |
|
| 320 | + | _ if matches!(check, TagCheck::Received) => self.record_acceptance(&stored), |
|
| 321 | + | _ => Ok(()), |
|
| 322 | + | } |
|
| 323 | + | .map_err(|e| e.to_string())?; |
|
| 324 | + | } |
|
| 235 | 325 | self.set_tag_attest(&t.name, &stored) |
|
| 236 | 326 | .map_err(|e| e.to_string())?; |
|
| 237 | 327 | } else if policy.is_some() { |
|
| 238 | 328 | return Err(format!( |
|
| 239 | 329 | "tag {:?} needs a signed attestation under this repository's policy", |
|
| 240 | 330 | t.name |
|
| 241 | - | )); |
|
| 331 | + | ) |
|
| 332 | + | .into()); |
|
| 242 | 333 | } |
|
| 243 | 334 | view.tags.insert(t.name.clone(), t.state); |
|
| 244 | 335 | Ok(()) |
|
| 336 | + | } |
|
| 337 | + | ||
| 338 | + | /// Whether short-lived key `signer`'s certificate gives it tag `t` |
|
| 339 | + | /// under policy `p` at the time `check` says (spec §14.7, §14.8): |
|
| 340 | + | /// `accepted` is the issuer's acceptance time the tag came with. |
|
| 341 | + | fn tag_certified_in_time( |
|
| 342 | + | &self, |
|
| 343 | + | p: &crate::policy::Policy, |
|
| 344 | + | t: &crate::protocol::TagRef, |
|
| 345 | + | sa: &SignedAttest, |
|
| 346 | + | signer: &KeyId, |
|
| 347 | + | accepted: Option<u64>, |
|
| 348 | + | check: &mut TagCheck, |
|
| 349 | + | ) -> std::result::Result<bool, TagRefusal> { |
|
| 350 | + | use super::transparency::{judge, Timed}; |
|
| 351 | + | let reference = format!("tags/{}", t.name); |
|
| 352 | + | let certified = |ms: u64| self.certified_authorized(p, signer, &reference, Scope::REFS, ms); |
|
| 353 | + | Ok(match check { |
|
| 354 | + | TagCheck::Received => certified(Hlc::now().wall_ms) || accepted.is_some_and(certified), |
|
| 355 | + | TagCheck::Mirrored => certified(accepted.unwrap_or(sa.attest.hlc.wall_ms)), |
|
| 356 | + | TagCheck::Fetched { remote, log, notes } => { |
|
| 357 | + | let time = log.tag_time_of(&t.name); |
|
| 358 | + | let integrated = time.as_ref().ok().map(|i| i.ms()); |
|
| 359 | + | if integrated.is_none() && accepted.is_none() { |
|
| 360 | + | return Err(TagRefusal { |
|
| 361 | + | reason: format!( |
|
| 362 | + | "signed by short-lived key {}, and {remote} gave no time to check its certificate at (neither its issuer's acceptance nor a witnessed log time: {}); a tag is never checked at the time it claims", |
|
| 363 | + | signer.short(12), |
|
| 364 | + | time.err().unwrap_or_default() |
|
| 365 | + | ), |
|
| 366 | + | untimed: true, |
|
| 367 | + | }); |
|
| 368 | + | } |
|
| 369 | + | let what = format!("{remote} tag {}", t.name); |
|
| 370 | + | match judge(integrated, certified, || accepted.is_some_and(certified)) { |
|
| 371 | + | Timed::Refused => false, |
|
| 372 | + | Timed::Integrated => true, |
|
| 373 | + | Timed::Disagrees => { |
|
| 374 | + | notes.push(format!( |
|
| 375 | + | "{what}: {}'s certificate had lapsed when witness {} first saw its attestation, though not at the issuer's acceptance; accepted while transparency logs are new", |
|
| 376 | + | signer.short(12), |
|
| 377 | + | time.as_ref().map(|i| i.witness.as_str()).unwrap_or("?") |
|
| 378 | + | )); |
|
| 379 | + | true |
|
| 380 | + | } |
|
| 381 | + | Timed::Fallback => { |
|
| 382 | + | if log.kept { |
|
| 383 | + | notes.push(format!( |
|
| 384 | + | "{what}: {} checked at the issuer's acceptance, not at a witnessed time: {}", |
|
| 385 | + | signer.short(12), |
|
| 386 | + | time.err().unwrap_or_default() |
|
| 387 | + | )); |
|
| 388 | + | } |
|
| 389 | + | true |
|
| 390 | + | } |
|
| 391 | + | } |
|
| 392 | + | } |
|
| 393 | + | }) |
|
| 245 | 394 | } |
|
| 246 | 395 | } |
modified crates/oxy-core/src/repo/tests.rs +180 −8
| … | 1205 unchanged lines |
||
| 1206 | 1206 | .set_user_config("author.signer", &second.to_hex()) |
|
| 1207 | 1207 | .unwrap(); |
|
| 1208 | 1208 | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 1209 | - | assert!(host.store().get_identity(&first).is_err(), "the host has never seen the tag's key"); |
|
| 1209 | + | assert!( |
|
| 1210 | + | host.store().get_identity(&first).is_err(), |
|
| 1211 | + | "the host has never seen the tag's key" |
|
| 1212 | + | ); |
|
| 1210 | 1213 | let r = agent.push("origin", false).unwrap(); |
|
| 1214 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 1215 | + | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 1216 | + | assert!( |
|
| 1217 | + | host.tag_refs().unwrap().iter().any(|t| t.name == "v1"), |
|
| 1218 | + | "the tag arrived" |
|
| 1219 | + | ); |
|
| 1220 | + | } |
|
| 1221 | + | ||
| 1222 | + | /// oxygen#129: a short-lived key's tag is checked on a fetch at a time its |
|
| 1223 | + | /// signer did not choose, its issuer's acceptance, never at the time it |
|
| 1224 | + | /// claims nor at the fetch's (when the ten-minute certificate has long |
|
| 1225 | + | /// expired). Without an acceptance it is left out and said so; with one |
|
| 1226 | + | /// inside the certificate's window it is fetched however much later, and |
|
| 1227 | + | /// a host it is pushed on to keeps and serves the acceptance. |
|
| 1228 | + | #[test] |
|
| 1229 | + | fn a_short_lived_key_s_tag_is_fetched_at_its_issuer_s_acceptance() { |
|
| 1230 | + | use crate::cert::{Certificate, SignedCertificate}; |
|
| 1231 | + | use crate::identity::Scope; |
|
| 1232 | + | hermetic(); |
|
| 1233 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1234 | + | let root = dir.path(); |
|
| 1235 | + | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 1236 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 1237 | + | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 1238 | + | .unwrap() |
|
| 1239 | + | .with_home(root.join("home-a")); |
|
| 1240 | + | a.create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 1241 | + | .unwrap(); |
|
| 1242 | + | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 1243 | + | a.commit("a", &[]).unwrap(); |
|
| 1244 | + | a.policy_init().unwrap(); |
|
| 1245 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1246 | + | let hub_sk = SecretKey::generate(); |
|
| 1247 | + | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 1248 | + | a.policy_grant_subject( |
|
| 1249 | + | "agent:claude@ada", |
|
| 1250 | + | hub.clone(), |
|
| 1251 | + | Scope::CHANGES | Scope::REFS, |
|
| 1252 | + | Some("*"), |
|
| 1253 | + | ) |
|
| 1254 | + | .unwrap(); |
|
| 1255 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1256 | + | ||
| 1257 | + | let now = Hlc::now().wall_ms; |
|
| 1258 | + | // An agent whose key the hub certified from `from` to `to`. |
|
| 1259 | + | let agent = |name: &str, from: u64, to: u64| { |
|
| 1260 | + | let mut r = Repo::clone(&url, &root.join(name)) |
|
| 1261 | + | .unwrap() |
|
| 1262 | + | .with_home(root.join(format!("home-{name}"))); |
|
| 1263 | + | r.store().put_identity(&hub).unwrap(); |
|
| 1264 | + | let (_, id) = r |
|
| 1265 | + | .create_identity(name, "[email protected]", IdentityScope::User) |
|
| 1266 | + | .unwrap(); |
|
| 1267 | + | let cert = SignedCertificate::sign( |
|
| 1268 | + | Certificate { |
|
| 1269 | + | key: id, |
|
| 1270 | + | issuer: hub.hash(), |
|
| 1271 | + | subject: "agent:claude@ada".into(), |
|
| 1272 | + | claims: vec![], |
|
| 1273 | + | scope: Scope::CHANGES | Scope::REFS, |
|
| 1274 | + | not_before: from, |
|
| 1275 | + | expires: to, |
|
| 1276 | + | grant: None, |
|
| 1277 | + | }, |
|
| 1278 | + | &hub, |
|
| 1279 | + | &hub_sk, |
|
| 1280 | + | ); |
|
| 1281 | + | r.store().put_certificate(&cert).unwrap(); |
|
| 1282 | + | r |
|
| 1283 | + | }; |
|
| 1284 | + | ||
| 1285 | + | // A tag by a key certified now, pushed to a host that records no |
|
| 1286 | + | // acceptance (a path on disk): it lands, but a fetch has no time to |
|
| 1287 | + | // check its certificate at, so it is left out and the fetch goes on. |
|
| 1288 | + | let claude = agent("claude", now - 60_000, now + 600_000); |
|
| 1289 | + | let heads = claude.bookmark_heads().unwrap(); |
|
| 1290 | + | claude.tag_create("v1", &heads, "one").unwrap(); |
|
| 1291 | + | let r = claude.push("origin", false).unwrap(); |
|
| 1211 | 1292 | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 1293 | + | let b = Repo::clone(&url, &root.join("b")) |
|
| 1294 | + | .unwrap() |
|
| 1295 | + | .with_home(root.join("home-b")); |
|
| 1296 | + | let rep = b.fetch("origin").unwrap(); |
|
| 1297 | + | assert!( |
|
| 1298 | + | rep.skipped |
|
| 1299 | + | .iter() |
|
| 1300 | + | .any(|(n, why)| n == "tags/v1" && why.contains("no time to check its certificate at")), |
|
| 1301 | + | "{rep:?}" |
|
| 1302 | + | ); |
|
| 1303 | + | assert_eq!(b.tag_heads("v1").unwrap(), None); |
|
| 1304 | + | ||
| 1305 | + | // The host's acceptance, signed by the certificate's issuer, served |
|
| 1306 | + | // with the tag: fetched. |
|
| 1307 | + | let host = Repo::open(&root.join("origin")) |
|
| 1308 | + | .unwrap() |
|
| 1309 | + | .with_acceptor(hub.clone(), hub_sk.clone()); |
|
| 1310 | + | let (v1, _) = host.tag_attest("v1").unwrap().unwrap(); |
|
| 1311 | + | host.record_acceptance(&v1).unwrap(); |
|
| 1312 | + | let served = host.tag_refs().unwrap(); |
|
| 1313 | + | assert!(served |
|
| 1314 | + | .iter() |
|
| 1315 | + | .any(|t| t.name == "v1" && t.accepted.is_some())); |
|
| 1316 | + | let rep = b.fetch("origin").unwrap(); |
|
| 1317 | + | assert!(rep.skipped.is_empty(), "{rep:?}"); |
|
| 1318 | + | assert_eq!(b.tag_heads("v1").unwrap(), Some(heads.clone())); |
|
| 1319 | + | // gc keeps the acceptance with the tag. |
|
| 1320 | + | host.gc(true).unwrap(); |
|
| 1321 | + | assert!(host.acceptance_of(&v1).unwrap().is_some()); |
|
| 1322 | + | ||
| 1323 | + | // Long after: a key whose certificate expired an hour ago may not |
|
| 1324 | + | // create a tag now. |
|
| 1325 | + | let late = agent("late", now - 7_200_000, now - 3_600_000); |
|
| 1326 | + | late.tag_create("v2", &heads, "two").unwrap(); |
|
| 1327 | + | let r = late.push("origin", false).unwrap(); |
|
| 1328 | + | assert!( |
|
| 1329 | + | r.iter() |
|
| 1330 | + | .any(|x| x.name == "tags/v2" && !x.ok && x.reason.contains("may not create")), |
|
| 1331 | + | "{r:?}" |
|
| 1332 | + | ); |
|
| 1333 | + | // With its issuer's acceptance from inside the window (as a hub gave |
|
| 1334 | + | // it then, passed on to another host), it is taken at that time: by |
|
| 1335 | + | // the host it is pushed to, which keeps it, and by a fresh clone. |
|
| 1336 | + | let (v2, _) = late.tag_attest("v2").unwrap().unwrap(); |
|
| 1337 | + | let acc = Attest { |
|
| 1338 | + | repo: late.id().unwrap(), |
|
| 1339 | + | hlc: Hlc { |
|
| 1340 | + | wall_ms: now - 5_400_000, |
|
| 1341 | + | logical: 0, |
|
| 1342 | + | }, |
|
| 1343 | + | body: AttestBody::Accepted { attest: v2 }, |
|
| 1344 | + | }; |
|
| 1345 | + | let sig = hub_sk.sign(Domain::Attest, &hub.hash(), &acc.hash()); |
|
| 1346 | + | // One by any other key gives no time. |
|
| 1347 | + | let stranger_sk = SecretKey::generate(); |
|
| 1348 | + | let stranger = stranger_sk.identity("stranger", "[email protected]", 1); |
|
| 1349 | + | let forged = stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.hash()); |
|
| 1350 | + | late.keep_acceptance( |
|
| 1351 | + | &v2, |
|
| 1352 | + | &SignedAttest { |
|
| 1353 | + | attest: acc.clone(), |
|
| 1354 | + | sigs: vec![forged], |
|
| 1355 | + | }, |
|
| 1356 | + | ) |
|
| 1357 | + | .unwrap(); |
|
| 1358 | + | let r = late.push("origin", false).unwrap(); |
|
| 1359 | + | assert!( |
|
| 1360 | + | r.iter().any(|x| x.name == "tags/v2" && !x.ok), |
|
| 1361 | + | "not the issuer's: {r:?}" |
|
| 1362 | + | ); |
|
| 1363 | + | let theirs = SignedAttest { |
|
| 1364 | + | attest: acc, |
|
| 1365 | + | sigs: vec![sig], |
|
| 1366 | + | }; |
|
| 1367 | + | // Kept beside the stranger's: the issuer's is the one sent. |
|
| 1368 | + | late.keep_acceptance(&v2, &theirs).unwrap(); |
|
| 1369 | + | let r = late.push("origin", false).unwrap(); |
|
| 1370 | + | // (Its re-attestation of main is refused: an expired key moves no |
|
| 1371 | + | // bookmark.) |
|
| 1372 | + | assert!(r.iter().any(|x| x.name == "tags/v2" && x.ok), "{r:?}"); |
|
| 1212 | 1373 | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 1213 | - | assert!(host.tag_refs().unwrap().iter().any(|t| t.name == "v1"), "the tag arrived"); |
|
| 1374 | + | let (hv2, _) = host.tag_attest("v2").unwrap().unwrap(); |
|
| 1375 | + | assert_eq!(hv2, v2); |
|
| 1376 | + | assert!(host |
|
| 1377 | + | .store() |
|
| 1378 | + | .index_get_pub("accepted", &v2) |
|
| 1379 | + | .unwrap() |
|
| 1380 | + | .contains(&theirs.attest.hash())); |
|
| 1381 | + | let c = Repo::clone(&url, &root.join("c")) |
|
| 1382 | + | .unwrap() |
|
| 1383 | + | .with_home(root.join("home-c")); |
|
| 1384 | + | assert_eq!(c.tag_heads("v2").unwrap(), Some(heads.clone())); |
|
| 1385 | + | assert_eq!(c.tag_heads("v1").unwrap(), Some(heads)); |
|
| 1214 | 1386 | } |
|
| 1215 | 1387 | ||
| 1216 | 1388 | /// oxygenhub#266: a short-lived key its issuer certified moves a bookmark |
|
| … | 1709 unchanged lines |
||
| 2926 | 3098 | a.policy_init().unwrap(); |
|
| 2927 | 3099 | a.push("origin", false).unwrap(); |
|
| 2928 | 3100 | origin.refresh().unwrap(); |
|
| 2929 | - | let unattested = crate::protocol::TagRef { |
|
| 2930 | - | name: "v3".into(), |
|
| 2931 | - | state: origin |
|
| 3101 | + | let unattested = crate::protocol::TagRef::new( |
|
| 3102 | + | "v3".into(), |
|
| 3103 | + | origin |
|
| 2932 | 3104 | .store() |
|
| 2933 | 3105 | .put_state(&StateObject::new(vec![h2])) |
|
| 2934 | 3106 | .unwrap(), |
|
| 2935 | - | heads: vec![h2], |
|
| 2936 | - | attestation: None, |
|
| 2937 | - | }; |
|
| 3107 | + | vec![h2], |
|
| 3108 | + | None, |
|
| 3109 | + | ); |
|
| 2938 | 3110 | let r = origin |
|
| 2939 | 3111 | .receive_push(Bundle::default(), vec![], None, vec![unattested]) |
|
| 2940 | 3112 | .unwrap(); |
|
| … | 6753 unchanged lines |
modified crates/oxy-core/src/repo/transparency.rs +48 −15
| … | 333 unchanged lines |
||
| 334 | 334 | out.notes.extend(accepted.notes.iter().cloned()); |
|
| 335 | 335 | let witnesses = self.pin_witnesses(&hub, &resp.witnesses, &mut out.notes)?; |
|
| 336 | 336 | let mut missing = 0; |
|
| 337 | - | for r in &resp.refs { |
|
| 338 | - | let Some(a) = &r.attestation else { continue }; |
|
| 339 | - | let Some(l) = &r.logged else { |
|
| 337 | + | // Each attestation's place in the log, and its integrated time. |
|
| 338 | + | let mut place = |name: &str, |
|
| 339 | + | kind: &str, |
|
| 340 | + | a: &SignedAttest, |
|
| 341 | + | logged: Option<&crate::protocol::Logged>, |
|
| 342 | + | integration: &[crate::protocol::Integration]| |
|
| 343 | + | -> std::result::Result<Integrated, String> { |
|
| 344 | + | let Some(l) = logged else { |
|
| 340 | 345 | missing += 1; |
|
| 341 | - | out.times |
|
| 342 | - | .insert(r.name.clone(), Err("it is not in the log yet".into())); |
|
| 343 | - | continue; |
|
| 346 | + | return Err("it is not in the log yet".into()); |
|
| 344 | 347 | }; |
|
| 345 | 348 | let fits = Entry::decode(&l.entry).is_ok_and(|e| { |
|
| 346 | - | e.kind == "ref" && e.object == a.attest.hash() && e.repo == a.attest.repo |
|
| 349 | + | e.kind == kind && e.object == a.attest.hash() && e.repo == a.attest.repo |
|
| 347 | 350 | }); |
|
| 348 | 351 | let included = fits |
|
| 349 | 352 | && tlog::check_inclusion( |
|
| … | 6 unchanged lines |
||
| 356 | 359 | .is_ok(); |
|
| 357 | 360 | if !included { |
|
| 358 | 361 | out.notes.push(format!( |
|
| 359 | - | "{hub}'s log entry {} for {} does not prove its attestation is in the log", |
|
| 360 | - | l.index, r.name |
|
| 362 | + | "{hub}'s log entry {} for {name} does not prove its attestation is in the log", |
|
| 363 | + | l.index |
|
| 361 | 364 | )); |
|
| 362 | 365 | } |
|
| 363 | - | let time = if !fits { |
|
| 366 | + | if !fits { |
|
| 364 | 367 | Err("its log entry is not its attestation's".to_string()) |
|
| 365 | 368 | } else if witnesses.is_empty() { |
|
| 366 | 369 | Err(format!("{hub} names no witness")) |
|
| 367 | 370 | } else { |
|
| 368 | - | let cosigned: Vec<(&str, &[Hash])> = r |
|
| 369 | - | .integration |
|
| 371 | + | let cosigned: Vec<(&str, &[Hash])> = integration |
|
| 370 | 372 | .iter() |
|
| 371 | 373 | .map(|i| (i.note.as_str(), i.proof.as_slice())) |
|
| 372 | 374 | .collect(); |
|
| … | 5 unchanged lines |
||
| 378 | 380 | &accepted.identity, |
|
| 379 | 381 | &witnesses, |
|
| 380 | 382 | ) |
|
| 381 | - | }; |
|
| 382 | - | out.times.insert(r.name.clone(), time); |
|
| 383 | + | } |
|
| 384 | + | }; |
|
| 385 | + | let mut times = HashMap::new(); |
|
| 386 | + | for r in &resp.refs { |
|
| 387 | + | let Some(a) = &r.attestation else { continue }; |
|
| 388 | + | let time = place(&r.name, "ref", a, r.logged.as_ref(), &r.integration); |
|
| 389 | + | times.insert(r.name.clone(), time); |
|
| 390 | + | } |
|
| 391 | + | // Tags too (oxygen#129): a short-lived key's tag is checked at its |
|
| 392 | + | // integrated time on every later fetch. |
|
| 393 | + | let mut tag_times = HashMap::new(); |
|
| 394 | + | for t in &resp.tags { |
|
| 395 | + | let Some(a) = &t.attestation else { continue }; |
|
| 396 | + | let name = format!("tags/{}", t.name); |
|
| 397 | + | let time = place(&name, "tag", a, t.logged.as_ref(), &t.integration); |
|
| 398 | + | tag_times.insert(t.name.clone(), time); |
|
| 383 | 399 | } |
|
| 400 | + | out.times = times; |
|
| 401 | + | out.tag_times = tag_times; |
|
| 384 | 402 | if missing > 0 { |
|
| 385 | 403 | out.notes.push(format!( |
|
| 386 | 404 | "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)", |
|
| … | 24 unchanged lines |
||
| 411 | 429 | pub notes: Vec<String>, |
|
| 412 | 430 | /// Each attested bookmark's integrated time, or why it has none. |
|
| 413 | 431 | pub times: HashMap<String, std::result::Result<Integrated, String>>, |
|
| 432 | + | /// Each attested tag's, by tag name. |
|
| 433 | + | pub tag_times: HashMap<String, std::result::Result<Integrated, String>>, |
|
| 414 | 434 | } |
|
| 415 | 435 | ||
| 416 | 436 | impl LogCheck { |
|
| 417 | 437 | /// Bookmark `name`'s integrated time, or why there is none. |
|
| 418 | 438 | pub fn time_of(&self, name: &str) -> std::result::Result<&Integrated, String> { |
|
| 439 | + | self.lookup(&self.times, name) |
|
| 440 | + | } |
|
| 441 | + | ||
| 442 | + | /// Tag `name`'s integrated time, or why there is none. |
|
| 443 | + | pub fn tag_time_of(&self, name: &str) -> std::result::Result<&Integrated, String> { |
|
| 444 | + | self.lookup(&self.tag_times, name) |
|
| 445 | + | } |
|
| 446 | + | ||
| 447 | + | fn lookup<'a>( |
|
| 448 | + | &self, |
|
| 449 | + | times: &'a HashMap<String, std::result::Result<Integrated, String>>, |
|
| 450 | + | name: &str, |
|
| 451 | + | ) -> std::result::Result<&'a Integrated, String> { |
|
| 419 | 452 | if let Some(why) = &self.unchecked { |
|
| 420 | 453 | return Err(format!("its host's log was not checked ({why})")); |
|
| 421 | 454 | } |
|
| 422 | 455 | if !self.kept { |
|
| 423 | 456 | return Err("its host keeps no transparency log".into()); |
|
| 424 | 457 | } |
|
| 425 | - | match self.times.get(name) { |
|
| 458 | + | match times.get(name) { |
|
| 426 | 459 | Some(Ok(i)) => Ok(i), |
|
| 427 | 460 | Some(Err(why)) => Err(why.clone()), |
|
| 428 | 461 | None => Err("it has no log entry".into()), |
|
| … | 162 unchanged lines |
modified docs/protocol.md +1 −1
| … | 88 unchanged lines |
||
| 89 | 89 | | Capabilities | `{0: [protocol versions], 1: repo id bytes(16), 2: [feature names]}`; version `1`; features currently `["seal"]` | |
|
| 90 | 90 | | Refs response | `{0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}` | |
|
| 91 | 91 | | ref | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}`; the attestation is present only when it describes the current state; `last serial` is the highest attestation serial ever recorded for the name; key 5 (optional, only when there is one) is the host's `accepted` attestation of that attestation (spec §14.7); key 6 (optional) is `[entry number, entry bytes, [proof hash]]`, the attestation's inclusion proof in the host's transparency log against the response's key 6, the latest checkpoint as a signed note (spec §14.8) | |
|
| 92 | - | | tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 92 | + | | tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: signed acceptance, 5: log place, 6: [cosigned checkpoint]}`; keys 4 to 6 (optional, each only when there is one; oxygen#129) are for the tag's attestation what a ref's keys 5 to 7 are for its own: the issuer's `accepted` attestation when a short-lived key signed the tag (spec §14.7), which a fetch checks the certificate at, and its place in the host's transparency log with the cosigned checkpoints that first held it (spec §14.8); a push carries key 4 when the client has one; older readers ignore them | |
|
| 93 | 93 | | Negotiate request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order (no change before its dependencies) | |
|
| 94 | 94 | | Fetch request | `{0: [hashes], 1: max blob size or null}`; response a Bundle whose blobs above the size (and their chunks) are omitted | |
|
| 95 | 95 | | Objects request | `{0: [refs]}`; response a Bundle carrying only objects; a blob is accompanied by its chunks | |
|
| … | 108 unchanged lines |
modified docs/spec.md +6 −6
| … | 433 unchanged lines |
||
| 434 | 434 | ||
| 435 | 435 | Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update. A fetched bookmark whose attestation cannot be verified because its signer's identity is unknown to the client or its signature does not verify is skipped, not fatal, when it is not the current bookmark: its `remotes/` reference stays as it was, the client warns naming it and why, and the other bookmarks are fetched (oxygenhub#266). For the current bookmark (the one a `pull` takes, or a `clone` checks out) the fetch, pull or clone still fails, and an attestation that is missing under a policy, unauthorized, rolled back or not describing the update fails the fetch as before. |
|
| 436 | 436 | ||
| 437 | - | Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them. |
|
| 437 | + | Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them. A tag signed by a short-lived key (§14.7), as a keyless agent's is, is authorized by a subject grant like a bookmark update: on a push at the receiver's clock, on a fetch at a time its signer did not choose (§14.7, *Acceptance*). The push carries the tag signer's identity and certificate as it does the bookmark signer's, since the tag may have been made by an earlier process's key (oxygen#129). |
|
| 438 | 438 | ||
| 439 | 439 | ## 11. Checkpoints |
|
| 440 | 440 | ||
| … | 272 unchanged lines |
||
| 713 | 713 | - The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys of the policy it replaces and have a higher `serial`. An update that a single holder cannot complete is a **proposal**: the signed policy (`[policy bytes, [Signature]]`, §16.3) is passed to other root holders, each of whom adds a signature with `oxy policy sign`; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it. |
|
| 714 | 714 | - A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's `author` (or a successor) when the signer is that author's delegate. |
|
| 715 | 715 | - A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it with the `refs` scope, not expired at the attestation's `hlc.wall_ms` (§14.2), or, on a fetch, at its integrated time when the host's log gives one (§14.8). Delegation is the identity's own signed statement and the policy names that identity, so a delegate with `refs` moves references exactly as far as its delegator may; revoking either key, or the delegation expiring, ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. A delegate without `refs` (an agent) is never authorized for a reference or tag, even when its delegator is. |
|
| 716 | - | - A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. The time is the receiver's own clock when it receives a push (changes and the reference attestation), and, on a fetch, the attestation's integrated time when the host's log gives one (§14.8, *Integrated time*); without one, the time the certificate's issuer says it accepted the update (§14.7, *Acceptance*), or the attestation's own time when there is no such acceptance, which is weaker: a key whose certificate expired could backdate an attestation within its scope. A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged. |
|
| 716 | + | - A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. The time is the receiver's own clock when it receives a push (changes and the reference attestation), and, on a fetch, the attestation's integrated time when the host's log gives one (§14.8, *Integrated time*); without one, the time the certificate's issuer says it accepted the update (§14.7, *Acceptance*), or the attestation's own time when there is no such acceptance, which is weaker: a key whose certificate expired could backdate an attestation within its scope. A tag is never checked at its own time: a fetch without an integrated time or an acceptance for it leaves the tag out and says why, and fetches the rest (oxygen#129). A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged. |
|
| 717 | 717 | - A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`. |
|
| 718 | 718 | - Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push. |
|
| 719 | 719 | - A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors. |
|
| … | 63 unchanged lines |
||
| 783 | 783 | ||
| 784 | 784 | A signed grant is `[grant bytes, Signature]`: the grantor's composite signature, in domain `grant`, over the grant's ObjectRef (type `grant`). It needs no maximum lifetime: it gives nothing until an issuer certifies a key under it, and the grantor revokes it by asking the issuer to stop. The issuer puts it in each certificate it issues under it (field 8), so it travels with the certificate and a verifier needs nothing else. A signature by a key that is neither a change's author nor its delegate (§14.4) still satisfies the author-or-delegate rule when the key's certificate verifies with its issuer and carries a grant that verifies with the change's author, names the certificate's subject and issuer, gives the scope needed (`changes` for a change signature, `seal` for a seal) in both the grant and the certificate, and covers the change's `hlc.wall_ms`, as a delegation does. Whether the certificate covered the time the key signed is the policy's check (§14.5), at a time the signer did not choose. |
|
| 785 | 785 | ||
| 786 | - | **Acceptance.** A host that issues certificates records when it accepted a reference attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation) and serves it with the bookmark (§16, `refs` key 5). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have. A fetch that has the attestation's integrated time (§14.8) checks at that instead: the acceptance is the issuer's clock, the integrated time its witnesses'. |
|
| 786 | + | **Acceptance.** A host that issues certificates records when it accepted a reference or tag attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation or the tag's) and serves it with the bookmark (§16, `refs` entry key 5) or the tag (tag key 4). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time for a bookmark, and not at all for a tag, which is left out (§14.5). Tags are kept for good and fetched long after a ten-minute certificate expired, so the issuer's acceptance travels with the tag: a client that fetched one keeps it and serves it with the tag, a push carries it, and a receiver checks a tag that comes with its issuer's acceptance at that time when its own clock is past the certificate's window (a tag passed on from the host that accepted it to another), keeping the acceptance rather than recording its own. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have. A fetch that has the attestation's integrated time (§14.8) checks at that instead: the acceptance is the issuer's clock, the integrated time its witnesses'. |
|
| 787 | 787 | ||
| 788 | 788 | ### 14.8 The transparency log |
|
| 789 | 789 | ||
| … | 43 unchanged lines |
||
| 833 | 833 | **Integrated time** (oxygenhub#240). A cosignature is a witness's statement, by its own clock, that the checkpoint existed then, so every entry the checkpoint holds existed by that time, whatever its signer or the host claims. An entry's **integrated time** is the earliest time a witness the reader trusts cosigned a checkpoint that holds it: an upper bound on when its object was signed, in seconds, by a clock neither the signer nor the host chose. |
|
| 834 | 834 | - **Witness keys.** A host lists the witnesses it asks to cosign, each as its name in cosignatures and its identity (refs response key 7, §16.3; and `GET /log/witnesses`, one `<name> <base64 identity>` line each). A client pins them per user and hub beside the log key (`<oxy home>/tlog/<hub>.witnesses`): the first list silently, as the log key on first sight; a witness listed later, or listed under a pinned name with another key, is pinned with a note, as a new log key is. A witness the host stops listing stays pinned. A cosignature counts only when both its lines verify with a pinned witness's key; lines by anyone else are ignored. Trust-on-first-use gives no more than the host's own word on first contact, which is what the acceptance gives today (§14.7); after it, a host that names a witness of its own to move a time earlier is noted by every client that pinned the real ones. |
|
| 835 | 835 | - **What a host serves.** With a bookmark's entry (ref key 6), the cosigned checkpoints that first held it (ref key 7): each a checkpoint note with the log's signatures and its witnesses' cosignatures, and the entry's inclusion proof against it, one for each witness when they first cosigned different checkpoints. A client takes one when its origin is the log's, it verifies with the pinned log key, the entry is the attestation's (as above) and the proof verifies; the integrated time is the earliest trusted cosignature among those it takes. A host can only make the time later by serving a later checkpoint, which weakens the bound and helps no one but a signer it would refuse; it cannot make it earlier without a witness's key. |
|
| 836 | - | - **Use.** On a fetch, a check that depends on when a reference attestation was signed (whether a delegation with `refs` was in force, §14.5, or whether a certificate covered it, §14.7) is made at the integrated time when there is one. Without one (the host keeps no log, the entry is in no cosigned checkpoint yet, or no trusted witness cosigned it), it is made as before, at the issuer's acceptance or the attestation's own time, and the fetch says so when the host keeps a log. While logs are new (oxygenhub docs/transparency.md, decision 3), an attestation that fails at its integrated time but passes as before is accepted with a note rather than refused; one that fails both is refused, as before. A key the rule lists itself is authorized whenever it signed, and revocations are absolute (§14.5): neither depends on the time. A push is checked at the receiver's own clock, as before. |
|
| 836 | + | - **Use.** On a fetch, a check that depends on when a reference or tag attestation was signed (whether a delegation with `refs` was in force, §14.5, or whether a certificate covered it, §14.7) is made at the integrated time when there is one. Without one (the host keeps no log, the entry is in no cosigned checkpoint yet, or no trusted witness cosigned it), it is made as before, at the issuer's acceptance or the attestation's own time (for a tag, at the acceptance only, §14.7), and the fetch says so when the host keeps a log. While logs are new (oxygenhub docs/transparency.md, decision 3), an attestation that fails at its integrated time but passes as before is accepted with a note rather than refused; one that fails both is refused, as before. A key the rule lists itself is authorized whenever it signed, and revocations are absolute (§14.5): neither depends on the time. A push is checked at the receiver's own clock, as before. |
|
| 837 | 837 | - **A large gap.** For an attestation whose check depends on the time, a claimed time (`hlc.wall_ms`) more than 10 minutes before its integrated time, or more than 10 minutes after it, is noted, not refused: the attestation was backdated, held back before it was logged (one a host accepted before its log began, logged when first served), or signed on a wrong clock. A reference attestation is made at push and logged and cosigned within seconds, so ten minutes leaves room for drift and for a witness that was briefly away. |
|
| 838 | 838 | ||
| 839 | 839 | **`oxy tlog verify [HUB]`** audits a whole log: it accepts the checkpoint as a fetch does, downloads every entry bundle, and requires the entries to make the root the checkpoint signs. It names the trusted witnesses that cosigned the checkpoint and when (pinning those `GET /log/witnesses` lists, as a fetch does), a bound on when every entry was made. It then lists this repository's entries (`--key KEYID` for a key's, `--all` for every one, `--json` for machines). |
|
| … | 55 unchanged lines |
||
| 895 | 895 | | Message | Encoding | |
|
| 896 | 896 | |---|---| |
|
| 897 | 897 | | Signed attestation | `[attest bytes, [Signature]]` | |
|
| 898 | - | | `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; a push that moves a bookmark also carries the identity that signed the move and, for a delegate, the identity it acts for, so a receiver that has seen no change by that key can check it (oxygenhub#129); key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending; key 4 (optional, only when there are any) holds signed certificates (§14.7) of short-lived keys that signed what the bundle carries or the push's attestation, their keys' and issuers' identities among the objects; a receiver keeps each that verifies with its issuer and refuses a bundle with one that does not | |
|
| 898 | + | | `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; a push that moves a bookmark also carries the identity that signed the move and, for a delegate, the identity it acts for, so a receiver that has seen no change by that key can check it (oxygenhub#129), and a push that sends tags carries each tag signer's identity and, for a short-lived key, its certificate likewise (oxygen#129); key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending; key 4 (optional, only when there are any) holds signed certificates (§14.7) of short-lived keys that signed what the bundle carries or the push's attestation, their keys' and issuers' identities among the objects; a receiver keeps each that verifies with its issuer and refuses a bundle with one that does not | |
|
| 899 | 899 | | `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` | |
|
| 900 | 900 | | Signed policy | `[policy bytes, [Signature]]` | |
|
| 901 | - | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; an entry's key 5 (optional, only when there is one) is the host's acceptance of that attestation (§14.7); an entry's key 6 (optional) is the attestation's place in the host's transparency log, `[entry number, entry bytes, [proof hash]]`, an inclusion proof against the checkpoint in the response's key 6 (§14.8); an entry's key 7 (optional, only beside key 6 and when there are any) lists the cosigned checkpoints that first held that log entry, `[[checkpoint note text, [proof hash]]]`, each with the entry's inclusion proof against it, from which its integrated time is read (§14.8); the response's key 6 (optional, text) is the host's latest checkpoint, a signed note; the response's key 7 (optional, only when there are any) lists the witnesses the host asks to cosign, `[[name, identity bytes]]`, which a client pins (§14.8); a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 901 | + | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; an entry's key 5 (optional, only when there is one) is the host's acceptance of that attestation (§14.7); an entry's key 6 (optional) is the attestation's place in the host's transparency log, `[entry number, entry bytes, [proof hash]]`, an inclusion proof against the checkpoint in the response's key 6 (§14.8); an entry's key 7 (optional, only beside key 6 and when there are any) lists the cosigned checkpoints that first held that log entry, `[[checkpoint note text, [proof hash]]]`, each with the entry's inclusion proof against it, from which its integrated time is read (§14.8); the response's key 6 (optional, text) is the host's latest checkpoint, a signed note; the response's key 7 (optional, only when there are any) lists the witnesses the host asks to cosign, `[[name, identity bytes]]`, which a client pins (§14.8); a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}`, and optionally, each only when there is one, `4: signed acceptance`, `5: log place`, `6: [cosigned checkpoint]`, meaning for the tag's attestation what a bookmark entry's keys 5 to 7 mean for its attestation (oxygen#129); a reader that does not know them ignores them | |
|
| 902 | 902 | | `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order | |
|
| 903 | 903 | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size; also when it names no change, the bundle carries the identities of the keys that signed the attestations served with `refs` (each bookmark's latest and each tag's) and, for a short-lived key, its certificate and its issuer's identity (§14.7), each once, so a client can check an attestation whose signer signed none of the changes it receives, such as a delegate's or a keyless agent's bookmark move (oxygenhub#266) | |
|
| 904 | 904 | | `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks | |
|
| … | 282 unchanged lines |